Online play
Games made with Sparklight can be played online: a headless dedicated server runs the game, clients join by address, free slots are filled by bots, and a client plays its own player without waiting for the network (prediction) while everybody else is drawn smoothly (interpolation). It works for the 2D runtime and for Space3D (vehicles are the first customer: samples/netplay/track.space3d.json).
Code: crates/sparklight-net (transport, replication, server, client, adapters), the net.* commands in crates/sparklight-control/src/commands/net/, the verbs spark serve-game and spark join-game in crates/sparklight-cli/src/netgame.rs. Tests: crates/sparklight-net/tests/{session,engine}.rs, crates/sparklight-control/tests/net.rs, crates/sparklight-cli/tests/netplay.rs (real processes).
Quick start
Section titled “Quick start”# a dedicated racing server on 127.0.0.1:7777 (stop it with Ctrl-C, `quit` on stdin, or --duration SEC)GAMEWAY_MUTE=1 spark serve-game samples/netplay/track.space3d.json --assets samples/netplay/assets --port 7777
# two scripted clients that join, get ready and drive for 20 s over an 80 ms round trip with 2% loss each way;# their summary lists every authoritative snapshot (tick, state hash) they sawGAMEWAY_MUTE=1 spark join-game 127.0.0.1:7777 --name ann --scene samples/netplay/track.space3d.json --assets samples/netplay/assets \ --ready --drive sine --duration 20 --link rtt=80,jitter=4,loss=2%The same from one session, as commands (MCP tools net_host, net_join, …):
{"command": "net.host", "params": {"scene": "samples/netplay/track.space3d.json", "assets": "samples/netplay/assets", "start": "auto"}}{"command": "net.join", "params": {"address": "127.0.0.1:PORT", "label": "ann", "scene": "samples/netplay/track.space3d.json", "assets": "samples/netplay/assets"}}{"command": "net.simulate_link", "params": {"target": "all", "rtt_ms": 80, "jitter_ms": 4, "loss": 0.02}}{"command": "net.ready", "params": {"client": "ann"}}{"command": "net.pump", "params": {"ms": 700}}{"command": "net.input", "params": {"client": "ann", "inputs": {"throttle": 1.0}}}{"command": "net.pump", "params": {"ms": 3000}}{"command": "net.converged", "params": {}} // every snapshot a client saw equals the server's state at that tick{"command": "net.view", "params": {"client": "ann"}}Nothing advances between commands: net.pump lets real time pass. A session hosts at most one server and any number of clients.
The model
Section titled “The model”- Server-authoritative. The server owns the world and runs it at a fixed tick (
tick_hz, default 30). Clients send inputs, never state. - Inputs. One input frame per client tick: a fixed-point number per named input (
throttle,steer, …), sent unreliably, the last four repeated in every packet (a held input costs about one byte per frame). The server queues a player’s inputs, starts consuming them when two are buffered (input_buffer_target) to absorb network jitter, applies one per tick, repeats the last one when the queue runs dry, and drops the oldest when more than six are waiting. - Snapshots. After every tick the server sends each client the state of all replicated entities as a delta against the newest snapshot that client acknowledged (zigzag varints of the difference of the quantised values; unchanged fields cost nothing). A lost snapshot costs nothing: the next one is still relative to an acknowledged one. A snapshot over one packet is fragmented. Every snapshot says “the server has applied your inputs up to number n”.
- Prediction and reconciliation. The client runs the same simulation for its own player. It remembers the state it predicted after each input; when snapshot “applied up to n” arrives it compares (per field, within the field’s tolerance). On a mismatch it writes the authoritative state into its simulation, replays the inputs the server has not applied yet, and fades the visible jump out over
smooth_ms(100 ms). Mispredictions are counted (net.stats:mispredictions,max_error,worst_field) and reported as events. - Interpolation. Other entities are drawn
interp_delay_ticks(3) behind the newest snapshot, blended between the two around that time (positions linear, angles the short way, quaternions normalised lerp, flags stepped). The client tracks the server’s clock as the fastest delivery it has seen. - Lag compensation. The server keeps the last 128 states. A client action (
send_action) carries the server tick the player was looking at (view_tick);Simulation::on_actionreceives the rewound replicated state at that tick (at most 12 ticks back) to judge a hit against what the shooter saw. - Determinism for proof. A replicated state is quantised, so it can be hashed:
WorldState::hash(). The server logs(tick, hash), the clients log the hash of every authoritative snapshot;net.convergedand the process tests compare them.
The net game file (<scene>.net.json)
Section titled “The net game file (<scene>.net.json)”A scene stays a plain scene; the multiplayer game is a file next to it (foo.space3d.json → foo.net.json, or --net file). Schema: schemas/net-game.schema.json (regenerate with cargo run -p sparklight-net --example write_net_schema).
{ "version": 1, "tick_hz": 30, "players": [{"entity": "Car1"}, {"entity": "Car2"}, {"entity": "Car3"}, {"entity": "Car4"}], "replicate": ["Barrel_*"], "inputs": ["throttle", "steer", "brake"], "drive": {"kind": "vehicle", "throttle": "throttle", "steer": "steer", "brake": "brake"}, "bots": {"kind": "waypoints", "points": [[0, -30], [40, -30], [40, 30], [0, 30]], "radius": 8}, "server": {"name": "my track", "min_players": 1, "start": "auto"}}| Key | Meaning |
|---|---|
players |
Slot i controls the object named entity. Network id = slot + 1, owner = slot + 1. |
replicate |
Other replicated objects (names, * and ? wildcards). Network ids follow the slots, in name order. Must be top-level objects (no parent). |
inputs |
Input names in wire order. Every input becomes the action p<slot+1>.<name> (p1.throttle), which Lua scripts read with sparklight.action("p1.throttle") (3D and 2D). |
drive |
Native input mapping for Space3D: vehicle (throttle -1..1, brake 0..1, steer -1..1, handbrake) or character (move_x, move_z times speed, jump). Without it only the actions are set. |
extra |
More replicated numbers by JSON path ({"name": "Race", "fields": [{"name": "lap", "path": "Race/lap", "step": 1, "interp": "snap"}], "scope": "players"}): booleans become 0/1. Use it for any gameplay state a script keeps (see Limits). |
quantization |
Steps (metres, unit-quaternion components, m/s; defaults 0.005, 0.001, 0.02) and prediction tolerances (0.03 m, 0.01, 0.15 m/s). Coarser steps cost fewer bytes. |
bots |
idle, scripted (steps of ticks + inputs, repeated; each bot starts at another offset), or waypoints (drives a 3D object around points in the XZ plane with steer/throttle/brake; forward is z for vehicles or -z). |
server |
Defaults for name, min_players, start, fill_bots; command-line flags override them. |
steps_per_tick, max_players |
Simulation steps per network tick (default: the scene’s step rate / tick_hz, so 60 Hz physics at 30 Hz = 2) and slots in use. |
Replicated per object: Transform (3D: x y z qx qy qz qw, rotation sign-normalised so q and -q agree; 2D: x y rot) and Velocity (3D linear and angular velocity of the body, the vehicle chassis or the character body; 2D vx vy w), then, for Space3D objects with a Vehicle, the hidden vehicle state: Drivetrain (gear, engine speed, clutch, pedals, shift timers, steering position, traction-control scale, rev limiter), Wheels (per wheel: spin rate, suspension compression, steering angle, slip angle, ABS factor; up to 6 wheels) and, when a vehicle has a VehicleDamage, Damage (health of body, engine, tyres and suspension), then the extra components. Hidden fields are restored on a correction and sent to every client (remote wheels spin), but a difference in them alone is not a misprediction (FieldDef.check). The dents of the body mesh and detached parts are not replicated (visual; a game that needs them keeps them in extra).
Lobby, sessions, bots
Section titled “Lobby, sessions, bots”Phases: lobby (players join, set ready; the host is the first human to join and may start the game) and playing.
start |
The game begins |
|---|---|
lobby |
when the host sends Start (or net.start) |
auto (default) |
when at least min_players humans are connected and every human is ready |
now |
at once, with bots everywhere; humans join and take over a bot slot (drop-in) |
- A joining human takes the lowest free slot; in a running game a slot held by a bot is taken over. No slot:
E_NET_FULL. fill_bots(default on): when a game starts every free slot gets a bot; when a human leaves a running game a bot takes the slot.- When the last human leaves a running game, the server resets the world (restored bit for bit) and returns to the lobby, unless
startisnow. - A client with another scene or another net game file than the server is refused (
E_NET_SCENE_MISMATCH, identity = hash of the loaded state plus the replication schema). A client without a scene joins as an observer (no prediction, no check). - The server drops a client that is silent for 5 s (a killed process) and tells the others; a connection that never says hello is dropped after 5 s.
- Clean shutdown:
spark serve-gamestops on SIGINT, SIGTERM,quiton stdin,--durationor--ticks; it sends a DISCONNECT with reasonshutdownto every client three times, prints the final report and exits 0. Clients see a clean close (no error).
Command line
Section titled “Command line”spark serve-game <scene> [--port N] [--bind ADDR] [--net file] [--assets dir] [--seed N] [--name text] [--max-players N] [--min-players N] [--start lobby|auto|now] [--no-bots] [--link spec] [--duration SEC] [--ticks N] [--report file] (or: --demo toy [--toy-slots N] [--toy-props N])spark join-game <host:port> [--name text] [--scene file] [--net file] [--assets dir] [--ready] [--start-game] [--input name=value]... [--drive sine|none] [--duration SEC] [--link spec] [--no-predict] [--report file]- A server listens on 127.0.0.1 (default port 7777).
--bind ADDRis the explicit request to listen on another address (a LAN address, or0.0.0.0); the first output line says(LAN). Servers never listen on the network by accident. --linksimulates the network for what that process sends:latency=40,jitter=5,loss=0.02,duplicate=0(alsortt=80,loss=2%). Put it on both processes for a symmetric link;rtt=80on both is an 80 ms round trip.- Exit codes: 0 ok, 1 coded error (printed as JSON on stdout and text on stderr), 2 usage, 5 cannot bind.
- Both verbs are headless and silent. A client with
--scenepredicts; its scene must be the server’s.
Commands
Section titled “Commands”All have schemas in schemas/commands/; the table with parameters is in docs/agent/commands.md.
| Command | Use |
|---|---|
net.host |
start a server in the session (loopback unless lan: true and bind) |
net.join |
join as a client (label it; several clients per session are fine); waits for the welcome |
net.pump |
let real time pass (ms or ticks) |
net.status / net.stats |
phase, tick, players, rtt, loss, hashes / counters: snapshot sizes, bytes per second per client, retransmissions, starved and dropped inputs, predictions checked, mispredictions with the worst field, replayed ticks |
net.converged |
proof: every snapshot a client saw (tick, hash) against the server’s state at the same tick |
net.view |
what a client would draw: predicted local player, interpolated others, in world units |
net.input, net.ready, net.start, net.end, net.action |
play: inputs, lobby (ready, start, end the game), lag-compensated actions |
net.simulate_link |
delay, jitter, loss, duplicates for what a side sends (target: host, a label, all) |
net.kick, net.stop |
remove a player, shut down |
net.events |
joins, leaves, phases, refusals, game events, mispredictions, closes since the last call |
Errors
Section titled “Errors”| Code | Meaning |
|---|---|
E_NET_BIND |
The UDP socket could not be bound. |
E_NET_BIND_REFUSED |
Binding a non-loopback address needs an explicit request. |
E_NET_ADDRESS |
An address is not host:port. |
E_NET_VERSION |
Client and server speak different protocol versions (data has both). |
E_NET_FULL |
The server has no free player slot. |
E_NET_TIMEOUT |
The peer stopped answering. |
E_NET_REFUSED |
The server refused the connection. |
E_NET_KICKED |
The server removed this player (the message has the reason). |
E_NET_SCENE_MISMATCH |
The client’s scene differs from the server’s. |
E_NET_CONFIG |
The net game configuration is invalid (the message names the JSON path). |
E_NET_PROTOCOL |
A message could not be decoded. |
E_NET_MESSAGE_TOO_LARGE |
A message exceeds the size limit. |
E_NET_NOT_HOSTING |
No server runs in this session. |
E_NET_ALREADY_HOSTING |
A server already runs in this session. |
E_NET_NOT_CONNECTED |
No such client in this session (or not joined yet). |
E_NET_CLIENT_EXISTS |
A client with this label already exists. |
E_NET_PLAYER_UNKNOWN |
No such player. |
E_NET_PHASE |
Not allowed in the current phase. |
E_NET_NOT_HOST |
Only the host may do this (arrives as a notice to the client). |
E_NET_SCENE |
The scene could not be loaded for the network game. |
The list is sparklight_net::ERROR_CODES (a test checks that code, list and this table agree).
The Rust API (racing demo and other games)
Section titled “The Rust API (racing demo and other games)”Add sparklight-net and build on three types.
1. Describe the game with a *.net.json file (above), or implement Simulation yourself for a world of your own (sparklight_net::testkit::ToySim is a complete 100-line example):
pub trait Simulation { fn schema(&self) -> &Schema; // replicated components + input names fn slots(&self) -> usize; // players fn entity_ids(&self) -> Vec<u32>; // network ids fn entity_of_slot(&self, slot: usize) -> Option<u32>; fn read_entity(&self, net_id: u32) -> Option<EntityState>; // quantised state after a tick fn write_state(&mut self, state: &WorldState); // authoritative state in (client reconcile) fn tick(&mut self, inputs: &[Option<&InputFrame>]); // one network tick; server: all slots, client: the local slot only fn reset(&mut self); // back to the lobby fn on_action(&mut self, slot: usize, kind: u16, payload: &[u8], rewound: Option<&WorldState>) -> Vec<GameEvent>; // lag compensation // + player_joined / player_left / take_events / apply_views / scene_hash / as_any ... with defaults}Sim3D::load(scene, assets, net, seed) and Sim2D::load(...) implement it for engine scenes.
2. A client in your game loop (window or not):
use sparklight_net::runner::{JoinOptions, JoinedClient, SceneSpec};
let mut c = JoinedClient::start(&JoinOptions { server, name: "ann".into(), predict: true, link: Default::default(), seed: None, scene: Some(SceneSpec { scene: Some("track.space3d.json".into()), assets: Some("assets".into()), ..Default::default() }) })?;c.wait_joined(std::time::Duration::from_secs(5))?; // or call c.pump() every frame and watch c.client.player()c.client.set_ready(true)?;loop { // every rendered frame c.client.set_input("throttle", gamepad.trigger()); // -1..1, held until changed c.client.set_input("steer", gamepad.stick_x()); c.pump(); // receive, run due local ticks (prediction), send inputs let now = c.clock.now(); let view = c.client.sync_sim(now); // predicted local car + interpolated others, written into the client's own scene let space = c.client.sim_as::<Sim3D>().unwrap().space(); // the client's own scene: draw_list(), camera(), light(), ... // or read plain numbers: view.entity(id).unwrap().vec3(c.client.schema().unwrap(), "Transform") for e in c.take_events() { /* Welcome, PhaseChanged, Game{..}, Misprediction{..}, Closed(..) */ }}view.entities[i]is anEntityView { net_id, kind, owner, local, values };get(schema, "Transform", "x")reads a field in world units. The local entity islocal == trueand predicted (blended between its last two predicted ticks, so it is smooth at any frame rate, with the fading correction on top); every other entity is interpolated.- The client’s
Space3D/Runtimeis a normal scene: render it with the engine’s draw lists.sync_simwrites the remote entities into it at the interpolated pose, the local one is simulated by the prediction. client.send_action(kind, payload)sends a lag-compensated action; the server’sSimulation::on_actionreturnsGameEvents that all clients receive asClientEvent::Game.client.view_tick()is the server tick being shown (fractional).
3. A server in your own program: HostedServer::start(&HostOptions{..}) then pump() in a loop (this is what spark serve-game does), or GameServer::new(sim, ServerConfig{..}, socket, now) with your own socket and clock; update(now) does everything that is due. Game events: Simulation::take_events and on_action. Bots: server.set_bot_factory(Box::new(|slot| Box::new(MyBot))) with impl Bot (think(&BotCtx) -> InputFrame), or the bots of the file.
Tests without sockets or sleeping: sparklight_net::harness::Harness runs a server and clients on an in-memory network with a virtual clock (1 ms per step, repeatable with a seed; the seed fixes every random choice, so a failure reproduces).
Transport
Section titled “Transport”UDP only. A datagram is at most 1200 bytes. Handshake (stateless on the server until it succeeds, replies never larger than what triggered them): CONNECT (magic SPKL, protocol version, nonce, padded to 512 bytes) → CHALLENGE → RESPONSE → ACCEPT {connection id} or DENY {reason, server version}; both sides retransmit every 150 ms for up to 4 s. A wrong protocol version is E_NET_VERSION with both versions. After that every packet carries the connection id, a 16-bit packet number, the newest received packet number and a 32-bit mask of the 32 before it (so every packet acknowledges 33 packets), and the acked packet’s waiting time (subtracted from the round-trip sample).
| Channel | Delivery | Carries |
|---|---|---|
| 0 control | reliable, ordered | hello, welcome (with the schema), lobby, ready, start, leave |
| 1 input | unreliable | input frames |
| 2 state | unreliable, fragmented | snapshots |
| 3 event | reliable, ordered | game events, actions |
Reliable frames are retransmitted until acknowledged (timeout = smoothed rtt + 4 × variation, backed off up to 4×; or at once when the packet that carried them fell out of the acknowledgement window), delivered exactly once and in order, and a message larger than one packet (a welcome with a big schema, up to 1 MiB) is split and reassembled. Unreliable messages up to 255 fragments are reassembled and dropped as a whole when a fragment is lost. Measured per connection: smoothed rtt, loss (smoothed fraction of our packets that were never acknowledged), bandwidth over the last second (wire bytes including the 28 bytes of IP and UDP), retransmissions. Timeouts: 5 s of silence closes a connection; a reliable backlog of 4096 frames means the peer is gone. Malformed input never panics (the decoders return None/errors; fuzz tests cover datagrams, frames, messages and snapshots).
The simulated link (LinkConfig: latency, jitter, loss, duplicates, per direction, applied when sending; seeded) wraps any socket; change it at any time with net.simulate_link, --link, or set_link.
Measured
Section titled “Measured”Debug build, one Mac, loopback, 80 ms round trip (40 ms each way, 4 ms jitter), 2% loss each way:
| What | Number | Where measured |
|---|---|---|
| Bandwidth per client, 20 entities (4 players + 16 props), all moving, 30 Hz | about 118 B per snapshot, 41 kbit/s server to client, 19 kbit/s client to server (wire, IP and UDP headers included) | crates/sparklight-cli/tests/netplay.rs bandwidth_per_client_with_20_entities_at_30_hz (two real client processes), and session.rs (in process) |
| Round trip measured by the transport on an 80 ms link | 80 ± 3 ms | conn.rs unit test and every process test |
| Racing scene (4 cars on Space3D vehicles + 4 barrels), 2 client processes, 12 s | every authoritative snapshot of both clients equals the server’s state at the same tick (0 mismatches) | netplay.rs a_server_process_and_two_client_processes_converge_over_a_lossy_link_in_3d |
| Same, prediction (hidden vehicle state replicated) | about 345 predictions checked per client; 9 to 17 wrong in 8 client runs (2.6 to 4.9%), largest error 0.64 m/s of vertical velocity. Before the hidden state was replicated: 2 to 3% in calm runs and 25% to 63% after cars crashed | same test, printed with --nocapture |
| Why it matters | after a correction the car drifts 0.12 m in the next 1.5 s with the hidden state restored, 1.54 m without (13 times more) | engine.rs a_corrected_client_continues_the_car_only_if_the_hidden_vehicle_state_is_restored |
| Racing size: 8 cars with damage models (2 human, 4 bots, 2 parked) and 4 barrels, 80 ms, 2% loss, 30 Hz | about 203 B per snapshot, 64 kbit/s down and 16 kbit/s up per client (wire); every snapshot equals the server’s state; 13 of 469 predictions wrong | engine.rs eight_cars_four_bots_two_clients_converge_with_a_measured_bandwidth |
| Same for a 2D runtime scene with Lua players | converges, 0 mismatches | netplay.rs the_same_over_a_2d_scene |
Reproduce: cargo test -p sparklight-cli --test netplay -- --nocapture --test-threads=1.
Limits and known gaps
Section titled “Limits and known gaps”- The set of replicated objects is fixed for a scene (the file lists it). The protocol itself spawns and despawns entities in snapshots (new entity: kind, owner, all fields; gone: removed list), but
Sim3D/Sim2Ddo not create or destroy scene objects; a game that needs projectiles implementsSimulationitself or keeps a pool of pre-placed objects whose state (active, position) is replicated. - Other hidden state is not replicated. The client’s prediction restores positions, rotations, velocities and the vehicle state above; a Lua script’s
selffields, a destructible’s chunks, body-mesh dents, detached vehicle parts and particle state are not. They are re-created by running the same inputs, so they usually agree, but after a correction they can differ for a moment. Put gameplay state that matters (lap, health, ammunition) into components and list it inextra. - Remote entities in the client’s simulation are stand-ins written by
sync_sim(or coasting without it). A car pushing another car predicts the contact against where the other car was drawn (100 ms ago), so contacts cause mispredictions that the correction then fixes. - No encryption, authentication or NAT traversal; a server is meant for loopback, a LAN or a host with a public port. Gameway launch tickets are not checked yet (the handshake has the place for it).
- One simulation thread per process. The server ticks at most 5 times per
updateand skips ahead when it falls further behind (late_ticks_skippedinnet.stats). - The windowed
spark rundoes not join games yet: a windowed client is a few lines aroundJoinedClient(above) and a renderer;join-gameis headless. - Windows and non-Unix systems: the code compiles but only macOS was exercised; SIGINT/SIGTERM handling is Unix only (use
quiton stdin or--durationelsewhere).