3D through commands
Space3D (crate sparklight-scene3d) is the 3D scene: objects with components on the same ECS as the 2D engine, one physics world
(sparklight-physics-fx), skeletal animation (sparklight-skeletal) and the PBR renderer (sparklight-3d). Everything an agent needs
is a space3d.* command (module crates/sparklight-control/src/commands/space3d/), so a 3D game can be built, driven and verified
without a window: spark call, spark serve, spark mcp (every command is an MCP tool named space3d_<verb>), and spark test.
General rules of the command surface: docs/agent/commands.md. Physics features: docs/agent/physics.md. Animation:
docs/agent/animation3d.md. Rendering: docs/agent/rendering.md.
A 3D game is also playable: spark run <file>.space3d.json opens it in a window (section Playing in a window), its objects can carry Lua
scripts (section Lua scripts), and a session’s 2D and 3D state can be saved to a file and rebuilt in another process (section Snapshot files).
A session holds one 3D scene next to its 2D runtime. They are independent: space3d.step does not advance the 2D clock, batch.run
and txn.* do not roll the 3D scene back (use space3d.snapshot.*), and the state-changing 3D commands go to their own log
(space3d.log), which replays to the same state hash.
Quick start
Section titled “Quick start”# one process per command: a scene, 240 steps, the state hashspark call space3d.scenario.run '{"path": "samples/space3d/scenarios/ragdoll-wall.scenario3d.json"}' --json
# the same as a test (exit code 3 on failure); the report lands below .gwdev/outspark test samples/space3d/ragdoll-wall.test.json
# a session an agent keeps talking to (MCP over stdio): tools space3d_load, space3d_step, space3d_capture, ...spark mcp{"command": "space3d.load", "params": {"demo": "ragdoll_wall"}}{"command": "space3d.body.launch", "params": {"entity": "Ball", "velocity": [0, 0.8, -28]}}{"command": "space3d.step", "params": {"frames": 150}}{"command": "space3d.assert", "params": {"kind": "event", "event": "pieces_detached", "entity": "Wall"}}{"command": "space3d.capture", "params": {"width": 640, "height": 360, "path": "wall.png"}}Components
Section titled “Components”space3d.components lists them, space3d.component_schema {name} gives the JSON Schema and a default value. Values are plain JSON;
vectors are [x, y, z], quaternions [x, y, z, w]. Setting a component validates it first and changes nothing when it is invalid.
References to other objects are strings: a name, or #7 (#7.2 after the slot was reused); self is the object itself.
| Component | What it is |
|---|---|
Name, Transform3D, Parent |
identity and pose; a child’s transform is local to its parent |
MeshRenderer |
builtin:cube, builtin:sphere, builtin:plane, builtin:cylinder, a glTF path or an FBX path below the assets folder (an .fbx is converted by the asset importer with the fbx settings of its .meta, docs/agent/assets.md); colour, metallic, roughness overrides |
Camera, Light |
perspective camera, directional light (both look along local -Z) |
PointLight, SpotLight |
a point light (intensity = radiant intensity at 1 m, range 0 derives it) and a spot cone along local -Z (inner_angle, outer_angle in radians); cast_shadows gives the light a cube or atlas shadow map within the quality tier’s budget; they feed Scene3D::lights and Scene3D::shadow_lights in space3d.capture |
Body |
rigid body with one collider: Box, Sphere, Capsule, FromMesh (bounding box), ConvexHull (hull of the model), TriMesh (exact, Fixed only); ccd, sensor, layer, collides_with, surface (asphalt, grass, gravel, …: wheel grip and the surface name in vehicle telemetry) |
Joint |
fixed, revolute, prismatic, spherical, cone_twist between this body and other (or the world); limits, motor, break_force |
Ragdoll |
capsules and limited joints at the object’s pose: the built-in humanoid, or the bones of the object’s Skeleton; drawn as a skinned character (draw: "skin", default) or as bare capsules ("bones") |
Destructible |
Voronoi chunks and bonds from a box, cylinder, sphere or a pre-fractured asset; breaks into debris that shrinks away (debris_lifetime, debris_fade_time) and is pooled (debris_max_active) |
CharacterController |
kinematic capsule: slopes, steps, jumping, platforms |
Vehicle |
raycast vehicle: Pacejka tires, suspension, engine, clutch, gearbox, differentials, aids (sedan or rally preset plus a merge patch of overrides); docs/agent/vehicles.md |
VehicleDamage, DeformableMesh, DetachablePart |
impact damage for any dynamic body or vehicle (dents, body health, on a vehicle also engine, wheel and suspension damage that changes the handling), the mesh that crumples (uploaded to the GPU in place), parts that tear off into debris; space3d.damage.hit / .repair; docs/agent/vehicles.md |
Rope |
rope or chain of rigid links, ends optionally pinned to bodies |
Cloth |
XPBD sheet pinned at corners or an edge; collides with the world |
Skeleton, Animator |
glTF rig; animation graph (*.animgraph.json) running on it |
RootMotionTarget, RagdollLink, IkTarget |
root motion to the transform or the character controller; animation-to-ragdoll blend with a physics weight per bone; two-bone and FABRIK IK |
Sequence |
a cinematic (*.sequence.json): keyframed objects, camera shots, cues, audio and time dilation; see docs/agent/cinema.md |
Script |
a Lua behaviour: path (a .lua file below the assets folder) or inline source; see Lua scripts |
AiAgent |
a navigation agent with a brain on a baked navmesh, steered by space3d.step; see docs/agent/navigation.md |
ParticleEmitter3D, TrailRenderer, Decal |
3D effects: particles from a built-in or defined effect, a skid-mark or ribbon strip that follows the object, a texture projected onto the geometry in the object’s box; see docs/agent/vfx.md |
PrefabInstance |
editor data: this object is the root of an instance of a prefab file (source); the editor’s overrides are what differs from the file (docs/agent/editor.md); nothing in the simulation reads it |
PhysicsBody, Features |
read-only: the live body (velocity, mass, sleeping), and the live state of the object’s features |
A feature is built when its component is set. When it refers to something that does not exist yet (the other end of a joint, a
rig), it waits and is built at the next step; space3d.info shows pending_components, and a scene file with a reference that never
resolves is rejected whole (E_SPACE3D_REF_UNRESOLVED). Removing a component or despawning the object tears the feature down, bodies
included.
Commands
Section titled “Commands”All of them are listed with their schemas in schemas/commands/ and by spark commands --prefix space3d..
| Area | Commands |
|---|---|
| Scenes | space3d.new, space3d.load (path, json or demo: pyramid, ragdoll_wall), space3d.save, space3d.info |
| Objects | space3d.list, space3d.get, space3d.set, space3d.patch, space3d.remove, space3d.spawn, space3d.despawn, space3d.components, space3d.component_schema, space3d.state, space3d.camera.set |
| Time | space3d.step (exact fixed steps), space3d.advance (seconds), space3d.hash |
| Snapshots | space3d.snapshot.take, space3d.snapshot.restore, space3d.snapshot.list, space3d.snapshot.drop, space3d.determinism.check, space3d.log |
| Physics | space3d.physics.set (gravity), space3d.layers, space3d.layer.define, space3d.body.launch, space3d.raycast, space3d.shape_cast, space3d.overlap, space3d.closest, space3d.events |
| Features | space3d.ragdoll.hit, space3d.ragdoll.set_mode, space3d.ragdoll.set_strength, space3d.destructible.damage, space3d.destructible.shatter, space3d.character.move, space3d.vehicle.input, space3d.vehicle.telemetry, space3d.vehicle.damage, space3d.vehicle.repair, space3d.vehicle.tune, space3d.vehicle.reset, space3d.rope.cut |
| Animation | space3d.anim.set_param, space3d.anim.trigger, space3d.anim.play, space3d.anim.validate_graph, space3d.ik.set_target, space3d.skeleton.pose |
| Input and scripts | space3d.input, space3d.script.eval, space3d.script.limits |
| Cinematics | cinema.load, cinema.play, cinema.pause, cinema.stop, cinema.seek, cinema.status, cinema.evaluate, cinema.capture, cinema.validate (docs/agent/cinema.md) |
| Verification | space3d.assert, space3d.scenario.run, space3d.capture, space3d.golden.update, space3d.golden.check |
Paths work as in the 2D engine: space3d.get {"path": "/Ball/Transform3D/position/1"}; space3d.set takes a whole component
(/Crate/Body) or one field.
One physics step
Section titled “One physics step”Space3D::step() (and each fixed step of update(dt); the step is 1/60 s unless the scene file says physics.hz):
- flush components that were waiting for a reference;
1b.
Scriptcallbacks:on_update(self, dt)of every script (entity order), then the transforms they wrote move the bodies; 1c. navigation and AI (docs/agent/navigation.md):AiAgentcomponents become agents, obstacles follow their objects, brains tick (name order), crowds steer (mesh-name order), characters get their velocity (and a jump press on a jump link), plain objects their transform; - kinematic bodies are pushed to their transforms; edited transforms move their bodies;
- animators update (
speed, parameters), IK is solved on the pose, root motion moves the object or feeds its character controller, a linked ragdoll is driven with the exported animation pose; - characters and vehicles step; a dynamic ragdoll with no animator is driven with no target;
PhysicsWorld::step;- contact events become
Contact3Dandcontactevents, broken joints becomejoint_broken; scripts geton_contactfor both objects of every contact that began or ended; - destructibles take contact-force damage and update their debris, cloth steps, the ragdoll’s simulated pose is blended into the animator’s final pose, characters and vehicles write their pose back to the object’s transform;
- poses of dynamic bodies go to
Transform3D;draw_list,cameraandlightblend the previous and latest pose byrender_alpha(so a camera parented to a car or a ragdoll bone is smooth at any render rate).
Determinism, snapshots, hashes
Section titled “Determinism, snapshots, hashes”space3d.hashhashes the frame, every physics body (pose and velocities), object names and transforms, destructible, cloth, character, joint and animator state, and the Lua scripts (selffields, started flag,math.randomstate) and input actions. Equal hash, equal state.- One binary on one machine: the same scene and commands give the same hash, in one process or in two (tested by running
spark call space3d.scenario.runtwice). Across machines build with the facade featuredeterministic3d(sparklight-scene3d/deterministic): verified arm64 macOS against x86_64 Linux by pinned hashes (docs/agent/testing.md, “Determinism across machines”). space3d.snapshot.takecopies the objects, the whole physics world (PhysicsWorld::snapshot, with contact caches), the runtime of every feature and the stepping bookkeeping.space3d.snapshot.restorechecks that the hash equals the snapshot’s, and stepping on gives the states the original run had (tested bit for bit with every feature alive). Reported events are a log and are not rewound.space3d.determinism.check {frames, repeats}does snapshot, run, restore, run and compares the hashes.
Lua scripts
Section titled “Lua scripts”An object with a Script component runs Lua. path is a file below the assets folder (read at the first step, so space3d.load {assets}
decides where; a missing file is one script_error event), source is inline text (a syntax error is E_SPACE3D_SCRIPT when the component is
set, and the previous script keeps running). The language, sandbox and limits are the ones of the 2D scripts (docs/LUA_API.md): no io/os/
require, 2,000,000 instructions per callback, 16 MiB per script state, a 2 s wall-clock backstop (space3d.script.limits changes them),
E_SCRIPT_BUDGET / E_SCRIPT_MEMORY / E_SCRIPT_RUNTIME for a callback that fails. A failing callback is a script_error event
{code, script, line, message}; the script runs again on the next step and the other scripts are not affected.
function on_start(self) self.coins = 0 end -- once, before the first updatefunction on_update(self, dt) -- every fixed physics step, dt = 1/60 self.position.x = self.position.x + sparklight.action("move_x") * dt self:move(sparklight.action("move_x") * 5, 0, 0, sparklight.action("jump") > 0) -- CharacterControllerendfunction on_contact(self, other, started) ... end -- contact began (true) or ended (false), `other` is a name| API | Does |
|---|---|
self.name, self.position, self.scale (x y z), self.rotation (quaternion x y z w), self.euler (x y z, radians, yaw is y) |
read and write the object’s local transform; the body follows at the same step. Your own fields (self.coins) live in the object’s data, survive snapshots and are part of the state hash |
self:velocity(), self:set_velocity(x, y, z), self:impulse(x, y, z) |
body velocity (metres per second), an instant impulse |
self:move(vx, vy, vz [, jump]) |
wanted velocity of a CharacterController (y ignored), one-shot jump |
self:hit(px, py, pz, ix, iy, iz [, radius]), self:ragdoll_mode("dynamic"|"kinematic"|"blended") |
hit a ragdoll at a world point (returns the bone), switch its mode |
self:damage(px, py, pz, radius, damage [, impulse]), self:shatter([impulse [, ox, oy, oz]]) |
damage or break a Destructible; both return how many pieces came off |
self:set_param(name, number_or_bool), self:trigger(name) |
animator parameter and trigger |
self:despawn(), sparklight.despawn(object_or_name) |
remove an object |
sparklight.find(name) |
another object (same methods, no custom fields), or nil |
sparklight.action(name) |
an input action (move_x, move_y, jump, fire, pause, look_x, look_y, or what space3d.input set) |
sparklight.log(text), sparklight.play(sound) |
a script_log event; a sound event and a sound for the window to play from <assets>/sounds/<name>.* |
Methods called on an object that lacks the feature fail the callback with a message that says what is missing (object has no physics body,
object is not destructible, …). The scripts’ math.random is seeded from the session seed (space3d.load takes Session seed) mixed with the
object id, so the same seed gives the same play. Space3D::snapshot stores each script (source, started flag, random state, self fields; a
function or cyclic table in self is E_SPACE3D_SCRIPT), and restore rebuilds them.
A headless game: set the input, step, assert.
{"do": "input", "action": "move_y", "value": 1}{"do": "step", "frames": 60}{"do": "assert", "kind": "script", "entity": "Courier", "code": "self.steps", "value": 60}{"do": "assert", "kind": "event", "event": "script_error", "max_count": 0}samples/space3d/scenarios/lua-courier.scenario3d.json plays the Lua sample samples/space3d/courier.space3d.json this way: a character walks
(scripts/courier.lua), a chase camera follows (scripts/follow_camera.lua), the shot breaks a wall and knocks a ragdoll over, a snapshot is
restored and the run repeats to the same hash.
Playing in a window
Section titled “Playing in a window”spark run samples/space3d/courier.space3d.json --assets samples/space3d/assets # WASD / arrows, Space, Enterspark run game.space3d.json --gw-mute --frames 600 # an agent run: muted, closes itself, prints a summaryspark run <file>.space3d.json [--assets dir] [--seed N] [--exit-after SEC] [--frames N] opens a window (crate sparklight-scene3d, module
play, feature play; sparklight_scene3d::play::run). Each frame: winit events become raw input, the project’s action map
(<assets>/input.json, else the default map) turns them into actions, the actions reach the scripts (sparklight.action, plus look_x /
look_y, the mouse movement while the right button is held), Space3D::update(dt) runs the fixed steps behind the real clock, and the scene is
drawn with its own camera blended between the previous and the latest step (render_alpha), so a camera on a car, a character or a ragdoll
bone is smooth at any refresh rate. Without a scene camera, or after Tab, a free camera is used (W/A/S/D, Q/E, Shift, wheel, right mouse).
Esc pauses, R reloads the scene, F fires a ball, closing the window quits.
Audio follows the studio mute rule: GAMEWAY_MUTE=1 or --gw-mute gives a silent engine that opens no device, and the run says
audio is muted by the studio signal. Sounds scripts request are played from <assets>/sounds. A test starts the real binary with
GAMEWAY_MUTE unset and --gw-mute (crates/sparklight-cli/tests/play3d.rs). With --exit-after or --frames the process leaves by itself
and prints one JSON line {mode, drawn_frames, sim_frames, hash, muted, audio, scripts, script_errors}. Exit codes: 0 ok, 1 the scene
cannot be read, 2 usage, 5 no window system or GPU. Windows are for people; agents use the headless commands.
Snapshot files
Section titled “Snapshot files”snapshot.save {path} and snapshot.load {path} (docs/agent/commands.md) write and rebuild a session’s whole world: the 2D runtime and the 3D scene.
The file (sparklight-control-snapshot, version 2) holds the seed, the 2D command log with its frame and hash, and the 3D command log (space3d.log)
with its frame and hash. Loading replays both logs in a scratch session and adopts the result only when both hashes match. Because every
state change of the 3D scene (loads, steps, inputs, edits, script evals, impulses) goes through a logged command, the file reproduces Lua
state, physics warm-start data and destruction exactly.
process 1: space3d.load courier ... step 100 -> snapshot.save courier.snapshot.jsonprocess 2: snapshot.load courier.snapshot.json -> step 200 # hash at frame 300 == an uninterrupted runRestoring costs the replay time; a file is valid for the same scene and asset files and the same engine build and machine class (like every hash).
space3d.snapshot.take / restore stay the in-memory way to branch inside one process.
Layers and queries
Section titled “Layers and queries”space3d.layers is the named layer table (bit 0 is default, at most 32; saved in the scene file as layers). A Body has a
layer and a collides_with list of layer names (null = all); two colliders interact when each one’s layer is in the other’s
list. Characters, ragdolls and destructibles take the same two fields. space3d.raycast, shape_cast, overlap and closest take
layers, include_sensors and exclude; every hit names the object and the part: body, ragdoll:<bone>, chunk:<n>,
rope:<n>, vehicle, character, joint_anchor.
Rendering and goldens
Section titled “Rendering and goldens”space3d.capture renders the scene headless with Renderer3D (clustered lights, cascaded shadows, IBL, post-processing; GPU
needed, E_SPACE3D_RENDERER otherwise) to a PNG below .gwdev/out; eye/target override the camera, tier picks a quality tier.
Pieces without an object of their own are drawn too: ragdoll bones (ellipsoids along the bones), destructible chunks (generated
meshes, outer and cut faces), debris, rope links, vehicle chassis and wheels, character capsules, cloth sheets. A capture is a pure
function of the scene: two captures of one state have the same pixel_hash (tested).
space3d.golden.update {name} writes goldens/<name>.png (3D goldens live in goldens/3d/), space3d.golden.check compares with
6/255 per channel on at most 0.4 percent of the pixels (like the renderer’s own goldens), and writes
golden3d-<name>.png (diff) and golden3d-<name>.actual.png below the output folder on a mismatch.
Open worlds
Section titled “Open worlds”A scene can hold an open world (terrain, foliage, sky, streaming): terrain.create, terrain.*, world.stream.*, world.atmosphere.*; the world is part of the scene file, of space3d.snapshot.* and of the state hash. Reference: docs/agent/world.md.
Scenarios
Section titled “Scenarios”A scenario is JSON (*.scenario3d.json; *.scenario.json is the 2D runner’s):
{ "version": 1, "name": "ragdoll-wall", "demo": "ragdoll_wall", "steps": [ {"do": "step", "frames": 120}, {"do": "body.launch", "entity": "Ball", "velocity": [0, 0.8, -28]}, {"do": "step", "frames": 150}, {"do": "assert", "kind": "event", "event": "pieces_detached", "entity": "Wall"}, {"do": "golden.check", "name": "3d/space3d_ragdoll_wall"}, {"do": "determinism.check", "frames": 60} ]}scene (a file, relative to the scenario) or demo loads the scene first. A step is a space3d.<do> command with the other fields as
parameters; expect_error: "CODE" makes a failure the expected outcome. The run stops at the first failing step with
E_SCENARIO_FAILED (data.step, data.error, data.results) and writes report.json. update_goldens: true turns
golden.check into golden.update.
Scene file
Section titled “Scene file”Space3D::to_json / from_json (format version 1, additive fields only): gravity, ambient, clear_color, active_camera,
layers, physics (hz, solver_iterations) and objects, each with name, transform, parent (index), mesh, camera,
light, body and components ({Component: value} for the physics features and animation components). Saving and loading
gives the same bytes. Schemas of the components: space3d.component_schema.
Error codes
Section titled “Error codes”Raised by this layer: E_SPACE3D_COMPONENT_UNKNOWN, _COMPONENT_VALUE, _COMPONENT_MISSING, _READONLY, _ENTITY_UNKNOWN,
_ENTITY_AMBIGUOUS, _REF_UNRESOLVED, _ASSET, _FEATURE, _LAYER, _SCENE, _SNAPSHOT_UNKNOWN, _NO_SCENE, _RENDERER,
_ASSERT, _SCRIPT; plus E_ASSERT_FAILED and E_SCENARIO_FAILED of the control plane. Errors of the features keep their codes:
E_FX_* (docs/agent/physics.md) and E_ANIM_* (docs/agent/animation3d.md). spark schema errors lists all of them.
Known limits
Section titled “Known limits”- Skinned meshes are not drawn yet: the renderer has no skinned draw path (
docs/agent/animation3d.md, section on the renderer). The skeleton pose is available throughspace3d.skeleton.pose, and ragdoll bones are drawn as ellipsoids. - Transform edits of an object with a ragdoll, character or vehicle do not teleport it (set the pose before creating the feature, or recreate the component).
- Cloth has no self collision; ropes snap only through
break_force. - Lua scripts for 3D are not supported; use scenarios and commands.
- The 3D scene is not part of
snapshot.savefiles orbatch.runrollback; usespace3d.snapshot.*andspace3d.log.