Input replay, profile CSVs, and overlays.
Replay & profiler
Two tools answer the hard questions of game development. Replay answers "how do I make this bug happen again?" by re-sending a recorded input stream through the deterministic simulation. The profiler answers "where does the frame time go?" by measuring named scopes while the game runs. Together with saves — player progress written outside the project — they are how you reproduce, measure, and persist game state. Examples use "Skyforge" at games/my_game and the CLI at demi.
What an input replay is
A replay file is a recording of what the player did: every input frame with its tick and values. Because DemiEngine simulation is deterministic (fixed timestep plus a seeded generator), replaying the same inputs in the same build reproduces the same run. Replays exist so a bug report becomes a file you can rerun in seconds.
Record fixtures the way your game records input for tests or debugging, then play them back:
demi run \
--input-replay fixtures/bug123.replay.json
run— launches Skyforge normally.--input-replay file— replaces live device input with the recorded frames infixtures/bug123.replay.json.
The replay drives the game to the end of the recording and exits like a normal run. Use --max-frames to bound it, and DEMI_HEADLESS=1 to replay without a window.
Fixed timestep must match
A replay is recorded against one simulation rate. If the project's fixed_timestep differs from the fixture's, frames no longer line up and the run diverges — the engine cannot guess how to retarget ticks. The rule:
- Keep the project's
simulation.fixed_timestepequal to the timestep the fixture was recorded with. - Treat a timestep change as a new recording: re-record fixtures after changing it.
Details of the fixed step and the random seed are in Simulation & seeds.
Profiling a run
The profiler measures how long each named part of the frame takes — input, scripts, physics, render submission, and your own scopes. It exists because "the game feels slow" is not fixable and "physics costs 11 ms in arena_step" is.
demi run linux --profiler
run linux— desktop launch of the selected project.--profiler— prints slow-frame details to the console while the game runs: frames that exceeded the budget, with their scope breakdown.
For numbers you can compare and archive, write reports:
demi run linux --profiler \
--profile-report report.csv --profile-frames frames.csv
--profile-report r.csv— writes an aggregate report: one row per named scope with totals and call counts for the session.--profile-frames f.csv— writes per-frame scope totals: one row per frame per scope, for plotting frame-time over time.
Both files are plain CSV and open in any spreadsheet or graphing tool. Returns 0 after a clean run; the reports are written as the game closes.
When GPU timers are available, native runtime and editor traces include Graphics.gpu_view.<viewId>.<name> scopes for individual render views, such as Shadow, Main and PostProcess passes. View durations appear only when a fresh GPU frame sample arrives and the view reports that same GPU frame; mismatched or delayed view samples are skipped. Graphics.gpu_frame identifies that GPU frame. These delayed intervals may overlap, so do not add them to CPU timings or assume their sum equals Graphics.gpu.
Rendering ablations for engine development
For a repeatable GPU diagnosis, configure a separate optimized build with -DDEMI_PERFORMANCE_DEBUG=ON and -DDEMI_PERFORMANCE_DEBUG_DISABLE="terrain_normals;terrain_macro". Rebuild and relaunch the runtime or editor before capturing the same camera, resolution and scene. These compile-time switches affect native shaders; they do not edit project assets or represent production rendering quality.
The supported stage names are terrain_materials, terrain_normals, terrain_macro, terrain_triplanar, terrain_blending and shadow_receive. Compare one stage at a time before combining them. Unknown names fail configuration while the diagnostic master switch is enabled. With DEMI_PERFORMANCE_DEBUG=OFF (the default), every override is zero even if a disabled-stage list remains in the CMake cache.
Diagnostic builds log their disabled stages at graphics initialization. Runtime and editor CSV traces include Graphics.performance_debug (0 or 1) and Graphics.performance_debug_disabled_mask. Mask bits 0 through 5 correspond to the stage names in the order above; later stages append new bits. Record these values and the build configuration beside measurements. GPU view intervals show receiver cost; disabling shadow_receive does not remove shadow-caster rendering.
CPU, memory and Lua garbage collection
During embedded Play, open Console → Profiler for the CPU, process RAM and Lua heap overview above the scope table. There is no GC overview card: explicit request counts do not measure automatic collection activity. Explicit GC metrics remain available in the detailed scope table and profiler reports. Resource values are measurements, not frame-duration scopes.
Process.cpu_percentmeasures process CPU time across all threads. 100% means one logical CPU fully occupied; parallel work can exceed 100%. It is not a sum of scope timings.Process.resident_bytesis physical memory resident for this process (RSS).Process.peak_resident_bytesis its OS-reported process-lifetime high-water mark. In embedded Play these include Editor + Game, not just game objects.- Process sampling runs approximately every 250 ms while profiling is active. CPU usage needs two samples. Unavailable measurements are shown as unavailable, not zero; paused Play retains its last measurements.
Lua.heap_bytesreports the gameplay Lua VM's heap.Lua.heap_peak_sampled_bytesis the largest observed sample, so short-lived allocations between samples can exceed it. Lua memory is part of process RAM; do not add the values together.Lua.gc_runningindicates whether Lua's collector is enabled. Lua automatically collects unreachable Lua objects unless a script explicitly stops collection.Lua.gc_collect_requestsandLua.gc_step_requestscount explicit full-collection and incremental-step requests for the VM's lifetime, including the engine's full-collection request when clearing bindings.Lua.gc_step_cyclescounts successful step calls that reported completing a cycle.Lua.gc_explicitmeasures explicit collection work while profiling is enabled.
These are not automatic GC cycle counts. Lua's public API does not expose those counters or total automatic-GC time. Heap changes alone cannot identify completed collections. The profiler does not stop the collector, change its mode, or force extra collections to obtain measurements. Ordinary C++ allocations use object lifetimes and RAII rather than Lua's collector.
Process CPU and RAM sampling is implemented for Linux and Android; other platforms report unavailable values. Asset-residency and render-target counters describe particular resource categories, not a complete accounting of process or GPU memory.
Adding your own scopes
Engine scopes cover the frame skeleton. Wrap your own heavy work to see it in the same reports:
local Profile = require("demi.profile")
local Entity = require("demi.entity")
function Game:on_update(dt)
Profile.scope("skyforge_enemy_queries", function()
local enemies = Entity.query({ tags = { "enemy" } })
update_enemies(enemies, dt)
end)
end
require("demi.profile")— imports the profiling service table.Profile.enabled()→truewhile profiling is active.Profile.scope(name, fn)— runs the functionfnand records its duration undernamein every profile report. Returnstruewhen the scope ran and was recorded.
Give scopes names you will recognize in a CSV a month from now: skyforge_enemy_queries, not tmp1.
Debug overlays
Debug overlays draw development-only information over the game so you can see simulation state while profiling or replaying:
demi run \
--debug-overlays colliders,contacts,grid,entity_ids,draw_order,ui_bounds,profiler
--debug-overlays names— comma-separated overlay names, overriding the project's debug flags for this run.colliders— drawn collision shapes.contacts— current contact points and normals.grid— the world grid.entity_ids— stable entity IDs next to objects.draw_order— rendering order of transparent and sorted draws.ui_bounds— HUD node rectangles.profiler— the in-game profiler HUD.
Ship with all overlay flags false in the project; overlays are development aids, not player features. The same names toggle in the editor viewport (see Workspace & panels).
Saves
A save is player progress in a named slot, written to platform user-data — never into the project or cooked content. Saves exist so a quit and relaunch continues where the player stopped, and so tests can start from a known state.
local Save = require("demi.save")
Save.write("slot1", { level = 3, gold = 120 })
local data = Save.read("slot1") -- table or nil
Save.write(slot, table)— writes the whole table to the named slot. Returns a boolean success flag.Save.read(slot)— returns the saved table, ornilwhen the slot is empty or unreadable.
Save.set_number("slot1", "gold", 150)
print(Save.get_number("slot1", "gold", 0)) -- 150; 0 is the default
Save.set_number(slot, key, value)— writes one numeric field in the slot; returnstrueon success.Save.get_number(slot, key, default)— returns the stored number, ordefaultwhen the key is missing.
Save.register_migration(2, 3, function(old_table, from_version, to_version)
old_table.coins = old_table.gold
old_table.gold = nil
return old_table
end)
Save.register_migration(from_version, to_version, callback)— registers an upgrade function for saves stored atfrom_version; it returns an integer migration ID. The callback receives the old table plus both versions and returns the migrated table. Migrations run in order when an older save is loaded.
local meta = Save.metadata("slot1") -- table of save metadata
local err = Save.last_error() -- string or nil
local here = Save.exists("slot1") -- boolean
Save.delete("slot1") -- remove the slot
Save.metadata(slot)— returns a metadata table for the slot (autosave flag, sequence, reason), ornil.Save.last_error()— returns the last save error message as a string, ornilwhen the last operation succeeded.Save.exists(slot)— returns a boolean.Save.delete(slot)— removes the slot. Returns a boolean success flag.
Inspect a save file from the command line when something looks wrong:
demi save inspect saves/slot1.json
save inspect— validates and summarizes the save document. Returns0when valid,1when the file is corrupt.
A practical workflow
- Reproduce with a replay fixture and
--input-replay; keep the fixture's timestep equal to the project's. - Turn on the overlays that show the subsystem you suspect (
contactsfor physics,draw_orderfor sorting). - Profile with
--profile-reportand--profile-frames; wrap hot code inProfile.scopeuntil the cost has a name. - Fix, replay again, and compare the two CSV reports.
- Lock the behavior in with E2E tests.
Related pages: Build, cook & test for the command surface, and Play mode for the in-editor profiler panel.