> Cinder 0.1.0 documentation: Application runtime (cinder/app)
> Canonical page: https://cinder.ash10.dev/docs/guides/app/ · Source: `docs/guides/app.md` in the engine repository (engine commit 47c9352, imported 2026-11-09)
> Index of every page for agents: https://cinder.ash10.dev/llms.txt

# Application runtime (`cinder/app`)

`cinder/app` is the top of the engine: `App` owns every subsystem, runs the fixed-step game loop
and renders, either into an SDL window or, with `--headless`, into an offscreen texture that can be
read back. Gameplay implements the `Game` trait and is called from `App.run_frame`. The sample game
(`sample/`), the tests under `tests/app` and `tests/sample`, the zero-allocation test and the
benchmarks in `bench/` all run this one loop.

Source: `cinder/app/app.mojo` (`AppConfig`, `Game`, `App`), `cinder/app/cli.mojo`
(`parse_app_args`). API reference: `docs/api/app.md`. Design: `docs/adr/0013-tools-and-app-runtime.md`
(decisions 1 to 3), `docs/adr/0014-sample-game-and-performance.md` (zero allocations, benchmarks).

## `AppConfig`

`AppConfig(title="Cinder", width=1280, height=720, headless=False)` plus fields set afterwards:

| Field | Default | Meaning |
|---|---|---|
| `title`, `width`, `height` | `"Cinder"`, 1280, 720 | Window title and size. In windowed mode `App.width`/`height` are replaced by the window's pixel size. |
| `headless` | `False` | No window, no swapchain, no overlay, device-less audio; frames go to an offscreen RGBA8 target. |
| `fixed_hz` | 60.0 | Simulation rate of the fixed step (`FixedStep(fixed_hz)`, at most 8 steps per frame). |
| `render` | `RenderSettings()` | Renderer settings (MSAA, shadows, AO, bloom, FXAA, ...). Tests set `config.render.msaa = 1`. |
| `layers` | `LayerConfig.default()` | Physics object layers and collision pairs. |
| `audio` | `True` | Create the `AudioEngine`. |
| `debug_ui` | `True` | Create the ImGui overlay (windowed only; ignored headless). |
| `profile` | `False` | Call `Profiler.start()` at construction (the Tracy client). |
| `worker_threads` | 3 | Worker threads of the `JobSystem`. |

`AppConfig` is `Copyable`; `App.__init__` takes it by `var`, so pass `config^`.

## The `Game` trait

```mojo
trait Game:
    def start(mut self, mut app: App) raises: ...
    def fixed_update(mut self, mut app: App, dt: Float32) raises: ...
    def update(mut self, mut app: App, dt: Float32) raises: ...
    def draw_ui(mut self, mut app: App) raises: ...
```

- `start` runs once, on the first `run_frame`. Create meshes, materials, bodies, buses, register
  scene components (`app.registry.register[T]()`) and load the input map here.
- `fixed_update(dt)` runs once per simulation step with `dt = 1 / fixed_hz`, after the physics step.
- `update(dt)` runs once per frame with the real (or overridden) frame time.
- `draw_ui` runs only in windowed mode with the overlay enabled, between `DebugUi.begin_frame` and
  `DebugUi.draw`; ImGui calls made here (through `app.lib.ptr()[]`) land in the same frame.

There is no shutdown hook. The game releases what it created (bodies, shapes, sounds) itself before
it is destroyed; `tests/app/test_app_headless.mojo` adds a plain `shutdown(app)` method to its
`DropGame` and calls it explicitly.

## `App` ownership and destruction order

`App` is a `Movable` value whose fields are declared in destruction order, because Mojo destroys
fields in declaration order (`docs/MOJO_NOTES.md`). Subsystems that hand out pointers to themselves
are held in `Boxed[T]` cells (`cinder/core/boxed.mojo`): a heap allocation with a stable address, so
the `App` value can move while `Resources` keeps pointing at the same `Gfx`. Access a boxed field
with `app.physics[]`, `app.renderer[]`, `app.gfx[]`, `app.res[]`, `app.jobs[]`; `.ptr()` gives
the raw `Pointer` the subsystems themselves use.

Declaration (and destruction) order, top to bottom:

