Skip to content

Runtime

The runtime is what an agent drives: a scene under a manual clock, with a seeded random generator, snapshots, a state hash, a component registry with JSON Schemas, an event queue and the studio rules (mute, data root). Nothing needs a window, a GPU or the wall clock. Design background: docs/AGENT_FIRST.md sections 3.1, 3.4, 3.5, 3.8 and 3.9. Code: crates/sparklight-engine/src/runtime.rs, registry.rs, snapshot.rs, schemas.rs; foundations in crates/sparklight-core (error, events, rng, hash, clock, studio).

use sparklight_engine::{Runtime, RuntimeConfig};
let mut rt = Runtime::from_scene_file("samples/platformer/scenes/main.scene.json".as_ref(), RuntimeConfig::headless(42))?;
rt.load_assets("samples/platformer/assets".as_ref()); // clips, sounds (muted: no device), scripts resolve lazily
rt.set_action("move_x", 1.0); // input as scripts see it
rt.step(600); // exactly 600 fixed steps of 1/60 s
let x = rt.get("/Hero/Transform2D/position/0")?; // read by JSON path
rt.set("/Hero/Transform2D/position/0", 3.5.into())?; // write by JSON path (validated, atomic)
let snap = rt.snapshot()?; // save everything that evolves
rt.step(100);
rt.restore(&snap)?; // go back, bit for bit
assert_eq!(rt.hash(), snap.hash());

From a shell, cargo run -q -p sparklight-engine --example headless_run -- <scene> <assets> [seed] [frames] prints {"seed", "frames", "hash"} as JSON. The same arguments print the same hash in every process.

Call Effect
step(n) exactly n fixed steps (fixed_dt = 1/60 s by default); try_step(n) returns the error instead of reporting it as an event
advance(seconds) as many whole steps as fit; the remainder is carried to the next call. Negative and non-finite values do nothing (try_advance returns E_RUNTIME_ARG)
step_with(n, hook) like step, calling hook(&mut Scene) before every step (per-step input, camera)
advance_realtime(wall_dt, hook) only for realtime = true runtimes (the windowed game); clamps a frame to 0.25 s. A deterministic runtime returns E_RUNTIME_NOT_REALTIME
reset() back to the state at load time: clock 0, RNG reseeded, events and named snapshots cleared
reset_with_seed(seed) reset() under another seed: the runtime RNG, the math.random of every script and the particle emitters are seeded from seed as if the scene had been loaded with it (equal hash), and later reset() calls keep it. No rebuild of the runtime
frame(), elapsed() steps taken, simulated seconds (frame * dt, no accumulation error)

Ceiling. One step / advance call may take at most RuntimeConfig::max_steps_per_call steps (default 1,000,000). A larger request, such as advance(1e9), is refused: nothing runs, a try_* call returns E_RUNTIME_ARG, and the plain call returns a report with 0 steps and queues an E_RUNTIME_ARG error event. A refused call changes nothing, not even the carried remainder. rt.cancel_handle() returns an AtomicBool; setting it (from another thread or a step hook) stops the running call after the current step. RuntimeConfig::validate() checks fixed_dt (finite, in (0, 1] s), the ceiling and the event capacity; Runtime::try_with_scene refuses a bad config, Runtime::new / with_scene fall back to the defaults and queue an E_RUNTIME_ARG event. Scene::update ignores a step that is not finite, not positive or longer than one second (Scene::try_update returns the error). RuntimeConfig::event_level (default Debug) drops events below it before they are built; Info skips the per-frame contact and animation events.

Script limits. RuntimeConfig::script_limits (sparklight_script::ScriptLimits: 2,000,000 instructions per callback, 16 MiB per script state, 2 s wall-clock backstop by default) applies to every script of the runtime, scripts loaded later and scripts rebuilt by restore included. Runtime::set_script_limits(limits) changes them at run time: instructions at least 1, memory from MIN_SCRIPT_MEMORY (1 MiB) to MAX_SCRIPT_MEMORY (4 GiB), a wall time above zero, and no script may already hold more memory than the new cap, otherwise E_RUNTIME_ARG and nothing changes. Limits are not part of snapshots or of the state hash. The control plane sets them with run.reset {script_limits}.

