Skip to content

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

Terminal window
# 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 saw
GAMEWAY_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.

  • 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_action receives 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.converged and the process tests compare them.

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

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 start is now.
  • 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-game stops on SIGINT, SIGTERM, quit on stdin, --duration or --ticks; it sends a DISCONNECT with reason shutdown to every client three times, prints the final report and exits 0. Clients see a clean close (no error).
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 ADDR is the explicit request to listen on another address (a LAN address, or 0.0.0.0); the first output line says (LAN). Servers never listen on the network by accident.
  • --link simulates the network for what that process sends: latency=40,jitter=5,loss=0.02,duplicate=0 (also rtt=80, loss=2%). Put it on both processes for a symmetric link; rtt=80 on 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 --scene predicts; its scene must be the server’s.

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
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 an EntityView { net_id, kind, owner, local, values }; get(schema, "Transform", "x") reads a field in world units. The local entity is local == true and 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/Runtime is a normal scene: render it with the engine’s draw lists. sync_sim writes 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’s Simulation::on_action returns GameEvents that all clients receive as ClientEvent::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).

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.

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.

  • 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/Sim2D do not create or destroy scene objects; a game that needs projectiles implements Simulation itself 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 self fields, 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 in extra.
  • 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 update and skips ahead when it falls further behind (late_ticks_skipped in net.stats).
  • The windowed spark run does not join games yet: a windowed client is a few lines around JoinedClient (above) and a renderer; join-game is headless.
  • Windows and non-Unix systems: the code compiles but only macOS was exercised; SIGINT/SIGTERM handling is Unix only (use quit on stdin or --duration elsewhere).