Skip to content
Work in progress. These docs describe Spawnite at launch, and some parts are still being built.

The six ways to network a mechanic

Every mechanic in a multiplayer game answers two questions: how soon the player sees the action, and who decides its result. This page shows the six answers the engine supports, ordered by how soon the player sees the action, with the following table as the summary: The overview has the table that picks one.

Way The player sees the action Who decides Code a game writes
Room only A round trip later The room A command and a system
Feedback at once A cosmetic effect at once The room The same, plus the effect
Predicted At once The room, after the fact A predicted trait, a predicted command, a predicted system
The room judges what the player saw At once The room, rewound to the shooter’s view None for the engine’s weapons
Client owned At once The client A command and a system that takes its word
No room At once The one world None

Example: the player picks a card from a shop offer. Industry term: server-authoritative.

The client sends a command that names what the player wants. A system on the room reads it, checks it against the world, and accepts or refuses it. The client hears the one result, and the room’s next send shows what changed:

src/cards.plugin.ts
import type { World } from "koota";
import {
defineCommand,
definePlugin,
defineTrait,
findCharacter,
integer,
readCommands,
type AuthoritativeStep,
} from "@spawnite/engine/core";
/** The slot of the card a player took from the offer, -1 before any. */
export const PickTrait = defineTrait("pick", { slot: -1 }, { ownerOnly: true });
/** Takes each card a player picked since the last step, once. */
function takePicks(world: World, step: AuthoritativeStep) {
for (const command of readCommands(step, cards.commands.pick)) {
const character = findCharacter(world, command.player);
// The room checks the pick against the world before it keeps it.
if (character?.get(PickTrait)?.slot !== -1) {
command.refuse("taken");
continue;
}
character.set(PickTrait, { slot: command.payload.slot });
command.accept();
}
}
export const cards = definePlugin({
name: "cards",
commands: {
pick: defineCommand({ slot: integer(0, 2) }, { refusals: ["taken"] }),
},
onCharacterSpawn: (_step, character) => {
character.add(PickTrait);
},
systems: { rules: { takePicks: { system: takePicks, answers: ["pick"] } } },
});

The client sends the pick with await sendCommand(world, cards.commands.pick, { slot: 1 }), whose result is accepted, or refused taken for a second pick. The system that answers a command runs on the room alone, so the client never runs takePicks. Send the room a command covers commands in full.

  • What the player feels: the card lands a round trip after the click, 30 to 300 ms.
  • Pros: the least code, and no client can cheat it.
  • Cons: the delay shows on anything that should feel instant.
  • Use it for: anything that changes another player, the world or the score, and anything where a fraction of a second does not matter: buying, voting, opening a door.

Example: the player presses Interact on a chest. The client plays the reach and the creak at once, and the room decides what is inside. Industry term: client-side feedback, cosmetic prediction.

The rule is the same room-only system. The client adds a cosmetic effect for the key press: a clip, a sound or a highlight, started in the same handler that sends the command. The effect is never state: nothing in the world changes until the room’s result arrives, and the command’s result says whether the room accepted it, so an effect that the room refused can be undone where the handler awaits it.

  • What the player feels: the key press answers at once, and the result follows a round trip later.
  • Pros: cheap, and the only thing to undo is a cosmetic effect.
  • Cons: the effect can promise what the room refuses, such as a chest another player emptied first.
  • Use it for: a click on the ground, a menu choice, and an action whose result lands on someone else.

Example: a dash with two charges and a cooldown. Industry term: client-side prediction with server reconciliation.

The client and the room run the same system on the same command, on the tick the player sent it, or on the room’s next tick where the command arrives up to its lateTicks, 17 by default, late. The client shows its result at once. When the room’s result for that tick arrives, the client compares the two, and takes the room’s where they differ. A predicted mechanic declares three things: the state, a trait declared predicted: true, which only a predicted system writes; the action, a command declared predicted: true that both ends run on its tick; and the rule, a predicted system that answers the command, which both ends run and a correction re-runs:

