Talos docs

Writing a networked-ready game

Since 0.7Changed in next

Multiplayer is coming; it isn't in this build. You can't turn on a networked game yet.

Plain text

Multiplayer is coming; it isn't in this build. You can't turn on a networked game yet. This page is how to write a game now so that it will run networked when it arrives. A game written this way plays the same in single-player today: nothing here costs a single-player game anything.

The worked example is samples/arena-ready: up to four players in an arena, drones that chase them, blasters that fire on their own, a round clock and a scoreboard. Every rule below is in it, and the file that shows each one is named as it comes up. Its scripts are short, so read them alongside this page. crates/app/tests/arena_ready.rs plays it under a rollback client against a serial reference and requires every confirmed tick to agree to the bit.

A second mode, fixtures/ai-authoring/core-capture (capture a core, with patrol drones), was written by an author who had only this page and packages/api/src/index.ts. It runs under the same harness (crates/app/tests/core_capture.rs), and what it had to fix is in its AUTHORING-NOTES.md. Where this page says what a thing does, that mode is the second place to look.

The full rules are in the script API reference (docs/API.md, the networked-session list under "Conventions") and in NETCODE.md.

How a networked game runs your scripts

Every player's machine runs the whole simulation, tick by tick, 60 times a second. Only inputs cross the network. The machines stay in step only if every tick does exactly the same thing on every machine, given the same inputs. Two things follow, and every rule on this page comes from one of them.

  1. Ticks run more than once. A machine doesn't wait for the other players' inputs: it guesses them (each player keeps doing what they last did) and runs ahead. When the real input arrives, it puts the world back to the last tick everyone agreed on (a restore) and runs the ticks since then again (a re-run), now with the real inputs. At 100 ms of round trip that is about 8 ticks behind, so a tick can run nine times or more before it is final. Whatever a tick does must come out the same however many times it runs, and anything it keeps must be put back by the restore. A restore puts back ctx.state and nothing else your script holds.
  2. Everything that steers a tick must be the same on every machine. The same scene, the same inputs and the same code must give the same bits everywhere. So nothing a machine has on its own may steer a tick: its wall clock, its window, what it draws, the order its hash table happens to be in.

A tick that ran on a guess is a predicted tick. Once every player's input for it has arrived, it is run once more and becomes a confirmed tick.

The rules in script terms

The engine's own determinism rules are numbered 30 to 35 (docs/SPEC.md, "Determinism guardrails", and the lane rules). Here is what each one means for a behaviour.

RuleFor the engineFor your script
30Sim maths gives the same bits on every platformMath already does. Write Math.pow, never **. Ask the engine to rank by distance; don't rank in JavaScript
31No hash order, no wall clockSort by id where order matters. Never steer a tick with ctx.debug.wallMs()
32The sim reads only recorded input and the sim cameraAim from ctx.input, ctx.camera.viewYaw and the sim bone pose, never from what is drawn
33Gameplay state that changes lives in ctx.stateNo changing variable at the top of a module
34Side effects are requestsPlay sounds and particles from the tick that decides them. Let the score, the UI and the save follow confirmed ticks
35Read the start of the pass, queue writes to others, settle by a fixed ruleemitTo the entity a change is for, let it apply the change to itself, and settle ties by a fixed rule

A behaviour file

import type { Behavior } from "@argus/api"                       // type only: it costs nothing at run time

interface State { score: number[] }
export default {
  init(ctx) { ctx.state.score = [0, 0, 0, 0] },
} satisfies Behavior<State>                                       // Behavior<State, Props>: `ctx.props` typed too

That import is the one every sample uses (samples/arena-ready/scenes/behaviors/match.ts). The project checks with strict and noUncheckedIndexedAccess, so batch.states[k], list[i] and positions[3 * k] are T | undefined: guard them (const s = batch.states[k]; if (s === undefined) continue) or give a default (?? 0). Type a position Vec3, not number[], so v[0] is a number. The engine loads a script without type-checking it, so run tsc over your behaviours before you trust them.

A scene needs meta.strings (an empty {} will do) or it is refused at load; samples/arena-ready/tools/build-scene.mjs shows the whole meta. Asset paths are relative to the scene file (scenes/models/..., scenes/assets/...).