1. `debug_ui: Optional[DebugUi]`, `assets: Optional[Boxed[AssetServer]]`, `anim`,
   `audio: Optional[AudioEngine]`, `physics_sync`, `bridge`, `physics: Boxed[PhysicsWorld]`,
   `input: InputMap`, `world: World`, `scene: RenderScene`, `registry: SceneRegistry`,
   `logs: MemorySink`, `events`
2. `swapchain: Optional[Swapchain]`, `target_view`, `target_tex`, `readback` (handles),
   `renderer: Boxed[Renderer]`, `res: Boxed[Resources]`, `gfx: Boxed[Gfx]`
3. `window: Optional[Boxed[Window]]`, `platform: Optional[Boxed[Platform]]`, `jobs: Boxed[JobSystem]`
4. `profiler`, `clock`, `fixed`, `config`, counters and timing fields
5. `lib: NativeLibrary`, last: every destructor above calls through it.

`App.__deinit__` only destroys the offscreen target (texture, view, readback buffer) through
`Resources` before the fields go. Two consequences for callers:

- **Destroy the game before the app.** A `Game` holds handles into `app.physics[]`, `app.audio`
  and `app.renderer[]`; releasing them needs the app alive. `sample/main.mojo` ends with
  `_ = game^` then `_ = app^`, in that order, and the tests `keep(game)` and `keep(app)` after their
  last assertions so ASAP destruction does not reorder them.
