ADR-0014: Sample game, benchmarks and the zero-allocation frame (Phase 8)
Status: accepted, 2026-11-09.
Context #
Section 7 asks for a short third-person game built only on the engine (skeletal character on the
character controller, lit level with 32+ dynamic lights, 500+ rigid bodies, collectables, win and
restart, 3D audio and music, a pause menu with saved settings, save/load). Section 8 sets
performance budgets, demands benchmarks in bench/ with JSON results and regression checks, and
requires "zero allocations per frame in steady state, asserted by a test".
Decisions #
Sample game (sample/) #
- The game is a
Gameover theAppruntime, nothing else. Everything it does goes through public engine APIs, so it doubles as the integration test of the whole stack. - Content is CC0, pinned and fetched, never committed.
tools/fetch_game_assets.pydownloads the KayKit Character Animations pack (character, 30 clips) and two Juhani Junkala audio packs (effects, music) with SHA-256 verification intoassets/game/and writesATTRIBUTION.md; the level, crates and collectables are Khronos sample models already fetched for the golden tests (Sponza, BoxTextured, Avocado). - Two levels share the same gameplay.
arenais procedural (floor and walls, fast to load: the headless playthrough test and benchmarks use it);sponzaloads the Khronos atrium through the glTF importer and builds a Jolt mesh collider from its triangles. Content placement uses physics queries against the loaded level (ray casts for the floor, capsule sweeps for standing spots), so the same code works for both without hand-placed coordinates. - Game state is scene components.
Player,Crate,Collectable,OrbitLightandGameStateareSceneComponents, so saving isSceneRegistry.to_docinto the diffable text format and loading isinstantiatefollowed by a "realise" pass that gives component-only entities their bodies and renderables. The same pass builds a new game. - Settings live in two files:
settings.txt(quality preset, MSAA, volumes, sensitivity) andinput.txt(theInputMaptext format). Everything but MSAA applies live. - Autopilot is a feature, not a test hook. It steers to the nearest collectable with a
simple detour when blocked; it serves as attract mode, as the headless playthrough test
(
tests/sample/test_sample_game.mojowins the arena, saves mid-game, reloads, restarts and checks that nothing leaks) and as the benchmark driver.
Benchmarks (bench/) #
bench/stressbuilds the section 8 stress scene (50,000 instances, 2,000 dynamic bodies, 256 clustered lights) headless at 1920x1080 andbench/sample_benchruns the sample game on the high preset. Both record per-frame CPU time, physics step time and render submission time (median, p99, mean, max), the adapter name and startup time, as JSON.tools/bench_check.pycompares results withbench/baselines/<name>.jsonand fails on a regression above 10% of any tracked metric; results from a different adapter are reported but not compared.pixi run bench-allruns everything; CI runs it on a schedule once the reference hardware exists.- Budgets are reported, not met, on this host. The only GPU available to this run is Mesa lavapipe inside Docker (see REPORT.md): numbers are regression baselines for that configuration, and the section 8 budgets stay open until the named hardware runs the suite.
Zero allocations per frame #
- Counting.
libcinder_alloccount.so(LD_PRELOADed bytools/run_tests.shfortests/perf/test_zero_alloc.mojo) interposes the glibc allocation entry points and, since the Mojo runtime allocates through its own TCMalloc over mmap, alsoKGEN_CompilerRT_AlignedAlloc. It attributes calls to the executable, the Mojo runtime or a shared library by return address, and the shim forwards the counters (cn_alloc_counter_*) so a plain run is a no-op. - What the test asserts. A representative headless scene (300 static instances, 100 falling bodies, 32 point lights and a sun, an animated skinned character) runs 90 warm-up frames, then 60 frames during which Mojo allocations must be exactly zero. Third-party libraries (lavapipe, wgpu-native, Jolt) still allocate internally; those are outside the engine's control and are reported separately.
- How the engine got there. Every per-frame list and byte writer became a persistent
scratch field cleared each frame (
ByteWriter.reset,List.clearkeep capacity); ECS systems keepQuerySpecs and chunk lists instead of building queries per frame; the frame graph recycles pass records and compile scratch; cascade fitting and frustum corners use stack arrays; post-process and GTAO bind groups are cached by the views they bind; change detection compares numbers instead of formatted strings; the frame graph's DOT text is produced on demand (Renderer.graph_dot).App.count_allocationsandRenderer.alloc_marksprint per-phase deltas when something regresses.
Consequences #
- The steady-state budget is enforced by CI: a new per-frame allocation anywhere in the frame
path fails
test_zero_alloc. pack_lights,fit_cascades,cull_spheres,palette,leaf_weightskeep allocating convenience forms for tests; the engine uses the_intovariants.- The sample's Sponza level takes several seconds to start on this CPU because the importer decodes PNG/JPEG textures in process; the cooked KTX2 path exists but the sample does not use it yet, so the cold-start budget is unmeasured as well as unmet here.