Rule 33: all play state in ctx.state

A restore puts back each behaviour's ctx.state and nothing else. A variable at the top of a module is not put back, so a re-run starts from whatever the last guess left in it, and the machines part without an error.

// Don't: the predicted ticks count too, and a restore never takes them back.
let shots = 0
export default { update(ctx) { shots += 1 } }

// Do:
export default {
  init(ctx) { ctx.state.shots = 0 },
  update(ctx) { ctx.state.shots += 1 },
}

A networked session warns of this at load (script-module-state, with the variable, the line and the hook). It does the same for:

  • a module-level array, Map or object a hook changes, including one changed through a local alias or through a helper it is handed;
  • a class instance kept at module level whose method writes this;
  • a counter kept in a closure that a module-level call made (const tick = makeCounter()).

What is fine at module level:

  • Constants (const RANGE = 14) and data read once at load.
  • A variable only init and dispose set: a course read once, a handle looked up once. A re-run runs neither again. The exception is a write that reads its own old value (id++, list.push(x)), because init runs again for an entity a predicted tick spawned. Check a stored entity is alive before you use it.
  • A cache built on the first miss from nothing the game holds (table ??= build()). A flag (ready = true) or a value taken from a hook's arguments is state, not a cache.

The warning reads the source text, so it misses state kept on this of the exported object, or written into a module collection through a loop variable. The re-sim-twice check (below) finds those.

In updateAll, ctx is the first instance's. ctx.props, ctx.entity and ctx.state are the first instance's own. Reach every instance through batch.entities[k] and batch.states[k]. A per-instance prop such as a speed goes into each instance's state at init:

init(ctx) { ctx.state.speed = ctx.props.speed },            // each instance's own
updateAll(ctx, dt, batch) {
  for (let k = 0; k < batch.count; k++) {
    const s = batch.states[k]
    step(batch.entities[k], s.speed * dt)                   // not ctx.props.speed: that is drone 1's
  }
},

samples/arena-ready/scenes/behaviors/drone.ts does this.

Rule 35: change other entities by sending them events

Send the change to the entity it is for with ctx.events.emitTo, and let that entity's own handler apply it. Only that entity's behaviours hear it, so a hit costs one handler call. emit, by contrast, costs one call for every behaviour in the scene. A timer can do the same with { to }:

ctx.events.emitTo(drone, "hit", { by: s.player, damage: 1 })     // blaster.ts
ctx.time.after(0.5, "stun-over", {}, { to: target })

Merge what arrives by a rule that doesn't care about order. Add up the things that add (damage, knock-back). Settle anything only one player can win (a kill, a pickup) by a fixed rule on ids or player numbers, never by which handler ran first. A drone in the sample adds up the tick's hits in its own state and settles them on its next step. When two players finish it in the same tick, the lower player number gets the point:

onEvent(ctx, ev) {                                                // drone.ts
  if (ev.name !== "hit") return
  const s = ctx.state, d = ev.data as { by: number; damage: number }
  s.damage += d.damage
  s.by = s.by === 0 ? d.by : Math.min(s.by, d.by)
},

Order by id yourself, not by which behaviour updates first. Behaviours update in the order they were added: the scene's order when it loads, then spawns as they happen. That order is the same on every machine, but a small change to the scene changes it. When a result depends on order, sort by id. Ids compare as plain strings ("crawler#10" comes before "crawler#2"):

const winner = claims.reduce((a, b) => (b.id < a.id ? b : a))   // the lowest id wins

match.ts sorts the ids it sends phase events to, so their order doesn't depend on the scene.

When an event arrives. emit and emitTo share one queue, delivered at the end of the tick that sent them, in send order, and an event sent while delivering is delivered in the same tick (up to 8 rounds). So the receiver's onEvent runs in the same tick, after every update has run. Whatever it changes (a position, its own state) is first seen by the next tick's reads, by that entity's update and by everyone else's. A hit takes effect one tick after the shot that caused it. A confirmed-only module's events are delivered at the end of the confirmed tick that sent them. fixtures/ai-authoring/core-capture/scenes/behaviors/drone.ts knocks a player out this way.

Finish a tick's work by the next tick. A hit you carry in a variable across several ticks comes back wrong after a restore. Carry it in ctx.state, as the drone's damage is.

