Box2D physics on a phone, written in sysl, drawn by SDL3. A boxful of discs, squares and triangles with no gravity, perfect restitution and a little friction, so nothing ever settles and everything tumbles. Tap to throw in another.
An independent sysl package for Android. It is not affiliated with or endorsed by Google.
To start an Android project, clone
sysl-lang/skitter-app — two lines in
gradle.properties and a program/main.sysl. This repository is a demo, and everything about how
a sysl program becomes an APK is documented in skitter-app: the SDL_main export, the CMake
inversion, the system-bar bridge, the orientation and theme traps. This README covers only what is
particular to the physics.
sysl-lang/skitter the system bars, the orientation pair, and the Java-side activity
sysl-lang/box2d rigid-body physics — Box2D v3, vendored
Two coordinates and neither of them is SDL3, because imports are transitive: Skitter reaches it. Skitter is at 0.3.0, which sets the compiler floor at sysl 0.0.161.
On a phone, standard output and standard error go nowhere, so SDL_main calls Skitter's
logcat("bouncing") before anything else. From then on every sysl.log record becomes a logcat
entry under that tag:
adb logcat -s bouncing
It logs start with the version, box each time the walls are built (the window in pixels, the box
in metres, and how many bodies were thrown in), and stop. CMakeLists.txt links log (Android's
liblog) for this, because Gradle's CMake owns the final link and never sees the module's
@link("log"). On a desktop the call does nothing and the records go to standard error.
This used to be androidkit's demo and androidkit used to be the template. It is neither now —
the machinery in this repository is byte-identical to skitter-app's rather than a copy of
androidkit's with the names changed, which is what the whole port was for. What that removed here was
about seven hundred lines of Gradle, CMake, manifest and sbt duplication and, more to the point, a
hand-written JNI symbol (Java_sh_sysl_androidbouncing_MainActivity_nativeSetSystemBars) that had to
be kept in step with a Scala class renamed into this application's package.
Download the APK and open it on an Android phone.
- Android 8.0 or newer (
minSdk 26), and arm64 — which is every phone since about 2015. It will not install on an x86_64 emulator, because sysl has one Android target and that isaarch64-android. - Your phone will ask whether to allow installing from wherever you downloaded it. That prompt is what sideloading is; it is not a warning about this app in particular.
- It is signed with the project's own key rather than by a store, so it is not checked by anyone but you. The source is right here.
The demo is Box2D physics drawn by SDL3: a boxful of discs, squares and triangles with no gravity, perfect restitution and no friction, so nothing ever settles. One body carries an image. Tap to throw in another.
Two of Box2D's defaults have to be turned off or it all stops, and neither is a fact about physics:
restitution_threshold— Box2D ignores restitution below a relative speed, one metre per second by default, because a stack that keeps bouncing a little is jitter. Here it means every glancing hit is inelastic and the box goes quiet within a minute. Zero says always bounce.contact_damping_ratio— the default of 10 is heavily damped, which is right for a pile that should settle and wrong for this.
And the two settings are not enough on their own, because a rigid-body solver is not
energy-preserving and no tuning makes Box2D one. A soft-step integrator applies restitution once
per contact with a bounded impulse, so it under-restores; and the friction below dissipates whenever
a contact slides. So the demo measures the kinetic energy and puts it back: ½mv² + ½Iω² over
every loose body, and since scaling every velocity by s scales energy by s², restoring a target
is one square root and one pass.
That is a governor rather than physics, and it is the honest way to have a demo that never winds down — the alternative is pretending a number that keeps falling is conserved. The target rises whenever it is exceeded, so a tap adds its body's energy to the budget rather than being scaled away.
And a little friction, which is what lets a collision change a body's spin at all. Every impulse on a frictionless disc points along the line between the two centres, so it passes through the centre of mass and its torque is exactly zero — a frictionless circle can only ever keep the spin it was thrown with. Box2D mixes the two shapes' friction as their geometric mean, so one frictionless party makes the whole contact frictionless, which is why it is on the walls and on everything thrown rather than on some of it.
It is also the one thing here that genuinely dissipates, since sliding contact does — which is half of what the governor above exists to make up.
Neither sh.sysl.sdl3 nor sh.sysl.box2d needed a line changed to run here, and that is the
claim the demo exists to make:
- box2d is vendored Box2D v3 — portable C with
requires { heap, posix }, both of which Bionic answers. The NDK compiles it like any other C: 40 archive members, 660b2*symbols. - sdl3 is identical on a phone because
15 §8says a@linkdirective names a library and never a path. Only where the library lives changes, and that isCMakeLists.txt's business. - Taps need no finger handling. SDL synthesises mouse events from touch, so
EventKind.MouseButtonDownwithmouse_x/mouse_yis the whole of the input — and the same source runs on a desktop.
One thing did have to change, and it was the compiler. An Android program is always loaded as a
.so, and a vendored library's globals are ordinary C globals — preemptible — so ld.lld refused
them:
ld.lld: error: relocation R_AARCH64_ADR_PREL_PG_HI21 cannot be used against symbol
'b2AssertHandler'; recompile with -fPIC
Toolchain.compileC passed -fPIC nowhere. targets.md § Android had concluded no relocation model
was needed, which was measured on sysl's own object — where every global is Linkage.Private and so
not preemptible — and did not cover the C a package carries. Fixed in the compiler, not here.
There is no Java in this repository and no Kotlin either, and as of the Skitter port there is no
activity of this application's own either. SkitterActivity is Scala 3 and it is
skitter-app's; this repository carries a copy because
the template is cloned rather than depended on, but it is never renamed and never subclassed. The
Scala standard library is a dependency of the application, so an activity can use the language and
not only its syntax — proved rather than claimed, since the insets are logged through a List, a
zip, a map and an interpolated string, none of which links without the runtime.
What it costs is a second build system, and that is the whole of the cost. The Android Gradle
plugin compiles Java and Kotlin itself and has no Scala support, so activity/ is an sbt project and
app/build.gradle.kts runs it as a task and puts the jar on the classpath. ./gradlew assembleDebug
is still the one command; sbt has to be installed.
Two things bite, and both are silent:
- A
private@nativemethod does not work. Scala renames a private method reached from an inner class tosh$sysl$skitter$SkitterActivity$$nativeSetSystemBarsso the inner class can see it, and JNI then looks for a symbol with_00024in it that nothing defines. It compiles, links, and dies at the first call with anUnsatisfiedLinkError. The listener is an inner class, so this is exactly that case — leave the method non-private. It is Skitter's file now and already right; this is here because it is the kind of thing somebody re-derives while editing one. minSdkis 26 because ofscala-library.d8refuses to dex it below that — "Increase the minSdkVersion to 26 or above" — so an APK carrying the Scala runtime starts at Android 8.0. SDL's own floor is 21 and sysl's triple states 24; the three do not have to agree and the higher wins.
For comparison, Kotlin costs nothing at all under AGP 9 — it is compiled by AGP itself, and the
org.jetbrains.kotlin.android plugin is refused outright as no longer required. Scala is here
because it is what this project is written in everywhere else.
arm64-v8a, and that is not an apology. On an Apple Silicon host the emulator runs arm64-v8a, and
so does every Android device made since 2015 — one build covers both. x86_64 matters only on an
Intel host or a CI runner, and armeabi-v7a only for pre-2015 hardware; either is a line in
app/build.gradle.kts and a registry row in the compiler that does not exist yet.
sdl3 + sysl + one shell per surface is framework-shaped, and the layer that would carry a name —
the surface, input and lifecycle abstraction — does not exist. picokit hand-wires it for a panel
and this hand-wires it for a phone. Extracting it from one surface would be a guess; it gets
extracted when a second one makes the duplication visible. bouncing keeps its name either way: a
template is named for what it starts, which is why picokit is named for a board.
ISC. See LICENSE.