DEMIENGINEDOCUMENTATION Everything is free

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 in fixtures/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_timestep equal 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_percent measures 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_bytes is physical memory resident for this process (RSS). Process.peak_resident_bytes is 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_bytes reports the gameplay Lua VM's heap. Lua.heap_peak_sampled_bytes is 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_running indicates whether Lua's collector is enabled. Lua automatically collects unreachable Lua objects unless a script explicitly stops collection.
  • Lua.gc_collect_requests and Lua.gc_step_requests count 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_cycles counts successful step calls that reported completing a cycle. Lua.gc_explicit measures 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() → true while profiling is active.
  • Profile.scope(name, fn) — runs the function fn and records its duration under name in every profile report. Returns true when 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, or nil when 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; returns true on success.
  • Save.get_number(slot, key, default) — returns the stored number, or default when 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 at from_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), or nil.
  • Save.last_error() — returns the last save error message as a string, or nil when 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. Returns 0 when valid, 1 when the file is corrupt.

A practical workflow

  1. Reproduce with a replay fixture and --input-replay; keep the fixture's timestep equal to the project's.
  2. Turn on the overlays that show the subsystem you suspect (contacts for physics, draw_order for sorting).
  3. Profile with --profile-report and --profile-frames; wrap hot code in Profile.scope until the cost has a name.
  4. Fix, replay again, and compare the two CSV reports.
  5. 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.