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 #
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: ...startruns once, on the firstrun_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 withdt = 1 / fixed_hz, after the physics step.update(dt)runs once per frame with the real (or overridden) frame time.draw_uiruns only in windowed mode with the overlay enabled, betweenDebugUi.begin_frameandDebugUi.draw; ImGui calls made here (throughapp.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:
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,eventsswapchain: Optional[Swapchain],target_view,target_tex,readback(handles),renderer: Boxed[Renderer],res: Boxed[Resources],gfx: Boxed[Gfx]window: Optional[Boxed[Window]],platform: Optional[Boxed[Platform]],jobs: Boxed[JobSystem]profiler,clock,fixed,config, counters and timing fieldslib: 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
Gameholds handles intoapp.physics[],app.audioandapp.renderer[]; releasing them needs the app alive.sample/main.mojoends with_ = game^then_ = app^, in that order, and the testskeep(game)andkeep(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 oflive_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 #
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:
- First call only:
game.start(app), then the clock is reset so loading time does not become dt. dt=frame_secondsorclock.tick()._poll: windowed apps pump SDL events; quit/close setquit_requested; resize updateswidth/height, the swapchain and the camera aspect; events go toinput.processunlessui_consumes_eventsays 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 tologs. Headless apps skip this.fixed.advance(dt)steps, each inside a"fixed step"profiler zone:world.advance_tick(),physics_sync.push_kinematics,physics[].step(step_dt)(timed intophysics_ms),physics_sync.pull_bodies,game.fixed_update(app, step_dt),fixed_steps += 1.physics_sync.write_transforms(world, fixed.alpha())interpolates rendered transforms.game.update(app, dt)."animation"zone:anim.update(dt, jobs)andanim.publish(world).propagate_transforms(world)."audio"zone, if audio exists:sync_audio(world, audio); headless:read_framesfordtworth of samples (capped at one second) so sounds progress without a device;audio.update().assets[].update()if mounted._render("render"zone, timed intorender_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(...), thendebug_ui.renderinto its own render pass), forget the view, present; headless:renderer[].renderintotarget_view. GPU diagnostics go tologs.- Allocation deltas are stored if
count_allocations;physics_msis set;input.end_frame();profiler.frame_mark();frame_index += 1;frame_msis set.
The typical main loop:
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)):
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:
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 += 1pixi run sample-headless runs sample --headless --autopilot --frames 600.
Timing and allocation accounting #
frame_ms: CPU time of the lastrun_frame, from afterstartto the end of the frame.physics_ms: time insidephysics[].stepsummed 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:
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
Appis load-bearing. Adding a field that needsliborgfxafter them breaks shutdown; add new subsystems in the first block. AppisMovablebut notCopyable; functions return it by value (def _app() raises -> App). Pointers into it (app.renderer.ptr()) are stable because ofBoxed, pointers to theAppvalue itself are not.run_framecallsstartlazily;app.worldis empty before the first frame.- With
frame_secondsleft at the default, the first frame's dt is the time sincestartfinished, not since construction. FixedStepcaps 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_frameafterrequest_quitstill works: the quit only stopsrun_framefrom running.- Headless audio is pumped by
run_frame; if you driveAudioEngineyourself, callread_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, andlive_objects() == 0; plusparse_app_argscoverage andui_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).