BoxUniverse: a physics sandbox, and the Box2D that wasn't there

This is the third old libGDX project I've dragged into the browser, after PrimalityGame and Graph_Space. BoxUniverse is from 2017, and the README I wrote back then describes it as "a prototype of a 2D/3D platformer where people can play and create their own maps like LittleBigPlanet". That's generous. What actually exists is a sandbox: there's a world made of textured blocks, gravity, and you can click to add more blocks and watch the pile collapse.

Which is honestly the fun part anyway.

Play it right here

It boots straight into a bundled map so there's something moving on screen immediately. Give it a few seconds, it's a 2.4 MB WebAssembly module.

Controls

  • Click anywhere to drop a new block at that point.
  • W A S D to move the camera. It's a perspective camera, and the pan speed scales with how far out you're zoomed.
  • Mouse wheel to zoom in and out.
  • P and O to cycle the selected material. The current one is shown as a texture swatch in the top left corner.
  • + and - to switch which layer you're building on.
  • R to wipe the world back to bare ground.
  • Y to reload the bundled map.

The layer thing is the "2D/3D" bit from the README. The world is a stack of independent 2D physics simulations at different depths along Z, all rendered with one perspective camera. Blocks in different layers never collide, they just visually overlap. It's a cheap trick to get depth out of a 2D physics engine, and switching layers with +/- while placing blocks is the closest this prototype gets to being a level editor.

The five materials

Every block is the same box. The only things that differ between materials are the texture and three numbers handed to the physics engine:

MaterialDensityRestitutionFriction
CrackedStone101
Brick501
Glass101
Ice100.1
Slime10.91

Density is mass per unit area, so Brick blocks are five times heavier than everything else and shove lighter blocks around when they land. Restitution is bounciness: 0 means a block lands and stays put, 0.9 means it keeps 90% of its speed on every bounce, which is why Slime blocks refuse to settle. Friction is how much sliding contact resists motion, so Ice at 0.1 slides off any slope it lands on.

Those two numbers are worth playing with directly, since they're most of what makes the materials feel different:

left box uses the sliders, the four behind it are the game's presets

The blue box on the left uses the sliders, the four behind it are the game's own presets. Drop them on the ramp and the friction difference shows up immediately: Ice keeps going, everything else grips. Crank restitution up and your box turns into Slime.