Rule 30: maths and distances

In a networked game, Math gives the same bits on every machine and operating system. Math.sqrt, Math.abs, Math.floor and Math.min are fine as they are. Math.random() is fine too: each behaviour draws from its own stream, seeded from the game, and a restore puts the stream back.

Write Math.pow(x, y), not x ** y. The ** operator bypasses Math, so a module that uses it is refused when it loads.

const falloff = Math.pow(1 - d / radius, 2)   // not (1 - d / radius) ** 2

Let the engine pick "the nearest". Two candidates whose distances differ in the last bit can tie in the engine's f32 measure and then order differently in your JavaScript doubles. Ask the engine instead:

  • query with sort: "distance" and limit for one point (blaster.ts);
  • nearestMany for many points at once (drone.ts).

Both settle ties the same way on every machine.

const nearest = ctx.world.query({ tag: "drone", near: from, radius: RANGE, sort: "distance", limit: 1 })

Rule 31: no clock, no accidental order

ctx.time.now, performance.now() and Date.now() all read the game's own clock, so they agree on every machine. A cooldown on ctx.time.now or on dt is fine. ctx.debug.wallMs() is real time, for measuring how long your own code takes. A value from it kept in ctx.state, or a branch taken on it, differs between machines.

A plain object's keys and a Map iterate in insertion order, which is the same everywhere only if the insertions happened in the same order. Sort when the order came from events or from the scene.

Rule 32: aim with what the simulation has

What a player aims with must be something every machine has: their recorded input (ctx.input), the sim camera (ctx.camera.viewYaw, the frame the engine turns a stick by), and the sim pose of a bone. Not the drawn picture, not the window's size.

Read bones from the sim pose when the answer decides anything. ctx.animator.bone(name) has two poses:

  • the sim pose: what the clips play, the same on every machine and in every re-run;
  • the drawn pose: after look-at, IK and a face's turns, which are only drawn.

A re-run draws nothing. So in a networked game bone(name) with no pose answers the sim pose, while single-player answers the drawn one, as before. Say which pose you mean, and the script reads the same everywhere:

const chest = ctx.animator?.bone("chest", { pose: "sim" })      // blaster.ts: where a shot starts
const look = ctx.animator?.bone("Head", { pose: "drawn" })       // a particle at the head: drawn only

Rule 34: side effects are requests

ctx.audio.play, ctx.particles.emit and ctx.particles.play don't play anything during your hook. They queue a request, which the engine carries out at the end of the tick. A re-run of a tick throws its requests away, so a shot is heard once however many times its tick runs. So:

  • Play a sound or an effect from the tick that decides it, right where the shot or the hit is worked out (blaster.ts, drone.ts). Don't wait for confirmation: a predicted shot must sound now.
  • Keep the handle ctx.audio.play returns if you need it. The engine assigns handles in tick order, so they are the same on every machine and in ctx.state.
  • Let the UI and the save follow the game; never read them back to decide anything. The screens and the save file are not in the world's snapshot. A tick that pushes a screen on a guess cannot take it back. Write the HUD from a confirmedOnly module (as match.ts does), or set it each tick from ctx.state so a re-run writes the same text. Write the save from a confirmedOnly module.

The HUD and screens

There are two ways to put live text on screen, and they are not the same thing.

  • ctx.hud (set, score, timer, objective) draws the engine's stock hud screen. Its text values are keys into meta.strings (a missing key reads [[key]]), so it can't show a live score line. ctx.hud.timer(seconds) and ctx.hud.score(n) take numbers and are fine for a clock and a score.
  • Your own screen, written with ctx.ui.set("screen:node-id", "text", value). This is what the sample does: samples/arena-ready/ui/screens/hud.tsx is a screen named hud, which replaces the stock one, and match.ts writes its chips each tick. Text that is built at run time needs data={{ literal: true }} on the node. A project with no ui/ folder can register a screen from the script instead: ctx.ui.screen({ format: 1, screen: "corehud", root: ... }), then ctx.ui.push("corehud") (fixtures/ai-authoring/core-capture/scenes/behaviors/match.ts). The stock hud is always the bottom of the stack, so a pushed screen sits on top of it; use clickThrough so it doesn't catch input.

