DocsGuides

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).
View as MarkdownSource: docs/guides/app.md