(That demo is a deliberately simple integrator, not the real engine. It handles one box against one immovable floor, which is a much easier problem than what the game does. Real contact resolution means every box pushing on every other box at once, all in the same instant, and that's the part that gets interesting below.)

Then I tried to compile it

The recipe from the other two ports is: point the gdx-teavm plugin at the existing libGDX code, compile it ahead-of-time to WebAssembly, embed the result in an iframe. The game's Java source barely changes.

That worked right up until the build spat out a few hundred lines like this:

Method com.badlogic.gdx.utils.BufferUtils.getUnsafeBufferAddress(Ljava/nio/Buffer;)J was not found
    at com.badlogic.gdx.physics.box2d.Body.<init>(Body.java:43)
    at com.badlogic.gdx.physics.box2d.World.createBody(World.java:302)
    at com.nsoft.boxuniverse.world.PhysicalBlock.<init>(BaseBlock.java:212)

Here's the thing about Box2D in libGDX: it isn't Java. Box2D is a C++ library, and libGDX ships a thin Java wrapper around it that calls into a compiled native library through JNI. On desktop that native library is a .so, on Android it's a .so per architecture, and in both cases getUnsafeBufferAddress does exactly what it sounds like, hands a raw pointer across to the C++ side.

A browser has no .so files to load and no raw pointers to hand out. So World, Body, Fixture and friends compile fine as Java classes but bottom out in native methods that cannot exist. The result isn't a runtime crash, it's the ahead-of-time compiler correctly refusing to invent an implementation.

There is an official answer to this: gdx-box2d-teavm, which cross-compiles Box2D's C++ to WebAssembly via Emscripten so the wrapper has something real underneath. I'd already flagged it as work-in-progress when I did the earlier ports. Digging in, it's worse than work-in-progress:

  • The last release of gdx-box2d-teavm is 1.0.0-b6, published July 2023.
  • It depends on backend-teavm:1.0.0-b6, from the era when the backend lived in the com.github.xpenatan.gdx.backends.teavm package.
  • The backend I'm actually using is backend-web:1.6.0, package com.github.xpenatan.gdx.teavm.backends.web, published two weeks ago.

Those are not the same library any more, they just share a GitHub repo. Adding the old Box2D artifact drags in the old backend alongside the new one, and the build starts failing in creative new ways as the two fight over which one owns Pixmap. The other extensions made the jump (gdx-freetype-web, gdx-controllers-web both exist and are current). There's no gdx-box2d-web. Box2D just didn't make it across.

The fix: physics that's already Java

The way out is almost embarrassingly simple once you stop trying to make the native path work. Box2D has a faithful pure-Java port called JBox2D. Same engine, same algorithms, same API shape, no JNI anywhere. And code with no native methods in it is just... code. It compiles to WebAssembly like anything else.

The game's actual Box2D usage turned out to be tiny. I grepped the whole codebase expecting a mess and found this:

  • new World(gravity) and world.step(dt, 6, 2)
  • world.createBody(bodyDef) and body.createFixture(fixtureDef)
  • reading back body.getPosition(), body.getAngle(), body.isAwake()

No joints, no contact listeners, no raycasts, no collision filtering. Boxes fall, boxes hit each other, boxes stop. The swap came down to changing which package the imports point at and one enum rename (BodyType.StaticBody became BodyType.STATIC), and it was done.

Before committing to it I checked JBox2D for the things that break under TeaVM, since a pure-Java library can still be pure-Java-that-doesn't-work-in-a-browser. Zero references to Thread, java.lang.reflect, java.io, or java.util.concurrent across all 167 classes. It's a numerical library that does arithmetic in a loop, which is the single most portable kind of Java there is.

There's a nice symmetry to this that I didn't notice until afterwards: NPhysics2, my high school physics simulator, ran on JBox2D too. Ten years later it's the thing that rescues the port.

What sequential impulses are actually doing

Since the whole post is about a physics engine, it's worth saying what world.step(dt, 6, 2) is doing with those two numbers, because they're the entire reason a pile of blocks behaves like a pile of blocks.

The naive approach to a physics step is: move everything by velocity times dt, find whatever's now overlapping, push it apart. This falls apart immediately with stacks. Push box A out of box B and you've shoved it into box C. Fix C and you've re-broken B. A tower of ten blocks resolved this way jitters, sinks into the floor, or explodes.

Box2D (and therefore JBox2D) uses sequential impulses. Instead of moving things apart, it computes, for each contact point, the impulse that would make the two bodies stop approaching each other along the contact normal, and applies it right then. Then it moves to the next contact and does the same, using the velocities that the previous contacts already modified. One sweep isn't enough (fixing the last contact disturbs the first), so it sweeps repeatedly. That's the 6: six velocity iterations per step. Each pass, the corrections get smaller, and the whole system converges toward a set of impulses that satisfies every contact at once.

The 2 is position iterations, a second, separate pass that deals with overlap that snuck in anyway, nudging bodies apart geometrically without touching their velocities.

This is why the numbers are iteration counts and not a tolerance. It isn't solving the contact system exactly, it's doing a fixed amount of Gauss-Seidel relaxation and accepting wherever it lands. More iterations means stiffer, more convincing stacks and more CPU per frame. Six and two are Box2D's defaults, and this game never had reason to change them.

The isAwake() check in the render loop is the other half of the performance story. A body that's barely moved for long enough gets put to sleep and skipped by the solver entirely until something touches it. In a sandbox where you spawn a hundred blocks and most of them are sitting in a settled heap, that's most of the world doing nothing every frame, which is exactly what you want.

Two other things that fought me

Neither is about physics, but both cost me a build cycle, so they go in the notes.

Field reflection doesn't exist. The map format is JSON, and the debug save path called Json.prettyPrint(worldLoader), which walks an object's fields reflectively to serialize them. TeaVM's WASM-GC output has no field reflection at all, and it says so at build time, at length, in the form of com.badlogic.gdx.utils.reflect.Field.get was not found. The map loader had already been converted to walk the JSON tree by hand for exactly this reason. The saver hadn't. Driving the existing hand-written write() method through an explicit writer fixed it, no reflection involved.

HashMap.entrySet() takes down the compiler. This one I did not expect:

Failed generating method body due to internal exception: java.lang.NullPointerException
    at org.teavm.backend.wasm.generate.methods.WasmGCMethodGenerator.generateRegularMethodBody
    at java.util.HashMap$HashMapEntrySet.iterator
    at com.nsoft.boxuniverse.world.WorldLoader.loadMap(WorldLoader.java:151)

That's not my code being rejected, that's TeaVM's own WASM-GC backend crashing while trying to compile HashMap's entry-set iterator. A perfectly ordinary for (Entry<K,V> e : map.entrySet()) loop, the kind that appears in every Java codebase ever written, and it kills the build. Iterating keySet() and calling get() inside the loop compiles fine. Slightly worse code, works.

Worth remembering the general shape of that one: when the stack trace has TeaVM's own package names in the upper frames, you're not debugging your program any more, you're routing around a compiler bug.

Was it worth it

The port is maybe two hundred lines of changed Java across a codebase I wrote as a teenager, and most of those lines are import statements. The interesting part was never the porting, it was finding out that the boundary between "this is Java" and "this is C++ wearing a Java costume" is invisible until you try to move to a platform that can't run C++. World and Body look like ordinary classes. You import them like ordinary classes. Then you point a compiler at them that can't call into native code, and the costume comes off.

The fact that the fix was a drop-in pure-Java implementation of the same engine is a bit of luck specific to Box2D being famous enough that someone bothered to port it. If this had been Bullet, I'd have been writing a physics engine instead of a blog post.