- **Leaks are reported, not fatal.** `live_objects()` returns physics shapes + bodies + characters
  plus live sounds and user buses; tests assert it is 0 after the game released its objects.
  `app.res[].live_counts()` covers GPU resources (shown in the overlay's Frame tab); they are not
  part of `live_objects()` because the engine's own targets, pipelines and meshes are alive for the
  App's whole life.

Other fields: `width`/`height` (current size), `frame_index`, `fixed_steps` (total steps run),
`quit_requested`, `started`, `events` (this frame's platform events, reused), `logs` (a
`MemorySink`: `app.logs.records` holds every `LogRecord`; `app.log(level, category, message)`
appends one; platform, GPU and audio diagnostics are forwarded here each frame).

## Construction

```mojo
from cinder.app import App, AppConfig, Game

var config = AppConfig(String("cinder headless test"), 160, 120, True)
config.render.msaa = 1
var app = App(config^)
```

`App.__init__` loads the native library (`NativeLibrary()`, path from `CINDER_NATIVE_LIB` or the
default per OS), creates the profiler (started if `config.profile`), clock, `FixedStep`, job
system, log sink, registry, ECS world, input map, animation system, physics world (with
`config.layers`), physics sync, render bridge and the audio engine (headless when
`config.headless`). Then, windowed: `Platform`, `Window`, `Gfx` on the window's surface, `Swapchain`
(whose format the renderer uses); headless: `Gfx` without a surface and RGBA8 as the target format.
`Resources` (capacity 4096), `Renderer` and a default `RenderScene` with a camera looking at the
origin follow. Finally headless apps create the offscreen target and windowed apps with
`config.debug_ui` create the `DebugUi` and set `Platform.forward_to_imgui`.

`mount_assets(root)` is optional and attaches an `AssetServer` over a cooked-asset directory (its
`manifest.txt`); `App.run_frame` then calls `AssetServer.update` every frame and the overlay's
Assets tab lists its records.

## `run_frame` and the loop order

`run_frame[G: Game](mut game: G, frame_seconds: Float64 = -1.0) -> Bool` runs one frame and returns
`False` once a quit was requested (window close, `EventKind.quit`, or `request_quit()`). With
`frame_seconds >= 0` that value replaces the clock's measured delta, which is how tests and
benchmarks get deterministic step counts (`1.0 / 60.0` gives exactly one fixed step per frame).

In order:

1. First call only: `game.start(app)`, then the clock is reset so loading time does not become dt.
2. `dt` = `frame_seconds` or `clock.tick()`.
3. `_poll`: windowed apps pump SDL events; quit/close set `quit_requested`; resize updates
   `width`/`height`, the swapchain and the camera aspect; events go to `input.process` unless
   `ui_consumes_event` says the overlay takes them (mouse presses, motion and wheel while it wants
   the mouse, key presses while it wants the keyboard; key and button releases always reach the
   input map, so nothing stays held when the overlay takes focus); SDL diagnostics go to `logs`.
   Headless apps skip this.
4. `fixed.advance(dt)` steps, each inside a `"fixed step"` profiler zone: `world.advance_tick()`,
   `physics_sync.push_kinematics`, `physics[].step(step_dt)` (timed into `physics_ms`),
   `physics_sync.pull_bodies`, `game.fixed_update(app, step_dt)`, `fixed_steps += 1`.
5. `physics_sync.write_transforms(world, fixed.alpha())` interpolates rendered transforms.
6. `game.update(app, dt)`.
7. `"animation"` zone: `anim.update(dt, jobs)` and `anim.publish(world)`.
8. `propagate_transforms(world)`.
9. `"audio"` zone, if audio exists: `sync_audio(world, audio)`; headless: `read_frames` for `dt`
   worth of samples (capped at one second) so sounds progress without a device; `audio.update()`.
10. `assets[].update()` if mounted.
11. `_render` (`"render"` zone, timed into `render_ms`): `bridge.build(world, scene)`; windowed:
    acquire the swapchain frame, import its view as a handle, `renderer[].render`, overlay
    (`_draw_overlay`: `debug_ui.begin_frame(dt)`, `game.draw_ui(app)`, `debug_ui.draw(...)`, then
    `debug_ui.render` into its own render pass), forget the view, present; headless:
    `renderer[].render` into `target_view`. GPU diagnostics go to `logs`.
12. Allocation deltas are stored if `count_allocations`; `physics_ms` is set; `input.end_frame()`;
    `profiler.frame_mark()`; `frame_index += 1`; `frame_ms` is set.

The typical main loop:

```mojo
var game = SampleGame(level, autopilot)
var app = App(config^)
var frames = 0
while app.run_frame(game):
    frames += 1
    if max_frames >= 0 and frames >= max_frames:
        break
_ = game^
_ = app^
```

## Windowed vs headless; `read_frame`

Both modes run the same code; headless differs only in what step 3 and step 11 touch. The
offscreen target is an RGBA8 texture with `RenderAttachment | CopySrc | TextureBinding` usage and
a map-readable buffer whose row stride is rounded up to `READBACK_ALIGN = 256` bytes (a wgpu
requirement; an unaligned copy aborts the process, `docs/MOJO_NOTES.md`).

`read_frame()` (headless only, raises otherwise) copies the target into the readback buffer,
waits for the queue, maps it and returns an `Image` (`width`, `height`, `pixel(x, y)`):

```mojo
var frame = app.read_frame()
var lit = 0
for y in range(frame.height):
    for x in range(frame.width):
        var p = frame.pixel(x, y)
        if Int(p[0]) + Int(p[1]) + Int(p[2]) > 30:
            lit += 1
save_png(app.lib.ptr(), String(".cache/tests/sample/frame.png"), frame)
```

Headless apps have no overlay (`app.debug_ui` is empty, `draw_ui` is never called) and never see
platform events, so the loop ends only through `request_quit()` or the caller's own frame limit.

## Command line: `parse_app_args`

`parse_app_args(args, config) -> List[String]` applies the engine options to `config` and returns
what it did not consume, so every executable shares the same flags and keeps its own:

| Option | Effect |
|---|---|
| `--headless` | `config.headless = True` |
| `--profile` | `config.profile = True` (start Tracy at construction) |
| `--no-audio` | `config.audio = False` |
| `--no-debug-ui` | `config.debug_ui = False` |
| `--width N`, `--height N` | positive integers, else raises |
| `--title TEXT` | window title |

A flag missing its value raises (`"--width needs a value"`). `sample/main.mojo` parses the rest
itself (`--level`, `--autopilot`, `--frames N`, `--screenshot PATH`, `--verbose`) and raises on
anything unknown:

```mojo
var config = AppConfig(String("Cinder sample"), 1280, 720, False)
var rest = parse_app_args(args, config)
var i = 0
while i < len(rest):
    if rest[i] == "--level" and i + 1 < len(rest):
        level = rest[i + 1]
        i += 1
    elif rest[i] == "--autopilot":
        autopilot = True
    else:
        raise Error(t"unknown argument {rest[i]}")
    i += 1
```

`pixi run sample-headless` runs `sample --headless --autopilot --frames 600`.

## Timing and allocation accounting

- `frame_ms`: CPU time of the last `run_frame`, from after `start` to the end of the frame.
- `physics_ms`: time inside `physics[].step` summed over the frame's fixed steps.
- `render_ms`: time inside `_render` (scene build, submission, overlay, present call), measured on the
  CPU; it does not wait for the GPU.

`bench/stress.mojo` and `bench/sample_bench.mojo` sample these three per frame into their JSON
results (`docs/BENCHMARKS.md`).

`count_allocations` turns on per-phase Mojo heap allocation counts, read through the shim's
`cn_alloc_counter_from_executable` at phase boundaries. It needs `libcinder_alloccount.so`
preloaded (`tools/run_tests.sh` does this for `tests/perf/test_zero_alloc.mojo`; without it every
counter reads 0). After a frame, `alloc_other` (poll and step calculation), `alloc_fixed`,
`alloc_update`, `alloc_anim`, `alloc_propagate`, `alloc_audio` (audio sync and asset update) and
`alloc_render` hold the deltas of the last frame:

```mojo
var lib = app.lib.ptr()
lib[].cn_alloc_counter_reset()
lib[].cn_alloc_counter_set_enabled(True)
app.count_allocations = True
for f in range(frames):
    app.renderer[].alloc_marks = f == frames - 1
    assert_true(app.run_frame(game, 1.0 / 60.0))
lib[].cn_alloc_counter_set_enabled(False)
print(t"fixed {app.alloc_fixed} update {app.alloc_update} render {app.alloc_render}")
```

`gpu_error_count()` returns `Gfx.error_count`, the number of uncaptured wgpu errors delivered
through the shim queue; tests assert it is 0 after rendering.

## Logging

`App.logs` is a `MemorySink`; everything the app learns about is appended there as `LogRecord`
(level, category, message): SDL log lines (`Platform.take_diagnostics`), GPU diagnostics
(`Gfx.take_diagnostics`), and your own `app.log(LogLevel.error(), String("app"), message)` calls.
The overlay's Log tab shows the last 200 records. Tests scan `app.logs.records` for
`LogLevel.error()`. Audio diagnostics stay in `AudioEngine.diagnostics` and are not forwarded.

## Threading

`run_frame` runs on the main thread: the SDL pump, presentation, ImGui and the subsystem calls all
happen there (`docs/THREADING.md`). Work fans out from inside the subsystems: animation submits
one job per animator to `App.jobs`, Jolt uses its own thread pool inside `physics[].step`, the asset
server decodes on the job system, and the miniaudio device thread (windowed) mixes on its own and
only posts events. Nothing in `cinder/app` runs Mojo on a foreign thread.

## Pitfalls

- Field order in `App` is load-bearing. Adding a field that needs `lib` or `gfx` after them breaks
  shutdown; add new subsystems in the first block.
- `App` is `Movable` but not `Copyable`; functions return it by value (`def _app() raises -> App`).
  Pointers into it (`app.renderer.ptr()`) are stable because of `Boxed`, pointers to the `App` value
  itself are not.
- `run_frame` calls `start` lazily; `app.world` is empty before the first frame.
- With `frame_seconds` left at the default, the first frame's dt is the time since `start`
  finished, not since construction.
- `FixedStep` caps steps at 8 per frame and drops the excess accumulator, so a frame longer than
  133 ms at 60 Hz loses simulation time instead of spiralling.
- `read_frame` after `request_quit` still works: the quit only stops `run_frame` from running.
- Headless audio is pumped by `run_frame`; if you drive `AudioEngine` yourself, call `read_frames`.

## Tests and references

- `tests/app/test_app_headless.mojo`: a cube falls onto a lit floor for 45 frames; checks step
  counts, physics-driven transforms, a visible readback, no GPU errors, no error logs, quit, and
  `live_objects() == 0`; plus `parse_app_args` coverage and `ui_consumes_event` (releases reach
  the input map while the overlay has focus).
- `tests/tools/test_debug_ui.mojo`: the windowed path with the overlay.
- `tests/sample/test_sample_game.mojo`, `tests/perf/test_zero_alloc.mojo`, `bench/`.
- `docs/adr/0013-tools-and-app-runtime.md`, `docs/adr/0014-sample-game-and-performance.md`,
  `docs/THREADING.md`, `docs/MOJO_NOTES.md` (Phase 7 and Phase 8 sections).
