> Cinder 0.1.0 documentation: ADR-0014: Sample game, benchmarks and the zero-allocation frame (Phase 8)
> Canonical page: https://cinder.ash10.dev/docs/adr/0014-sample-game-and-performance/ · Source: `docs/adr/0014-sample-game-and-performance.md` in the engine repository (engine commit 47c9352, imported 2026-11-09)
> Index of every page for agents: https://cinder.ash10.dev/llms.txt

# 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 `SceneComponent`s, 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 `QuerySpec`s 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.
