DocsDecisions

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/) #

  1. The game is a Game over the App runtime, nothing else. Everything it does goes through public engine APIs, so it doubles as the integration test of the whole stack.
  2. Content is CC0, pinned and fetched, never committed. tools/fetch_game_assets.py downloads the KayKit Character Animations pack (character, 30 clips) and two Juhani Junkala audio packs (effects, music) with SHA-256 verification into assets/game/ and writes ATTRIBUTION.md; the level, crates and collectables are Khronos sample models already fetched for the golden tests (Sponza, BoxTextured, Avocado).
  3. Two levels share the same gameplay. arena is procedural (floor and walls, fast to load: the headless playthrough test and benchmarks use it); sponza loads 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.
  4. Game state is scene components. Player, Crate, Collectable, OrbitLight and GameState are SceneComponents, so saving is SceneRegistry.to_doc into the diffable text format and loading is instantiate followed by a "realise" pass that gives component-only entities their bodies and renderables. The same pass builds a new game.
  5. Settings live in two files: settings.txt (quality preset, MSAA, volumes, sensitivity) and input.txt (the InputMap text format). Everything but MSAA applies live.
  6. 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.mojo wins the arena, saves mid-game, reloads, restarts and checks that nothing leaks) and as the benchmark driver.

Benchmarks (bench/) #

  1. bench/stress builds the section 8 stress scene (50,000 instances, 2,000 dynamic bodies, 256 clustered lights) headless at 1920x1080 and bench/sample_bench runs 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.
  2. tools/bench_check.py compares results with bench/baselines/<name>.json and fails on a regression above 10% of any tracked metric; results from a different adapter are reported but not compared. pixi run bench-all runs everything; CI runs it on a schedule once the reference hardware exists.
  3. 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 #

  1. Counting. libcinder_alloccount.so (LD_PRELOADed by tools/run_tests.sh for tests/perf/test_zero_alloc.mojo) interposes the glibc allocation entry points and, since the Mojo runtime allocates through its own TCMalloc over mmap, also KGEN_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.
  2. 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.
  3. How the engine got there. Every per-frame list and byte writer became a persistent scratch field cleared each frame (ByteWriter.reset, List.clear keep capacity); ECS systems keep QuerySpecs 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_allocations and Renderer.alloc_marks print 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_weights keep allocating convenience forms for tests; the engine uses the _into variants.
  • 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.
View as MarkdownSource: docs/adr/0014-sample-game-and-performance.md