A confirmed-only module's init may register and push screens: init runs on load and again after a restart. The stack is not in a snapshot, so guard a push with ctx.ui.stack().includes(name) as in match.ts. Write the HUD from a confirmed-only module, as match.ts does, so it only ever shows confirmed scores.

Seats and benching

A seat is a player number, 1 to 4, held by a device or by a remote player. The scene's players block sets the limit and how players join (join: "press"), and each seat has a player entity with { type: "spawn", for: "player", player: n }, driven by that player's input. Nothing moves the entity when a seat fills: the game does.

  • Park every figure off the floor in the scene (the sample's BENCH: a platform far from the drones), put only player 1 on the floor, and move a figure onto it when its seat fills.
  • Find out who sits where in a confirmed-only module: ctx.players.list() in init, and player-joined / player-left events (both samples). Compare against what you last saw, and emitTo the figure a seat or unseat; the figure's own script teleports itself to its start or back to the bench (arena-ready's blaster.ts, core-capture's seat.ts).
  • The seat table is in the snapshot (WorldState.players): a restore puts every seat back on the entity it drove, by slot and generation, so a seat never drives whatever later fills a dead entity's slot. The harnesses still fill every seat before the first tick; a seat change in the middle of a networked match is not something either sample's test exercises.
  • A seat with nobody in it gets no input, so its figure stays where it was put. A figure that is seated but must not play (knocked out, waiting to respawn) still gets its player's stick: a controller has no off switch. Teleport it to the bench and again every tick it must stay there (core-capture's seat.ts), or give it input: "script" and drive nothing.
  • A seat survives ctx.session.restart(); the match fills its own seat list again from players.list().

Scoring and match flow: confirmedOnly: true

A goal scored on a guess can be un-scored a moment later, and a shop that sells on a guess can sell twice. Give such a module confirmedOnly: true. It then runs only on confirmed ticks, a few ticks (about 130 ms) late, on every machine at the same tick, and never takes anything back.

export default {
  confirmedOnly: true,                                            // match.ts
  onEvent(ctx, ev) {
    if (ev.name === "drone-down") ctx.state.score[(ev.data as { by: number }).by] += 1
  },
}

Use it for:

  • Scoring: points, kills, laps, objectives.
  • Match flow: the countdown, round start and end, the winner, seats filled and emptied, respawn waves.
  • AI directors: what spawns where and when, difficulty changes, and any decision every machine must make the same way.
  • Economy: purchases, rewards, inventory and currency.
  • What the player reads as final: the scoreboard, the results screen, the save.