Evaluating Lua. Scene::eval_script(entity, code) runs a snippet in the context of the entity’s script (self, the script’s functions and globals, the sparklight API) under the same sandbox and limits as its callbacks and returns the value as JSON (LuaScript::eval in sparklight-script). It loads a script that still waits behind its ScriptPath; E_COMPONENT_MISSING when the entity has no script, then E_SCRIPT_LOAD (does not compile), E_SCRIPT_RUNTIME, E_SCRIPT_BUDGET, E_SCRIPT_MEMORY. Changes to self and the world stay; global assignments of the snippet do not. The control plane exposes it as script.eval.

RuntimeConfig::headless(seed) is the agent configuration: deterministic, muted, no wall clock. RuntimeConfig::windowed(&studio, seed) is what the game window uses (real frame times, audio unless muted). The window, the editor viewport and any future shell are only ways to look at a runtime and to feed it input.

A run is (scene, seed, inputs). The same triple gives the same hash() after the same number of frames.

Covered: entity iteration (slot order), component order (registration order), the physics step (rapier, same binary), animation time, particle pools, math.random of every script (a per-script generator seeded from the runtime seed and the entity id), particle emitters (seeded the same way), Runtime::rng_mut().

Verified by tests: two runs of the platformer sample with the same seed and inputs have equal hashes after 600 frames; so do the topdown and breakout samples, also across separate processes (headless_run).

Lua pairs() order over string keys is the same in every process: the repository’s .cargo/config.toml builds the vendored Lua with a fixed string-hash seed (luai_makeseed), and a cross-process test (tests/determinism.rs) checks it. (If your environment sets CFLAGS, that setting wins and the test fails; unset it.) Iterating arrays with ipairs is still the clearest way to be order-independent.

The first script or sandbox state of a process runs a self-test of that seed (pairs order of a probe table). What a failed self-test does depends on the runtime: a deterministic runtime (RuntimeConfig::headless, every headless run, scenarios, snapshot replay) refuses to create scripts with E_SCRIPT_NONDETERMINISTIC (SeedPolicy::Deterministic); a real-time runtime (RuntimeConfig::windowed, the game window) logs one warning on stderr and runs (SeedPolicy::Warn), because nothing replays it. Runtime::with_scene, try_with_scene and from_scene_file set the policy from config.realtime (sparklight_engine::apply_seed_policy), the windowed runner sets it before it reads its scene, and the policy is per process: the runtime made last wins. spark doctor reports the self-test as the lua_seed check either way.

Physics is bit-identical on the same binary and machine. For results that replay on any CPU and operating system, build with rapier’s enhanced determinism instead of SIMD: cargo test -p sparklight-engine --no-default-features --features deterministic (the default simd feature is faster and is what the windowed game uses; the two cannot be combined). tests/determinism.rs holds a golden hash recorded with it.

Not covered (known, deliberate):

  • Floating-point results can differ between CPU architectures and builds unless the deterministic feature is on. Compare hashes of the same binary on the same platform otherwise. Lua’s math.sin and friends call the platform’s libm, so scripts that use them are only reproducible on one platform.
  • Files read at run time (scripts, clips) must be identical between runs.

hash() is a 64-bit FNV-1a digest over: clock (frame, carry), runtime RNG, scene seed, camera, input actions, every live entity (id and every registered component as canonical JSON, floats by bit pattern), particle pools and the physics bodies (pose, velocities, sleeping). hash_hex() is the same as 16 hex digits. Equal hash after equal frames means equal runs; a different hash means the runs diverged (find the frame with a binary search over snapshot/restore).

The digest of each entity is cached until the entity changes (World::entity_version), so hashing a scene in which little changed is cheap. Components that read more than the entity’s own data (PhysicsBody, Parent) are ComponentOps::volatile and are re-read on every call; a plugin component that reads outside state must be volatile too.

