Skip to content

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.

Terminal window
# one process per command: a scene, 240 steps, the state hash
spark 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/out
spark 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"}}

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.

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.

Space3D::step() (and each fixed step of update(dt); the step is 1/60 s unless the scene file says physics.hz):

  1. flush components that were waiting for a reference; 1b. Script callbacks: 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): AiAgent components 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;
  2. kinematic bodies are pushed to their transforms; edited transforms move their bodies;
  3. 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;
  4. characters and vehicles step; a dynamic ragdoll with no animator is driven with no target;
  5. PhysicsWorld::step;
  6. contact events become Contact3D and contact events, broken joints become joint_broken; scripts get on_contact for both objects of every contact that began or ended;
  7. 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;
  8. poses of dynamic bodies go to Transform3D; draw_list, camera and light blend the previous and latest pose by render_alpha (so a camera parented to a car or a ragdoll bone is smooth at any render rate).
  • space3d.hash hashes the frame, every physics body (pose and velocities), object names and transforms, destructible, cloth, character, joint and animator state, and the Lua scripts (self fields, started flag, math.random state) 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.run twice). Across machines build with the facade feature deterministic3d (sparklight-scene3d/deterministic): verified arm64 macOS against x86_64 Linux by pinned hashes (docs/agent/testing.md, “Determinism across machines”).
  • space3d.snapshot.take copies the objects, the whole physics world (PhysicsWorld::snapshot, with contact caches), the runtime of every feature and the stepping bookkeeping. space3d.snapshot.restore checks 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.

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 update
function 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) -- CharacterController
end
function 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.

Terminal window
spark run samples/space3d/courier.space3d.json --assets samples/space3d/assets # WASD / arrows, Space, Enter
spark run game.space3d.json --gw-mute --frames 600 # an agent run: muted, closes itself, prints a summary

spark 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.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.json
process 2: snapshot.load courier.snapshot.json -> step 200 # hash at frame 300 == an uninterrupted run

Restoring 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.

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.

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.

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.

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.

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.

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.

  • 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 through space3d.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.save files or batch.run rollback; use space3d.snapshot.* and space3d.log.