// packages/room/test/outside/dash.ts (part)
/** Her charges, and the seconds toward the next. */
export const DashTrait = defineTrait(
"outsideDash",
{ charges: 2, recharge: 0 },
{ predicted: true },
);
export const dash = definePlugin({
name: "dash",
commands: {
dash: defineCommand(
{},
{
predicted: true,
entity: true,
refusals: [noCharge, Refusal.CoolingDown],
keys: ["KeyF"],
},
),
},
onCharacterSpawn: (_step, character) => {
character.add(DashTrait);
},
systems: {
input: {
dash: {
system: dashCharacters,
predicted: true,
answers: ["dash"],
},
},
},
});

Build a predicted mechanic writes the whole dash, step by step.

  • What the player feels: the dash on the client’s next step, at most 17 ms after the key press, and a correction only where the room disagreed.
  • Pros: instant, and the room still decides: where the room refuses a command the client ran, the next correction removes its effects from the client.
  • Cons: the mechanic follows the rules: its state lives in predicted traits, its system is a predicted system, and its outcomes run on the room alone. The type holds most of them: a predicted trait of yours is written only by a predicted system or the room’s grantPredicted, and an outcome takes the room’s step.
  • Use it for: what changes the player’s own character alone and should answer the key at once: movement abilities, a reload, a stance, a combo meter, a resource.

Example: an instant-hit rifle. The player fires at a target on the screen, and the room rewinds the target to where that screen showed it before it judges the hit. Industry term: lag compensation.

The engine’s weapons do this with no code of the game’s own, as Fair play, built in describes. A game’s own swing or turret gets the same rewind, as Weapon shows.

  • What the player feels: the shot hits what the crosshair was on, and the hit marker shows at once.
  • Pros: fair to the shooter on any connection.
  • Cons: a target can be hit a moment after it reached cover on its own screen.
  • Use it for: instant hits on targets that move. A projectile flies in the room’s world, so it is room only.

Example: a drawing game among friends, where each client says where its player’s brush is. Industry term: client authority.

A command whose room system takes what the client sent, without checking it:

src/brush.plugin.ts
import type { World } from "koota";
import {
defineCommand,
definePlugin,
defineTrait,
findCharacter,
number,
readCommands,
type AuthoritativeStep,
} from "@spawnite/engine/core";
/** Where a player's brush is on the canvas. */
export const BrushTrait = defineTrait("brush", { x: 0, y: 0 });
/** Puts each brush where its player's client says it is. */
function moveBrushes(world: World, step: AuthoritativeStep) {
for (const command of readCommands(step, brush.commands.move)) {
findCharacter(world, command.player)?.set(BrushTrait, command.payload);
command.accept();
}
}
export const brush = definePlugin({
name: "brush",
commands: {
move: defineCommand(
{ x: number(0, 1), y: number(0, 1) },
{ perSecond: 30, burst: 8 },
),
},
onCharacterSpawn: (_step, character) => {
character.add(BrushTrait);
},
systems: {
rules: { moveBrushes: { system: moveBrushes, answers: ["move"] } },
},
});

The client draws its own brush from its own pointer and sends the position; the room streams it to the others. The engine’s weapons offer client-owned hits as one setting, judge: ShotJudge.Page, described under Trust the players.

  • What the player feels: the action at once, and the room never corrects the state it takes from the client.
  • Pros: the least code for something that feels instant.
  • Cons: any client can send whatever it likes, and the room takes it.
  • Use it for: games where nobody gains by cheating. Never use it for anything another player competes for.

Client owned applies to state a game adds, such as the brush. The movement of a player’s character is always predicted and decided by the room.

Example: a single-player game. Industry term: offline.

Nothing to declare. A game played alone runs every system in one world, which is both the room and the client: a predicted command runs once, on the next step, step.authoritative is true, and no correction ever happens. A mechanic written for a room runs there unchanged, so a game can start single-player and add a room later.

One mechanic often uses two ways. A predicted strike takes its cost and starts its cooldown on the command’s tick, on both ends, and deals its damage on the room alone. The command is predicted, and the damage is room only. A strike that costs focus shows the split in one system.