A scene file is lossless for everything the registry marks persistent: every live entity becomes an object (an entity with no name, no transform or no persistent component at all is saved too), fixed fields carry the engine’s components, and components carries the other persistent components of the registry by name (Scene::to_json_with(&registry), Scene::from_json_with). Gravity is saved; the random seed and the assets root belong to the runtime, not the scene. Transient particle bursts are not saved. A property test saves and reloads random scenes and compares registry dumps. Loading validates everything and answers E_SCENE_SCHEMA with the path of the bad value (/objects/3/body): non-finite or zero scale, non-positive half, negative friction, a parent index out of range, a parent cycle, a shearing hierarchy; an unknown extra component is E_COMPONENT_UNKNOWN. Scene, prefab and data files are written atomically (temporary file, flush, rename), so a crash never leaves a half-written file. A script that fails to load keeps its ScriptPath and is retried when the file changes on disk.

snapshot() captures: the world (every component of every entity, entity ids and the free list, so entities created later get the same ids), physics (bodies, colliders, joints, solver warm-start data, pending contacts), Lua scripts (name, source, on_start flag, math.random state, the script’s own self fields), animator and particle state, camera, input actions, pending output lists, clock and RNG. restore() puts all of it back and checks that hash() equals the snapshot’s hash (E_INTERNAL otherwise). A snapshot can be restored any number of times. snapshot_named / restore_named / snapshot_names / drop_snapshot keep named snapshots inside the runtime.

Not in a snapshot: assets (textures, sounds, animation clips), the audio device, assets_root, the event queue (events are an output stream and are not rewound; a snapshot event marks the restore), the renderer.

Limits: a Snapshot is an in-memory object and has no file form. Files that bring a state back are built on top of the runtime: the control plane’s snapshot.save / snapshot.load write and replay a recipe (seed, replayable command log, state hash) for the 2D runtime and for the session’s 3D scene (docs/agent/commands.md, docs/agent/space3d.md), and sparklight_scenario::SnapshotFile stores a state dump for scenarios. A script that stores a function, userdata or a cyclic table in self makes snapshot fail with E_SNAPSHOT_UNSUPPORTED and a path; so does a component type that was never registered as clonable (World::register_clone).

rt.registry (ComponentRegistry) lists every component: name, doc, access (read_write / read_only), persistence, JSON Schema and a default value (describe()). entity_schema() is the JSON Schema of a whole entity.