How it behaves:

  • On a predicted tick the module runs nothing: no update, no event hook, no ctx.events listener. init and dispose still run.
  • An event sent to it on a predicted tick is not lost. That tick runs again once confirmed, sends the event again, and the module hears it then. So the drones can send drone-down at once and the match counts it when the kill is final.
  • Events it sends go out on the confirmed tick, at the same tick on every machine. match.ts starts and ends the round by sending phase to every player and drone.
  • Its ctx.state is saved and restored with the rest.
  • Its reads see the confirmed tick, not the newest one. The module runs inside the re-run of confirmed tick t: the world is as it was at t (every player's real input in), not the predicted head some ticks ahead. ctx.world.query, positions and players.list() all answer for t, the same on every machine, so a capture zone read from query in match.ts of core-capture is judged on where the players really stood. The things it emits are delivered at the end of t and then re-predicted forward with the rest. A confirmed-only module that must count time counts its own update calls (ctx.state.ticks += 1): it runs once per tick, never twice.
  • The UI is not in a snapshot, so write it so that doing it twice is harmless: ctx.ui.set is already change-gated, but a screen can be on the stack only once, so match.ts pushes results only when ctx.ui.stack() does not hold it. (The re-simulation check cannot judge the tick that pushes a screen: it runs the tick twice and the second run finds the screen and its focus already there, so it reports a UI-state mismatch that is the check's limit, not the script's. Run it over the play, as arena_ready.rs does, and not over the tick the results go up.)

Restarting for another round. ctx.session.restart() rebuilds the scene when the tick ends. Frame and clock (ctx.time.frame, ctx.time.now) go back to 0; every behaviour's ctx.state is gone and its init runs again; pending ctx.time.after timers and the event bus are emptied; controllers go back to their spawns; the screen stack starts again with only the stock hud (a screen your script pushed, the results among them, is gone, and a screen your script registered must be registered again in init). The seat table and the player's key bindings are kept. A match that restarts after its results needs no cleanup code for any of this. Don't restart inside a rollback session yet. A restart asked for in a re-run waits for the next visible tick (World::step_rerun), which is not the same tick on every machine, so a client parts from the reference at the restart. The rollback tests end their window before it (arena_ready.rs, core_capture.rs); a solo test plays through it (a_solo_round_ends_in_results_and_restarts_clean).

Leave movement, aiming, the camera, your own shots and hit effects as they are. They must answer at once, and the rollback corrects them. A module with confirmedOnly does nothing different in single-player.

Many entities: updateAll

A re-run runs every behaviour again, so a behaviour that 100 units carry costs its update × 100 × re-runs, every frame. Give such a module updateAll instead. The host calls it once a tick with every instance, and the batch reads answer for all of them at once:

updateAll(ctx, dt, batch) {                                       // drone.ts
  const at = ctx.world.positions(batch.entities)                  // Float32Array, 3 per entity
  const players = ctx.world.query({ tag: "player" })
  const nearest = ctx.world.nearestMany(at, ctx.world.positions(players), 30) // Int32Array; -1 = none
  for (let k = 0; k < batch.count; k++) {
    const s = batch.states[k]                                     // that instance's ctx.state
    // ...move batch.entities[k] towards players[nearest[k]]
  }
},
  • Keep per-instance data in batch.states[k], never in module variables.
  • nearestMany gives equal distances to the lower index, so every machine picks the same target.
  • raycastMany casts many rays in one call, with excludeEach for each shooter's own capsule.
  • Read every position at the top of the call, then write. Each instance then sees the same start of the tick, whatever order the batch is in (rule 35).
  • The call comes at the first instance's place in attach order. A module that updates before the batch's first instance sees the world as the tick started; one after it sees the batch's writes (moveToward, teleport) already made. Don't depend on it: read what another module wrote from events, or sort by id (rule 35).
  • A batch call takes one radius. nearestMany(from, to, radius) has a single radius. If each instance has its own (batch.states[k].hitRadius), pass the largest, then test the distance to the answer against that instance's own radius in the loop. Don't pass one instance's value for all.

A behaviour on a handful of entities can keep a per-instance update. That's right when it reads something only its own ctx has, such as its animator's bones. The sample's blaster does this for its four players. The exact rules are in docs/API.md (Changes: "One call for every instance").

Movement: let the engine drive

A controller with input: "auto" (the default) takes its seat's stick, jump and turn straight from the recorded input. That movement is the engine's, it is in the snapshot, and it needs no script. The sample's players have no movement script at all. A controller's entity position is at its feet (the bottom of its capsule), so a zone test or a hit test against a player works on the ground plane, and a point that must hit the body is about 0.9 m above the position. When a game needs its own movement (a dash, a coyote-time jump), use input: "script" and drive ctx.controller from ctx.input. Keep the state the movement needs, such as timers and the last jump, in ctx.state.

Don't

Don't use async, await, Promise or queueMicrotask. A module that does is refused when it loads, because a promise's code would run at a different moment on each machine. Wait on the tick clock instead:

export default {
  init(ctx) {
    ctx.time.after(2, "fuse-done", { power: 3 })        // not: await sleep(2); explode()
  },
  onEvent(ctx, ev) {
    if (ev.name === "fuse-done") explode(ctx, (ev.data as { power: number }).power)
  },
} satisfies Behavior

Don't recurse deeply. A networked game stops any callback more than 256 calls deep, with a RangeError ("call depth exceeded"). A flood fill over a 32 x 32 grid can recurse 1,024 deep. Use a list you push to and pop from:

const todo = [start]
while (todo.length > 0) {
  const cell = todo.pop()!
  // ...visit cell, push its unvisited neighbours onto todo
}

Don't change the built-ins. Math, Array.prototype and the ctx handles are frozen for every behaviour, networked or not. Patching one throws a TypeError.

Don't write into another entity's state. Send it an event (rule 35). Nothing stops a script from setting another entity's position, but then the result depends on which behaviour ran first.

Don't keep a handle to an entity across ticks in a module variable. Keep its id in ctx.state and look it up again (ctx.world.find(id)).

Don't rely on a member the browser doesn't have yet if browser players can join the match. In a mixed session (browser and desktop players together) a desktop machine gets only what the browser supports, and a behaviour that calls anything else stops with WebUnsupportedError on every machine. The table under "Conventions" in docs/API.md lists what is missing and when it comes.

Check it before it's networked

  1. Play it alone. A networked-ready game is a single-player game first. Play the whole loop: the start, a round, the end, and playing again.
  2. Read the load warnings. A networked session lists every script-module-state it finds at load, and each one names the variable and the line. Play a few seconds and read script-state-handle too: it names a ctx.state path that holds an entity handle (or another engine object), which a snapshot keeps as plain fields, so after a restore it moves nothing. Keep the id instead (ctx.entity.id) and look it up with ctx.world.find(id).
  3. Run the re-sim-twice check. POST /desync { "resim": true } (the play.desync tool's resim) runs every tick twice from the same snapshot while you play. The first time the second run differs from the first, it names the tick, the system, the entity and the field. It raises resim-mismatch once (GET /events?filter=script_warning) and keeps the report at GET /desync. also catches a value a tick reads before a later tick writes it, such as a last-hit time or a latch. A one-tick re-run cannot see those. outside ctx.state.
    • Add "depth": 8 (the rollback depth at about 100 ms) to re-run from up to 8 ticks back. That
    • Play the busiest moments with it on: a fight, a round ending, a player joining.
    • GET /desync/record counts what it checked.
    • A clean game reports no mismatch. A mismatch in a script field means that behaviour keeps state
  4. Engine developers: the rollback harness. crates/app/tests/arena_ready.rs is the full check, and a copy of it can drive any scene. Four players walk seeded paths behind a modelled network (100 ms and 250 ms of round trip, jitter, 1% loss). A client restores, re-runs and predicts as a networked game will, and every confirmed tick must equal a serial reference's to the bit.

The checklist

  • [ ] Everything that changes during play is in ctx.state; module level holds only constants, load-time data and caches.
  • [ ] No async, await, promises or **; no deep recursion.
  • [ ] A change to another entity is an emitTo its own handler applies; simultaneous claims settle by a fixed rule.
  • [ ] "The nearest" comes from query (sort, limit) or nearestMany, not from a JavaScript sort on distances.
  • [ ] Order that matters is sorted by id.
  • [ ] No ctx.debug.wallMs() in anything a tick keeps or branches on.
  • [ ] Bones that decide something are read with { pose: "sim" }.
  • [ ] Sounds and effects are played from the tick that decides them.
  • [ ] Scores, match flow, directors, purchases, the save and the results are in confirmedOnly modules.
  • [ ] A behaviour on many entities uses updateAll, and takes per-instance props from batch.states[k].
  • [ ] Behaviours pass tsc under strict and noUncheckedIndexedAccess; the scene has meta.strings.
  • [ ] Seats: figures start on a bench, and the match moves one onto the floor when its seat fills.
  • [ ] The re-sim-twice check at depth 8 stays clean through a full round (it can't judge the tick that pushes a screen, so end its window before the results).

If something goes wrong

  • "module '...' uses async (line 12): this session refuses promises, async and await" (or "uses **"): the message names the module and the line. Change that line as above.
  • script-module-state at load: move the named variable into ctx.state, or make it a constant or a cache (rule 33).
  • script-state-handle while playing: the named ctx.state path holds an entity handle (or another engine object). Store its id (handle.id) and look it up with ctx.world.find(id) where you use it. It is said when a snapshot is taken, so it appears within a tick of the handle being stored.
  • resim-mismatch from the re-sim-twice check: the named field took a different value the second time. In a script, look for state outside ctx.state. In a sound or effect handle, look for one kept from a tick that was thrown away. GET /desync/dump?system=&entity= shows the field now.
  • "call depth exceeded": a recursion is too deep. Use a list, as above.
  • WebUnsupportedError: the behaviour calls something browser players can't run yet. Leave it out of a mixed match, or wait for it to come to the browser.