Writing scripts for multiplayer
Multiplayer is coming; it isn't in this build. You can't turn on a networked game yet.
Multiplayer is coming; it isn't in this build. You can't turn on a networked game yet. These are the rules your scripts will run under once it arrives: write them this way now and they will be ready. A script that follows them runs unchanged in single-player today.
In a networked game every player's machine runs the same simulation, tick by tick, and the machines only stay in step if every behaviour does exactly the same thing on each of them. So a networked game runs your scripts under a few extra rules. This page is the short version, with the fix for each one. The full rules are in the script API reference (docs/API.md, the networked-session list under "Conventions") and in NETCODE.md.
Do
Wait with ctx.time.after, not async or promises. A module that uses async, await, Promise or queueMicrotask is refused when it loads, because a promise's code would run at a different moment on each machine. Raise an event later on the tick clock instead:
export default {
init(ctx) {
// not: await sleep(2); explode()
ctx.time.after(2, "fuse-done", { power: 3 })
},
onEvent(ctx, ev) {
if (ev.name === "fuse-done") explode(ctx, (ev.data as { power: number }).power)
},
} satisfies Behavior
Write Math.pow(x, y), not x ** y. In a networked game Math gives the same bits on every machine and operating system, but the ** operator bypasses it, so a module that uses ** is refused when it loads. Math.sqrt, Math.abs, Math.floor, Math.min and the like are fine as they are.
const falloff = Math.pow(1 - d / radius, 2) // not (1 - d / radius) ** 2
Send an event to the one entity it is for with ctx.events.emitTo. Only that entity's behaviours hear it, and a timer can do the same with { to }:
ctx.events.emitTo(target, "hit", { damage: 10 })
ctx.time.after(0.5, "stun-over", {}, { to: target })
Let each entity change itself. When a shot hits something, send the hit to the target and let the target's own handler apply it, rather than writing into the other entity from the shooter. Add up things that add (damage, knock-back) so the order of the hits doesn't matter, and settle anything only one player can win (the kill, the pickup) by a fixed rule on entity ids, such as the lowest id, never by which handler happened to run first. "Projectiles in a game that must agree across machines" in docs/API.md walks through it for shots.
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), not sorted by id. That order is the same on every machine, but a small change to the scene changes it, so a result that depends on it breaks easily. When you need a fixed order, sort by ctx.entity.id yourself: ids compare as plain strings, so "crawler#10" comes before "crawler#2", and plain < gives that order:
const winner = claims.reduce((a, b) => (b.id < a.id ? b : a)) // the lowest id wins
Keep game state in ctx.state, and finish a tick's work by the next tick. A save or a rollback holds ctx.state; anything kept elsewhere, or a hit you carry for several ticks, comes back wrong.
Use the clocks a behaviour already has. ctx.time.now, performance.now() and Date.now() all read the game's own clock, so they agree on every machine. Math.random() is fine too: each behaviour draws from its own stream, seeded from the game, so every machine draws the same numbers.
Step many entities in one call: updateAll. A networked game re-runs recent ticks whenever a late input arrives, often eight or more times a frame, so a behaviour that every player or unit carries costs its update × instances × re-runs. Give the module updateAll instead. The host calls it once a tick with every instance, and the batch queries answer for all of them at once:
export default {
updateAll(ctx, dt, batch) {
const at = ctx.world.positions(batch.entities) // Float32Array, 3 per entity
const enemies = ctx.world.query({ tag: "enemy" })
const near = ctx.world.nearestMany(at, ctx.world.positions(enemies), 10) // Int32Array; -1 = none
for (let k = 0; k < batch.count; k++) {
const st = batch.states[k] // that instance's ctx.state
st.target = near[k] === -1 ? null : enemies[near[k]].id
}
},
}
Keep per-instance data in batch.states[k], never in variables at the top of the module: a re-run puts ctx.state back, but nothing puts a module variable back. nearestMany gives equal distances to the lower index, so every machine picks the same target. (A point never skips itself: when to holds from's own points, each one's nearest is itself.) The exact rules are in docs/API.md (Changes: "One call for every instance").
Mark what must happen once: confirmedOnly: true. A networked game guesses ahead: it runs the next few ticks before every player's inputs have arrived, then runs them again when they do. For most scripts that is invisible. But 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 ticks every machine agrees on, a few ticks (about 130 ms) late, and never takes anything back.
export default {
confirmedOnly: true,
onEvent(ctx, ev) {
if (ev.name === "goal") ctx.state.score[ev.data.team] += 1
},
}
Use it for:
- Scoring: points, kills, laps, objectives.
- Match flow: round start and end, the winner, the timer running out, 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 changes and currency.
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.
Don't
Don't recurse deeply. A networked game stops any callback more than 256 calls deep, with a RangeError ("call depth exceeded"). A flood fill or a tree walk that recurses can pass that easily (a 32 x 32 grid is 1,024 calls deep). Use a list you push to and pop from instead:
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 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.
Don't time gameplay with a real clock. A cooldown on ctx.time.now 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.
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. - "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.