Component Value Notes
Name string how paths and scripts address the entity
Transform2D {position:[x,y], rotation, scale:[x,y]} local to the parent; setting it moves the physics body and the bodies below it; finite numbers, no zero scale, no shear
Parent unique entity name or handle (#7, #7.2) cycles and shearing links are rejected
Sprite {size:[w,h], color:{r,g,b,a}}
SpriteTexture {key, uv:[u0,v0,u1,v1]}
BodySpec {dynamic, half:[hx,hy], restitution, lock_rotation?, friction?} setting a different one recreates the body at the entity’s world pose; the collider is half times the world scale; half positive, friction and restitution not negative
PhysicsBody read-only: handle, position, angle, linvel, angvel, sleeping
TileLayer {map, palette} (tile map and palette files) setting it rebuilds the static bodies
Animator {clip} writable; playing, frame, time observable
ScriptPath assets-relative path setting it loads and attaches the script (E_SCRIPT_LOAD on failure)
Script read-only: {name, started, state} state is the script’s self fields
Emitter emitter file (schemas/emitter.schema.json)
TransientEmitter true marker: entity is deleted when its effect ends

Internal bookkeeping (ScriptStamp, the hot-reload timestamp) is not exposed.

A path is a JSON Pointer (RFC 6901): /<entity>/<Component>/<field>/.... Escape / as ~1 and ~ as ~0. Vectors are arrays, so position/0 is x.

Call Path Does
get "" list entities: [{id, name, components}]
get /Hero all components of the entity
get /Hero/Transform2D/position/0 one value
set /Hero/Transform2D/position/0 change one field; the whole component is re-validated
set /Hero/Sprite replace the component (adds it if missing)
set /Hero with {component: value, ...} several components; all or none
patch /Hero/BodySpec RFC 7396 merge patch; null deletes an optional field; a missing component starts from its default
remove /Hero/Sprite remove a component (BodySpec also removes the body)
remove /Hero/BodySpec/friction remove an optional field
remove /Hero despawn the entity and its descendants
spawn(name, {component: value}) new entity; removed again if any component is invalid

An entity is identified by a handle: a slot index plus a generation (sparklight_core::Entity). Its text form is #<index> for generation 0 and #<index>.<generation> after the slot was reused; its opaque form is Entity::to_bits() (generation << 32 | index). A handle never starts to mean another entity: once the entity is removed the handle is stale and every lookup answers E_ENTITY_STALE (even if a new entity took the slot). #7 means exactly generation 0 of slot 7, never “whatever is in slot 7 now”. Scripts and commands pass handles as text (Scene::resolve, resolve_entity) or as bits.

Names are a convenience, not an identity: a name path works only while exactly one entity has that name (several make it E_ENTITY_AMBIGUOUS, none E_ENTITY_UNKNOWN). Inside a script, the script’s own object name always means the script’s own entity, even if other objects share the name. Scripts hold objects as opaque handles (sparklight.find(name), self); a handle to a removed object is an error (“stale object handle”), never the object that reused the slot. find answers only for a unique name; a handle text such as #7.2 is accepted in place of a name. on_contact(self, other, ...) receives the other object’s name, also when that object was removed in the same step.

A write that fails changes nothing: every value of a multi-component set or spawn is checked before the first is applied (a bad ScriptPath cannot restart the body of the same call), and invalid data is refused with a coded error: non-finite numbers, zero scale, non-positive half, negative friction or restitution, and a non-uniform scale above a rotated object (it would shear; E_COMPONENT_VALUE).

Transform2D is local to the Parent. A physics body always lives in world space: its pose is the entity’s world transform and its collider is BodySpec.half times the world scale. Setting a transform or a parent re-poses the body and the bodies below it; after each step the dynamic bodies’ poses flow back into local position and rotation (physics never rewrites scale), and non-dynamic child bodies follow a parent that physics moved. When a body or its entity is removed, the contacts it was in end with a normal started: false contact event on the next step.

Every agent-facing API returns SparkError: {code, message, path?, hint?} (SparkError::to_value()). code is stable; match on it, not on the message.

Code Meaning Hint
E_COMPONENT_UNKNOWN No component with this name is registered. List component names with the registry (describe) and use one of them verbatim.
E_COMPONENT_VALUE The value does not match the component schema. Read the component schema and send a value that validates against it.
E_COMPONENT_READONLY The component cannot be written through the generic API. Use the dedicated command for this component instead.
E_COMPONENT_MISSING The entity does not have this component. Set the whole component first, or check the entity with a get on its path.
E_COMPONENT_DUPLICATE A component with this name is already registered. Choose a unique component name.
E_ENTITY_UNKNOWN No live entity matches this name or id. List entities first; names are case sensitive, ids look like #7.
E_ENTITY_STALE The entity handle refers to an entity that was removed; its slot may hold another entity now. Handles carry a generation (#7.2); list entities again and use a current handle.
E_ENTITY_AMBIGUOUS Several entities share this name. Address the entity by handle (#<index>.<generation>) instead of by name.
E_PATH_INVALID The path is not a valid JSON Pointer. Paths look like /Hero/Transform2D/position/x; escape / as ~1 and ~ as ~0.
E_PATH_NOT_FOUND Nothing exists at this path. Get the parent path to see which keys exist.
E_SCENE_SCHEMA The scene data does not match the scene format. Validate the file against schemas/scene.schema.json.
E_SCENE_VERSION The scene format version is not supported. Migrate the file to the current format version.
E_SCENE_IO The scene file could not be read or written. Check the path and the file permissions.
E_ASSET_IO An asset file could not be read. Check that the file exists under the assets root.
E_SNAPSHOT_UNKNOWN No snapshot with this name exists. List the stored snapshots and use one of their names.
E_SNAPSHOT_UNSUPPORTED The world holds state that cannot be snapshotted. Register the component type as clonable, or remove it before taking the snapshot.
E_RUNTIME_ARG An argument of a runtime call is out of range. Check the documented range of the argument.
E_RUNTIME_NOT_REALTIME Wall-clock stepping was requested on a deterministic runtime. Use step(frames) or advance(seconds), or create the runtime with realtime = true.
E_SCRIPT_LOAD A script could not be loaded or compiled. Open the script file and fix the reported line.
E_SCRIPT_RUNTIME A script callback raised an error. Fix the script at the reported line; the object keeps running.
E_SCRIPT_BUDGET A script callback exceeded its instruction budget. Remove unbounded loops from the callback.
E_SCRIPT_MEMORY A script exceeded its memory limit. Keep fewer or smaller values in the script’s own tables and strings.
E_SCRIPT_NONDETERMINISTIC The vendored Lua was built without the fixed string-hash seed, so replay is not deterministic. Run cargo clean -p mlua-sys -p lua-src and rebuild; do not set CFLAGS in the environment (it overrides .cargo/config.toml).
E_PHYSICS The physics world rejected the operation. Check that the entity has a body and valid dimensions.
E_DATA_ROOT_REFUSED A candidate data root was refused by the agent data rules. Pass a data root that is absolute and outside the real player data folder.
E_RENDERER_UNAVAILABLE No offscreen renderer is attached to the runtime. Attach a renderer before requesting frame capture.
E_LOCALE_LOAD A localisation file could not be read or parsed. Check that the file exists and is a flat JSON object of string keys to string texts.
E_LOCALE_KEY_MISSING A localisation key has no text in the active language. Add the key to the language file; the English text (or the key itself) is shown meanwhile.
E_CMD_UNKNOWN No command with this id is registered. List commands with system.commands (or spark schema commands) and use an id verbatim.
E_PARAMS_INVALID The command parameters do not match its input schema. Read the command’s input schema (system.commands) and send parameters that validate against it.
E_PARSE The message is not valid JSON. Send one JSON-RPC 2.0 object per line.
E_PROTOCOL The message is valid JSON but not a valid JSON-RPC 2.0 or MCP request. Send {“jsonrpc”:“2.0”,“id”:1,“method”:“”,“params”:{…}}.
E_TXN_NONE There is no open transaction. Open one with txn.begin first.
E_TXN_UNKNOWN No open transaction has this id. List open transactions with txn.status; ids are returned by txn.begin.
E_TXN_ORDER Transactions must be closed innermost first. Commit or roll back the most recent transaction before the outer one.
E_TXN_BLOCKED The command cannot run while a transaction is open. Commit or roll back the transaction first; commands that replace the whole world are not transactional.
E_SUBSCRIPTION_UNKNOWN No event subscription has this id. List subscriptions with events.status.
E_ASSERT_FAILED An assertion did not hold. Compare data.expected with data.actual in the error.
E_NOT_IMPLEMENTED The command exists in the surface but its backend is not available in this build. See docs/agent/commands.md for what the backend needs.
E_EDITOR_NOT_ATTACHED No editor is attached to this session. Attach to a running editor’s control socket, or start the editor with a control server.
E_PLUGIN_HOST_MISSING No plugin host is attached to this session. Plugins are available once the plugin host is attached (task plugins).
E_PLUGIN_ERROR A plugin operation failed. Read the message for the plugin’s own error; the previous version keeps running.
E_IO A file could not be read or written. Check the path and the file permissions; outputs belong under .gwdev/out/.
E_OUTPUT_REFUSED The output path is outside the allowed output folder. Write below .gwdev/out or .gwdev/out/; the path must be relative and without ...
E_BIND_REFUSED The listen address is not a loopback address. Servers bind only to 127.0.0.1 (or ::1); pass –listen 127.0.0.1:.
E_SEEK_UNAVAILABLE No keyframe at or before the requested frame exists. Seek forward, restore a named snapshot first, or reset and step.
E_IMAGE_INVALID An image file could not be decoded or the sizes do not match. Pass PNG files of the same size.
E_SNAPSHOT_FILE A snapshot file could not be read, written or replayed. Check the path; the file must come from snapshot.save of the same project.
E_SNAPSHOT_MISMATCH Replaying a snapshot file did not reproduce its state hash. The scene, assets or engine changed since the file was saved; save it again.
E_SCENARIO_FAILED A test script step did not meet its expectation. Read the report: the failing step, the expectation and the actual result are listed.
E_SCENARIO_IO A scenario file could not be read or its output folder could not be written. Check the scenario path and that the output folder is writable.
E_SCENARIO_SCHEMA A scenario file does not match the scenario format. Validate the file against schemas/scenario.schema.json; the message lists each JSON path.
E_SCENARIO_STEP A scenario step cannot run. Read the message for the step: a bad snapshot path or an until on a screenshot, for example.
E_GOLDEN_MISSING A golden image does not exist. The capture is saved as <out>/<scenario>/<name>.actual.png; review it and approve it as the golden.
E_GOLDEN_MISMATCH A capture differs from its golden image beyond the tolerance. Compare the .actual.png with the golden; update the golden only when the change is intended.
E_GOLDEN_SIZE A capture and its golden image have different sizes. Capture at the golden’s size, or approve a new golden.
E_IMAGE_IO A PNG file could not be read or written. Check the path and the file permissions; the file must be a PNG.
E_IMPORT_UNSUPPORTED The file is not an asset kind the import pipeline processes. Import a PNG, JPEG, glTF or GLB file; assets.report lists what the pipeline handles.
E_IMPORT_SETTINGS The import settings in the .meta are not valid. Fix the import object of the .meta; the message names the field (see schemas/commands/assets.import.schema.json).
E_IMPORT_FAILED The asset could not be processed. Read the message: the source file is damaged or uses a feature the importer does not support.
E_SYSTEM_ORDER The declared order of the systems has a cycle, or two systems have the same name. Fix the after / before lists; the message names the systems involved.
E_SYSTEM_ACCESS A system touched a component it did not declare. Declare the component in the system’s reads or writes (a write needs writes).
E_INTERNAL Internal engine error. Report this with the run id and the frame number.

Everything the runtime wants to report is an Event on the queue (drain_events(), events_mut()): {seq, frame, level, kind, target, code?, message, fields}. seq is never reused, so a gap shows dropped events (EventQueue::dropped()). frame is the frame counter after the step that produced the event.

kind target fields
script_log script object
script_error script object; code is E_SCRIPT_RUNTIME, E_SCRIPT_BUDGET, E_SCRIPT_MEMORY or E_SCRIPT_LOAD (also for a failed hot reload, reload_scripts())
contact physics a, b (entity names or ids), started
animation animation object, event, frame
particle_finished particles object
snapshot runtime frame
log any warnings from asset loading (E_ASSET_IO) and from other crates

Other crates push their own events through events_mut().push(frame, EventSpec::new(...)).

set_event_log(writer, min_level) writes events as JSON lines: {"run", "frame", "level", "target", "kind", "code"?, "msg", "fields"}. The game binary does this on stderr for agent runs and for --log-json; sparklight_core::events::init_json_logging does the same for log:: records.

schemas/*.schema.json (JSON Schema 2020-12) describe every file format: scene, prefab, clip, tilemap, palette, input-map, emitter and components (one entity). They are generated from the Rust types; a test fails when the committed files are stale: cargo run -p sparklight-engine --example write_schemas. Formats owned by other crates (clip, tile map, palette, input map, emitter) are described by mirror types in schemas.rs; tests check the mirrors against the real parsers, the presets and every sample file. schemas::validate(kind, &value) validates a value in code and returns one line per violation with its JSON path.

Runtime::attach_renderer(Box<dyn FrameRenderer>) plugs an off-screen renderer in; render_frame(w, h) returns an RGBA Frame or E_RENDERER_UNAVAILABLE. Renderers that ship: sparklight_scenario::GpuFrameRenderer (wgpu, off-screen; the spark binary attaches it for the first capture.frame when an adapter exists) and the flat-colour software renderer of sparklight-control (every sprite as a rectangle; the fallback, and the one --no-gpu selects). capture.frame and the golden commands of the control plane (docs/agent/commands.md) sit on top; a headless runtime without a renderer reports E_RENDERER_UNAVAILABLE.