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

Build a predicted mechanic

This page builds a dash: two charges, a short cooldown, and a push forward for a fifth of a second. The player sees the dash on the client’s next step, at most 17 ms after the key press. The room runs the same dash on the same tick and has the final say, so a modified client that claims a third charge gets nothing.

You will write:

  1. The state, a predicted trait.
  2. The action, a predicted command, declared with defineCommand.
  3. The rule, a predicted system that answers the command and steps the state.
  4. The key that sends the command.
  5. The HUD: the charges, and a line for a refused dash.

Then the page changes the character’s movement three more ways, with a sprint, a knockback and a blink, adds a strike whose damage stays on the room, shows how an effect the room starts reaches a character, predicts a kart the player drives, and ends with the rules every predicted mechanic follows and a checklist for reviewing one. The dash, the blink, the strike and the kart are mechanics the engine’s own tests run against a real room, written from the public API alone: dash.ts, blink.ts, strike.ts and racer.ts are the complete files, and the sprint, the block and the aim of Hold a key and the refill of When the room changes the player’s character are files beside them. Each sample names the file it copies on its first line.

You need a game with a room, as Run a room while you build sets up. Write the plugin in a file both the room and the client import, and add it to the game’s plugins list in src/game.ts: importing the file alone installs nothing. The engine plugins a mechanic calls into are in that list too: cooldowns() for startCooldown, as the dash calls, and resources() for a cost, as the strike charges. Plugins covers plugins, systems and traits in general, and Send the room a command covers commands the page does not predict.

The dash’s state is its charges and the seconds toward the next charge. Declare it as a predicted trait:

// 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 },
);

The push itself needs no state of yours: pushCharacter keeps it in a predicted trait of the engine’s own, as step 3 shows.

predicted: true does four things:

  • The client keeps the trait’s value for each tick it runs. When the room’s result for a tick arrives, the client compares the two.
  • A correction restores it. Where the values differ, the client takes the room’s value for that tick and re-runs the ticks since.
  • Only the owner receives it. The room sends the trait to the client of the player whose character holds it. Add seenByAll: true for state other players should see, such as a stance.
  • Only a character’s own step writes it. defineTrait returns a PredictedTrait, which koota’s own functions refuse: a plain entity’s get, set, add, has and remove, world.spawn, world.query, and koota’s createQuery and useTrait all fail to typecheck with it. The character a predicted system is handed is a PredictedEntity, whose methods take it as well as every other trait, and the room’s one-off change goes through grantPredicted. Read it anywhere with readTrait(entity, trait), the engine’s useTrait and the engine’s createQuery, each read-only.

For state that is predicted on an entity a player controls other than her character, such as a kart, or that is plain room state on a monster, such as the engine’s own movement, cooldowns and resources, add anyEntity: true: defineTrait then returns koota’s own trait, which any entity holds and any system writes, and the prediction checks name a write her client cannot replay instead. The checks watch her character alone: on a kart she drives, a write they would name on her character goes unnamed, so keep a vehicle’s writes in its predicted system.

Keep every value the rule carries for a character from one step to the next in a predicted trait. A value kept anywhere else, such as a module variable, is not restored by a correction, so the re-run would start from the wrong value. A rule may read state the room streams, such as the character’s stats, and use a scratch value that lives for one step.

Defining a trait attaches it to nothing. Step 2 adds it to each character as the character spawns.

How closely the room and her page must agree

Section titled “How closely the room and her page must agree”

The room and the client run the same steps, but their arithmetic can differ in the last digits: Rapier’s single-precision world, and Math.sin in a browser and in Node. So the client compares each field within a tolerance before it corrects:

Field holds Compared by Default
A number Its difference 0.0001
A Vector2 or Vector3 The distance between the two 0.0001
A Quaternion The angle between the two 0.0002 radians
A true or false, text, an enum, an entity Exactly none
The entity’s position and rotation Distance and angle 5 cm and 0.0002 radians

A trait sets its own with the object form of predicted: predicted: { tolerance: { speed: 0.05 } } lets the room’s speed and the client’s differ by 0.05 and still count as the same. The type accepts a key only for a field that holds a number, a vector or a quaternion, so a tolerance on a flag or an entity fails to compile. Infinity turns a field’s comparison off and keeps it restored on every correction, for a value the client cannot predict and must not correct on, such as a cosmetic timer the room resets. The engine’s CharacterTrait lets airSeconds and externalVelocity differ by 0.001, which gather rounding over a long fall, and never compares floorNormal.

A tolerance is for rounding, not for decisions. Keep it far below any threshold a system branches on: where a system acts once a charge passes 1, keep the outcome in a flag beside the number, which compares exactly whatever the number’s tolerance.

The pose’s tolerance belongs to the control, since the core keeps the pose: control(step, player, kart, { simulation: Simulation.Predicted, poseTolerance: { position: 0.2 } }) lets a kart at 30 m/s differ by 20 cm. The same call’s smoothing: { snapDistance, maxSeconds, speedSnapSeconds } says how the client draws a correction. It slides the drawn entity across the gap over at most maxSeconds, 0.4 s, and jumps instead past the larger of snapDistance, 2 m, and the distance the entity covers in speedSnapSeconds, 0.4 s, at the speed the room’s word gives it. So a character a kart throws at 20 m/s, whose client learns of the hit about 5 m late, slides across, while one the room moved 3 m as she stood still jumps. A dragon that a move of the room’s alone can shift 3 m while it hovers sets snapDistance: 8, and speedSnapSeconds: 0 keeps the jump at snapDistance whatever the speed. Each part left out takes defaultPoseTolerance and defaultCorrectionSmoothing. For the character characters() spawns, characters({ poseTolerance, smoothing }) sets the same options. The room never compares, so a tolerance changes nothing on the room.

The player’s key press reaches the rule as a predicted command. Declare the command on the plugin, add the state to each character, and name the rule:

// packages/room/test/outside/dash.ts (part)
/** The refusal of a dash with no charge left. */
export const noCharge = "no-charge";
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"],
},
},
},
});
  • commands: the first argument of defineCommand is the payload’s fields, {} here because a dash carries nothing. A command that names a slot would declare defineCommand({ slot: integer(1, 3) }, { ... }), one that names a side { side: oneOf(["left", "right"]) }, and Fields, options and refusals lists every field. The plugin exposes the command as a typed handle, dash.commands.dash, which sendCommand, readCommands and useLatestCommandResult share.
  • predicted: true: her page runs the command on its tick as a guess, and the room runs it on the same tick and decides. A predicted command needs entity: true, since it acts through an entity she controls whose predicted traits its reader writes. A call from code names that entity, { entity }, by type; a key bound to the command finds it itself.
  • refusals: every reason the rule may refuse with: the command’s own, such as noCharge, and the engine’s shared Refusal codes, such as Refusal.CoolingDown. command.refuse takes one of them and nothing else, so a misspelt reason fails the typecheck. Each is a kebab-case code of at most 64 characters.
  • keys: the keys that send the command, by KeyboardEvent.code, so F stays F under AZERTY. The engine sends one command on each first press of one, skips a key a focused field owns and a chord with Ctrl, Alt or ⌘, unless the game binds that Ctrl or Alt itself, as a crouch on Ctrl does, and sends nothing under a modal. Only a command with no fields takes keys, since a key carries no payload: send one with fields from code, as step 4 says. touch: { label, icon } beside keys gives it a button on a phone.
  • onCharacterSpawn: adds the state to each character as the room spawns it, and in a single-player game. Its character is a PredictedEntity, so it may add a predicted trait. A client in a room does not spawn characters: it receives the player’s own character from the room’s stream, with its predicted traits.
  • systems: the rule, which step 3 writes. predicted: true makes it a predicted system: the room and the client both run it, and a correction re-runs it. answers: ["dash"] names the command it settles; one system answers each command.

A predicted command takes the options of any command, with these differences:

  • perSecond and burst: 20 a second and 2 at once by default, any number above 0 and up to 60, such as 0.5 for one every two seconds, and a burst up to 16. A rate guards against a flood; a mechanic’s own pace is its cooldown, which the rule checks.
  • lateTicks: how late the command may reach the room and still run, on the room’s next tick: 17 ticks by default, at most 60. Past it, the room refuses it late.
  • Its lifetime is Control: it ends only when she leaves, when the entity is destroyed, or when her control of the entity changes. A scene that starts again does not end it.
  • It never waits: its reader runs on its tick and has no defer, and it takes no timeoutSeconds.

A step runs its systems in four phases: input, motion, rules, then physics. In each phase, the core’s and the engine plugins’ systems run first, in the engine’s order, and the game’s own plugins run after them in the order of the plugins list. before and after order a system against another in the same phase, named by its handle: characters.systems.movement for the engine’s, siege.systems.spawn for a plugin’s.

The predicted system Phase Order
Pushes or teleports the character, as the dash does input None: pushCharacter and teleport move her on the tick of the call
Changes the character’s speed for a while, as a sprint does input None: the input phase runs before characters.systems.movement, in the physics phase, so the speed counts this tick
Counts a timer or spends a resource rules None

core tables every engine system by phase, in the order the step runs them, with where each runs and what it does. spawnite simulate and spawnite play state print the systems of your own game’s step, one line per phase, the core’s among them, and the devtools’ Wiki tab lists each under its plugin.

An anchor is checked twice. A handle of another phase, a key its phase lacks or a name such as "characters.movement" fails to typecheck. An anchor to a system that does not run, of a plugin the game does not list or one its list omits, throws as the world is made, naming the plugin and the game’s list; wrap the handle in anchorIfPresent where the system orders against that system only when it runs.

A predicted system reads this tick’s commands, settles each one, and steps the state:

// packages/room/test/outside/dash.ts (part)
import type { World } from "koota";
import { Vector3 } from "three";
import {
Refusal,
createQuery,
defineCommand,
defineCooldown,
defineEvent,
definePlugin,
defineTrait,
emitEvent,
TransformTrait,
isCoolingDown,
pushCharacter,
readCommands,
startCooldown,
type PredictedStep,
} from "@spawnite/engine/core";
const dashCooldown = defineCooldown("outsideDash");
const dashers = createQuery(DashTrait);
const forward = new Vector3();
/** A dash the room ran: every page hears it, and the dasher's page reads
* its `cause` as her command's request id. */
export const DashedEvent = defineEvent("outsideDashed");
function dashCharacters(_world: World, step: PredictedStep) {
for (const command of readCommands(step, dash.commands.dash)) {
const { entity: character } = command;
const state = character.get(DashTrait);
const transform = character.get(TransformTrait);
if (!state || !transform || state.charges === 0) {
command.refuse(noCharge);
continue;
}
if (isCoolingDown(character, dashCooldown)) {
command.refuse(Refusal.CoolingDown);
continue;
}
startCooldown(character, { key: dashCooldown, seconds: 0.3 });
character.set(DashTrait, { charges: state.charges - 1 });
// Straight ahead at 12 m/s for 0.2 s; the steer ramps her down
// from it once the push ends.
forward.set(0, 0, -12).applyQuaternion(transform.rotation);
pushCharacter(step, character, { velocity: forward, seconds: 0.2 });
// The room's word, which names the command that caused it.
if (step.authoritative)
emitEvent(step, DashedEvent, {
entity: character,
cause: command.cause,
});
command.accept();
}
step.updateEachPredicted(dashers, ([state]) => {
if (state.charges >= 2) return;
state.recharge += step.deltaSeconds;
if (state.recharge < 3) return;
state.charges += 1;
state.recharge = 0;
});
}

The same function runs in three places:

  • On the client, on its next step. The player sees the charge spent and the push begin. This run’s result is her page’s guess.
  • On the room, on the same tick. The command names its tick, and the room runs it when its own clock reaches that tick, from its own values. One that reaches the room up to its lateTicks late runs on the room’s next tick instead, and its result names that tick as ranTick.
  • On the client again, during a correction. Where the room’s values for a tick differ from the client’s, the client restores the room’s values and re-runs this function for each tick since, for the player’s own character alone: each command on the tick the room ran it, and a command the room refused on none.

Six parts of the function handle the networking:

  • readCommands(step, dash.commands.dash) yields the commands of this tick for the entities this step may touch, one at a time. Each holds entity, the PredictedEntity it acts through, whose predicted traits the system may write, player, the sender, and the typed payload. Only the predicted system that answers a predicted command reads it: a read anywhere else fails to typecheck, or throws as the world is made.
  • command.accept() and command.refuse(reason) settle it. The system settles every command the read yields before it returns: one left open is refused faulted, and in development the step throws, naming the system. A refusal undoes nothing the rule already wrote on that run, so check every condition before the first write, as the dash checks its charge and its cooldown first.
  • Both ends decide from their own values. Where the client ran a dash and the room refused it, her page’s next correction re-runs the tick without the command: every predicted value the guess wrote, the charge, the cooldown and the push, goes back to the room’s, and her HUD shows the room’s refusal. A correction restores predicted state alone, so a sound or a particle a view started for the guess plays on.
  • command.cause names the command. An event the room emits with { cause: command.cause } reaches every page as usual, and her page alone reads event.cause as her command’s request id, null on every other page, so a view tells her own dash from another player’s. Emit it on the room alone, inside if (step.authoritative): her page’s guess and its re-runs must not emit.
  • step.updateEachPredicted(query, callback) hands the callback each character the step may touch that the query matches, with the query’s traits and the character as a PredictedEntity, and writes them back. It is the one pass that writes a predicted trait: the engine’s updateEach and world.query refuse a query that names one, and readEach hands its record read-only. Build the query with the engine’s createQuery, from @spawnite/engine/core, since koota’s refuses a predicted trait. Every form of koota’s query takes a predicted trait through the engine’s own: Not, Or, which hands each trait’s record as koota’s does, and the tracking modifiers of createChanged, createAdded and createRemoved, over as many traits as koota’s take, which match an entity where every trait they name changed and hand a predicted record read-only, since a replay fires them again. It steps:
    • On the room, and in a single-player game, every player’s character.
    • On the client’s own step, the player’s own character alone. Other players’ characters match a query of any trait every client receives, such as VelocityTrait, TransformTrait, cooldowns, resources and any seenByAll trait, and the room’s stream moves them.
    • On a correction’s re-run, every entity step.rerunEntities names, the player’s whole predicted set, so a system that reads one of her entities while it writes another, such as a turret’s aim on its kart, reads it as this tick left it.
    • In one order on every world: an entity standing on another the pass visits comes after it, and otherwise by the key the dump files each under, so two of one player’s entities that touch meet alike on the room and the client.
    • It never hands over an entity no player controls, such as a monster that holds the same anyEntity traits.
    • What it hands over is for this step alone. On the client and in a re-run, moveCharacter, moveAndSlide, teleport and commitPose refuse a PredictedEntity that the step’s pass did not hand out, such as one a system kept in a variable from an earlier step, after her player’s control of it may have passed to another player: a development page throws, naming the system, and a built one skips the move and logs it once.
  • step.deltaSeconds counts time. A predicted system never reads a clock or calls Math.random: both ends must compute the same result from the same values. For a number it predicts, such as a dash’s spread, it calls step.random(character), which draws the same on both ends for one tick and character. Time what a command starts, such as a cooldown, from the step, never from command.tick: a command that reached the room late runs on a later step than its own tick.

Two more lines keep no state of the mechanic’s own. pushCharacter holds the character at 12 m/s along her facing for 0.2 s, with no turn toward the stick, and then the engine’s steer slows her down from there, as Change the character’s movement says. The cooldown needs no trait of its own either: defineCooldown makes a key, and the engine keeps every character’s cooldowns in a predicted trait, as Cooldowns describes.

The command’s keys send it: F sends a dash, with no component of your own. As the world is made, the engine gathers every key the game binds into one table, the engine’s own among them, and throws where two take one key, naming both and the fix:

sprint.sprint takes ShiftLeft, which the engine's cameraLock takes: give sprint.sprint another key, or move cameraLock with controls({ keys: { cameraLock: [...] } }) in the game's plugins, or drop it with controls({ keys: { cameraLock: [] } }).

The engine takes WASD and the arrows to steer, Space to jump, E to interact, Shift for the camera’s lock, Escape for the menu and F2 for the performance overlay. Q and E’s climb belong to the devtools’ free camera alone, so Q is free for a game. controls() in the game’s plugins moves or drops any of the engine’s keys, as the Player page lists them.

A key sends the command through the entity she steers: the one she controls under her live input context of the highest priority, and her view target where several share it. Anything else that starts the action, such as a HUD button or a game’s own touch control, calls sendCommand(world, dash.commands.dash, {}, { entity: character }), naming the entity it acts through. A command with fields is sent this way, with its payload. In a HUD component, world is useWorld() from koota/react, and her character is useCharacter() from @spawnite/engine. sendCommand returns a promise of the room’s result, which never rejects, and the command runs on her page’s next step as a guess. In a single-player game, the same call runs the command on the world’s next step, and nothing is sent.

A key that sends a command with a payload is a page action whose handler sends it. Declare actions: { roll: pageAction({ keys: ["KeyQ"] }) } on the plugin, and in a HUD component call useAction(plugin.actions.roll, { onPress: () => void sendCommand(world, plugin.commands.roll, { side }, { entity: character }) }), both from @spawnite/engine, with the payload the page works out as she presses.

Her page refuses a command at once, with no round trip, for what it can check as the room would:

  • not-controlled: she does not control the entity it acts through.
  • malformed: a field is out of its kind.
  • too-fast: she sent more than its perSecond and burst allow, by the same credit the room charges.
  • too-many: 64 of her commands wait for a result.
  • late: her connection to the room is down, so its tick would pass before the room could see it.

Her page sends at most four predicted commands a tick and holds a fifth, with every command behind it, for the next tick.

A command also carries when she pressed it: command.atSeconds is the room’s clock, in seconds, at that moment, its tick and how far into the tick she was, kept on a command that runs late. A rhythm game judges a hit against it rather than against the tick the command ran on, which can be up to a tick, 17 ms, after she pressed. Her page says the fraction within the tick her command names, so trust it no further than that tick.

readRoomSeconds(world) reads the same clock between steps: the tick her page steps, to its fraction, in seconds, on the room’s timeline, so a sound, a countdown and a command she sends stand on one time. To play a cue at room second at, schedule it on the audio clock at context.currentTime + (at - readRoomSeconds(world)), read in the same frame; context.outputLatency says how much later the speakers play it. In a system, read step.tick instead, which a correction’s re-run gives again. On the room and in a headless test, readRoomSeconds reads the tick the world steps next.

A key the room never needs to hear, such as a HUD’s window or a scoreboard held open on Tab, is a page action. The table checks its keys like any other, so a game adds no keydown listener of its own:

  • Declare it on the plugin: actions: { scores: pageAction({ keys: ["Tab"] }) }, with pageAction from @spawnite/engine/core. touch: { label, icon } gives it a button on a phone too.
  • Act on it in a component of the game’s HUD: useAction(plugin.actions.scores, { onPress, onRelease }), or useActionHeld(plugin.actions.scores) for a value that is true while it is held, both from @spawnite/engine. onPress runs once for each press of the key; with repeat: true it also runs on a held key’s repeats, with repeat true. Outside React, listenToAction takes the same arguments and returns what stops it.
  • Hear the engine’s own with useAction(engineActions.interact, ...), on whatever key controls() gave it.

A mechanic that starts with one action and ends with another, such as a walk and its stop, declares the stop as a command of its own, with the most rate a command may take: perSecond: 60 and burst: 16, mostCommandsPerSecond and mostCommandBurst from @spawnite/engine/core. The two keep separate credit, so however fast she sent starts, her stop is never refused too-fast. The engine’s click-to-walk does this: characters.commands.walk takes five a second past a burst of eight, and characters.commands.stop keeps the most a command may take.

The HUD reads the client’s own prediction: the charges come from the trait on the player’s character, the outcome from the command’s latest result, and the room’s word from the event it emits:

packages/room/test/outside/DashHud.tsx
import { useEffect, useState } from "react";
import {
CommandStatus,
Refusal,
Text,
commandRefusalLines,
useCharacter,
useEvent,
useLatestCommandResult,
useTrait,
type CommandResult,
type RefusalOf,
} from "@spawnite/engine";
import { DashedEvent, DashTrait, dash } from "./dash.ts";
// The dash's HUD, built from the engine's public entry alone: her charges
// from her page's own prediction, a line for a dash that did not run, and
// a mark for each dash of hers the room confirmed.
/** Her dash charges, from her page's own prediction. */
export function DashCharges() {
const character = useCharacter();
const state = useTrait(character, DashTrait);
return state ? <Text>Dash {state.charges}/2</Text> : null;
}
// A line for each reason the dash can be refused, the engine's own among
// them: the typecheck fails if one is missing.
const lines: Record<RefusalOf<typeof dash.commands.dash>, string> = {
...commandRefusalLines,
"no-charge": "No dash charge left",
[Refusal.CoolingDown]: "Dash is not ready",
};
/** A line for two seconds after her latest dash did not run. */
export function DashRefusal() {
const last = useLatestCommandResult(dash.commands.dash);
// The result the line last cleared: each new result is a new object.
const [cleared, setCleared] = useState<CommandResult<
typeof dash.commands.dash
> | null>(null);
useEffect(() => {
if (!last) return;
const timer = setTimeout(() => setCleared(last), 2000);
return () => clearTimeout(timer);
}, [last]);
if (last?.status !== CommandStatus.Refused || cleared === last) return null;
return <Text>{lines[last.reason]}</Text>;
}
/** How many of her dashes the room confirmed: an event every page hears,
* which her page alone reads as hers by its cause. */
export function DashCount() {
const [count, setCount] = useState(0);
useEvent(DashedEvent, ({ cause }) => {
if (cause !== null) setCount((dashes) => dashes + 1);
});
return <Text>Dashes {count}</Text>;
}

The charge count changes on the key press, and a correction fixes it where the room differed. The engine’s useTrait reads a predicted trait read-only; koota’s useTrait refuses one.

useLatestCommandResult(handle) returns her newest result of a command on this page, or null before her first, and re-renders on each change. useCommandResults(handle) returns her last 16, oldest first, for a HUD that shows a history. A predicted command’s result changes up to three times:

  1. Pending, from the moment she sends it until her page runs it.
  2. Her page’s guess, Accepted or Refused with isPredicted: true, from her page’s run on its tick.
  3. The room’s result, with isPredicted: false, which replaces the guess. Where the room disagreed, the result changes status, and the correction has already put the predicted state back to the room’s.

So the refusal line shows at once, from her page’s guess, and clears two seconds later or at the next result. The room’s answer is a new result, so a refusal the room confirms shows for two seconds from the answer. await sendCommand(...) resolves once, with the room’s result alone, never the guess.

RefusalOf<handle> holds the reasons the command declares and the engine’s own it can meet, so the lines record covers every refusal and fails to compile where one is missing. commandRefusalLines gives each engine reason a plain line, which the game overrides where it likes. The engine’s reasons a predicted command can meet:

Reason What happened
late It reached the room more than its lateTicks after its tick, or she sent it while her connection was down.
too-fast She sent more than its perSecond and burst allow. Her page refuses it before it leaves.
too-many 64 of her commands waited for a result.
malformed A field was out of its kind, or it named a tick more than 60 ahead of the room’s.
not-controlled She did not control the entity it acts through, or her control of it changed after she sent it.
entity-gone The entity it acts through, or one its fields name, was gone when its reader reached it; detail names the field.
not-active Its plugin does not run in the level running when it arrived.
player-left She left the room before it ran.
room-closing The room was closing.
faulted The rule threw while it held the command, left it unsettled, or refused it with a reason it does not declare.

sendCommand throws for one call no type lets through: a handle of a plugin missing from the game’s list, naming the plugin to add.

The event’s cause is her command’s request id on her page and null on every other, so DashCount counts her own dashes the room ran and passes over every other player’s. A view that plays a sound for her own confirmed dash renders a <Sound> on the same check, which follows the player’s volume setting.

Run the game with its room, press the key, and read what the client reports:

  • The dash shows at once. The charge count and the push change on the step after the press, with no wait for the room.
  • The room agrees. spawnite play state prints the room’s corrections of the character on its Room: line, the last second’s and those since the page loaded. A dash on open ground corrects nothing. A command with no key goes out the same way from spawnite play press dash.dash, as the director says.
  • No prediction check fires. The development console prints a line that starts Prediction check: for each mistake the client sees. Debug prediction lists them.

Your room answers in a millisecond, so a mistake that only shows over a real connection hides here. Check the dash again at a player’s 150 ms, with the jitter and the stalls a phone brings:

Terminal window
spawnite play start --replace --latency 150 --jitter 20 --stall 500/10
spawnite play resume
spawnite play press KeyF
spawnite play state

--replace stops the session you started above and opens a new one, held as every session opens, and play resume runs it at real time, as a player’s connection runs. Each message to the room and each one back waits half the round trip, each trip varies by about 20 ms, and every 10 s nothing gets through for half a second. Press the key a few times over 20 s, so one dash lands during a stall, then read play state: its Room: line counts the corrections since the page loaded, so one read covers every dash. play state --sample 20 reads the link’s round trip, lead and corrections once a second while you press. Look for the same three things:

  • The dash still shows at once. The client predicts it, so the push starts on the step after the press, never 150 ms later. A dash that waits a round trip is not predicted.
  • The room still agrees. The Room: line names the link it read, such as on a slow link of 150 ms, jitter 20 ms, stall 500 ms every 10 s, and its round trip reads about 150 ms. A dash on open ground still corrects nothing. A correction after each dash, or a character that snaps back as a stall ends, means the client and the room ran the dash differently.
  • Still no prediction check fires.

In a browser you play yourself, open the devtools’ Performance panel and pick a connection, such as Phone, 4G. An agent passes latency, jitter and stall to the MCP’s start_playtest. Play it on a slow connection says what each setting does.

Never write the character’s velocity or transform yourself to change how she moves. The engine’s steer sets her velocity from the input on every tick, starting from where the last tick left it. A speed multiplied after it grows on every tick, a one-time knockback turns into a run along her facing on the next tick, and a written transform is swept back toward her body or drawn as a glide. Three calls change her movement instead. Each is called from a predicted system, predicted, restored by a correction and run again by its replay:

Call What it does Use it for
addSpeedModifier(step, character, { key, more }) Multiplies her top speed by 1 + more, for a while or until removed. A sprint, a crouch, an aim’s slow
pushCharacter(step, character, { velocity, seconds }) Holds her horizontal velocity for a while, and launches her where y is above 0. A dash, a knockback, a launch pad
teleport(step, character, { position }) Places her body and her transform together, drawn with no glide on every page. A blink, a portal

A speed modifier multiplies the top speed the steer runs her at. Define its key once, at module scope, and add it under that key from a predicted system that runs before characters.systems.movement:

import {
addSpeedModifier,
defineSpeedModifier,
removeSpeedModifier,
} from "@spawnite/engine/core";
const sprintSpeed = defineSpeedModifier("sprint");
const aimSlow = defineSpeedModifier("aim");
// In the predicted system:
addSpeedModifier(step, character, { key: sprintSpeed, more: 0.5 }); // ×1.5 until removed
addSpeedModifier(step, character, { key: aimSlow, more: -0.4, seconds: 0.5 }); // ×0.6 for 0.5 s
removeSpeedModifier(step, character, sprintSpeed);
  • Keys multiply, and a key replaces itself. A sprint at more: 0.5 and an aim at more: -0.4 run her at 5 × 1.5 × 0.6 = 4.5 m/s. Adding a key she holds replaces its values, so a sprint may add its modifier on every tick it runs.
  • more: -1 stops her, a root. A more under -1 throws.
  • It lays over the room’s slow. The engine’s slow, slowCharacter, is a modifier on her moveSpeed stat, which the room starts. A sprint over a 30% slow runs her at 5 × 0.7 × 1.5 = 5.25 m/s, and never past the stat’s max where one is set.
  • readMoveSpeed(character) answers the top speed she runs at now, for a HUD.

An effect the player’s own action starts goes through addSpeedModifier. An effect the room starts, such as a monster’s slow, goes on the stat, as When the room changes the player’s character says.

pushCharacter holds her horizontal velocity at velocity for seconds. It sets the velocity at once, so a push from any system before the motion phase moves her on the tick of the call. While it lasts, the steer neither speeds her up nor turns her, so a dash goes straight whatever the stick does, and a wall stops her as it stops a walk. When it ends, her ground speed is cut to endSpeed, and the steer takes her from there: left out, she keeps the push’s speed and slows down at her deceleration, and 0 stops her where the push ends.

  • A launch. A velocity.y above 0 launches her upward at that speed once, on the tick of the call, as a jump does, and gravity takes her from there. A replay launches her again only when it runs the command’s tick again.
  • Two pushes. A push of a lower priority than the one she is under is refused, and the call answers false, so a knockback at priority 1 interrupts a dash at 0, and a dash does not cancel the knockback. A push of the same priority or higher replaces the one she is under.
  • A speed modifier never changes a push. A slowed character dashes at the full 12 m/s.

A knockback the room starts uses the same call with the room’s step, as When the room changes the player’s character shows.

teleport places her feet at a spot exactly, her physics body with them, never short of it. A blink that stops at the first wall casts her capsule along the way first with castShape, as the body she is, and teleports her to where the cast stops. The following blink carries her 6 m the way she faces, with a 2 s cooldown:

packages/room/test/outside/blink.ts
import type { World } from "koota";
import { Vector3 } from "three";
import {
defineCooldown,
defineCommand,
definePlugin,
isCoolingDown,
readCommands,
Refusal,
startCooldown,
capsule,
castShape,
characters,
CharacterCapsuleTrait,
teleport,
TransformTrait,
type PredictedStep,
} from "@spawnite/engine/core";
// A blink of 6 m with a cooldown, built as a creator's plugin is, from the
// engine's public entry alone: its command casts her capsule 6 m the way she
// faces, teleports her to where it stops short of a wall, and she runs on
// out of it at the speed she ran into it.
/** Metres a blink carries her. */
export const blinkMetres = 6;
/** The blink's cooldown, which a room rule starts to refuse a blink. */
export const blinkCooldown = defineCooldown("outsideBlink");
const forward = new Vector3();
const spot = new Vector3();
const middle = new Vector3();
/** Metres the blink's cast rises over the ground: a kerb it carries her
* over. */
const kerbMetres = 0.3;
export const blink = definePlugin({
name: "blink",
commands: {
blink: defineCommand(
{},
{ predicted: true, entity: true, refusals: [Refusal.CoolingDown] },
),
},
systems: {
input: {
blink: {
system: blinkCharacters,
predicted: true,
answers: ["blink"],
},
},
},
});
function blinkCharacters(_world: World, step: PredictedStep) {
for (const command of readCommands(step, blink.commands.blink)) {
const { entity: character } = command;
if (isCoolingDown(character, blinkCooldown)) {
command.refuse(Refusal.CoolingDown);
continue;
}
// Taken, with nothing to blink: no character's body to move.
command.accept();
const transform = character.get(TransformTrait);
if (!transform) continue;
const body = character.get(CharacterCapsuleTrait);
if (!body) continue;
const at = transform.position;
forward.set(0, 0, -1).applyQuaternion(transform.rotation);
// Her capsule, as the body she is, lifted over a kerb so the ground
// she stands on is no wall: the blink stops short of the first
// wall on the way, as a dash would.
middle.copy(at).setY(at.y + body.height / 2 + kerbMetres);
const wall = castShape(step, {
shape: capsule({ radius: body.radius, height: body.height }),
from: middle,
direction: forward,
maxDistance: blinkMetres,
collisionLayer: characters.collisionLayers.players,
ignore: [character],
});
const metres = wall ? Math.max(0, wall.distance - 0.01) : blinkMetres;
spot.copy(at).addScaledVector(forward, metres);
teleport(step, character, { position: spot });
startCooldown(character, { key: blinkCooldown, seconds: 2 });
}
}

The cast rises 0.3 m over the ground, so the floor she stands on is no wall and a kerb under that height is passed, and leaves her own capsule out with ignore. Another walker in the way never stops a teleport: her next move pushes her off it. A spot under the ground stands her on it. Where her capsule at the spot overlaps a wall, the teleport pushes her out by the shortest way, up to her radius; still inside, it answers isClear: false, marks her StuckTrait, and her next move recovers her, so a blink that must not land inside a wall reads isClear and refuses:

if (!teleport(step, character, { position: spot }).isClear) {
command.refuse("blocked");
continue;
}
  • What she keeps. Her velocity, so she runs on out of a blink at the speed she ran into it, and her fall, so blinks in a row never hold her in the air. keepVelocity: false stops both, and rotation turns her.
  • What every page draws. Her own page draws her at the spot on the next frame, with no slide across the 6 m, and the camera stands at its own distance there rather than easing out of the cramped spot she left. Every other page stands her at the spot when the room’s next update arrives, rather than drawing her gliding there, because the teleport counts itself in a trait every page receives.
  • When the room disagrees. Where the room refused a blink her page ran, as for a cooldown her page had not heard of, the correction snaps her back: a correction past 2 m jumps rather than blends.

A command is one action on one tick. A sprint, a block and an aim last for as long as the player holds the key, so they are held inputs: the game declares each on its plugin, and every tick’s input to the room says whether it is held. Unity’s Netcode does the same with IInputComponentData, and Photon Fusion with NetworkButtons. A late release cannot leave the sprint on, because the next tick’s input says released anyway. The room’s repeat of her last input for 15 ticks, and its still input after them, treat a held input as they treat a held movement key.

sprint.ts, which the engine’s tests run against a room over a link with spikes, is a whole sprint:

packages/room/test/outside/sprint.ts
import type { World } from "koota";
import {
addSpeedModifier,
buttonInput,
controls,
createQuery,
definePlugin,
defineSpeedModifier,
defineTrait,
type PredictedStep,
readInput,
removeSpeedModifier,
} from "@spawnite/engine/core";
// A sprint, built as a creator's plugin is, from the engine's public entry
// alone: Shift held runs her half again as fast and drains her stamina;
// at none she is spent until she lets go, and it comes back while she
// walks. The input is held, so a release that comes late or never is
// replaced by the next tick's word.
/** Her stamina, and whether she spent it and has not let go since. */
export const SprintTrait = defineTrait(
"outsideHeldSprint",
{ stamina: 100, spent: false },
{ predicted: true },
);
const sprintSpeed = defineSpeedModifier("outsideHeldSprint");
const sprinters = createQuery(SprintTrait);
export const sprint = definePlugin({
name: "sprint",
inputs: {
sprint: buttonInput({
keys: ["ShiftLeft", "ShiftRight"],
touch: { label: "Sprint" },
}),
},
onCharacterSpawn: (_step, character) => {
character.add(SprintTrait);
},
systems: {
input: {
sprint: {
system: sprintCharacters,
predicted: true,
},
},
},
});
/** The plugins a game lists for it: Shift is the camera lock's until
* `controls()` frees it. */
export const sprintPlugins = [controls({ keys: { cameraLock: [] } }), sprint];
function sprintCharacters(_world: World, step: PredictedStep) {
step.updateEachPredicted(sprinters, ([state], character) => {
const held = readInput(character, sprint.inputs.sprint);
// Letting go re-arms it: a level rule, so no edge is needed.
if (!held) state.spent = false;
const running = held && !state.spent && state.stamina > 0;
if (running)
addSpeedModifier(step, character, { key: sprintSpeed, more: 0.5 });
else removeSpeedModifier(step, character, sprintSpeed);
state.stamina = running
? Math.max(0, state.stamina - 20 * step.deltaSeconds)
: Math.min(100, state.stamina + 10 * step.deltaSeconds);
if (running && state.stamina === 0) state.spent = true;
});
}
  • inputs: each held input, in four kinds: buttonInput() for a key held or not; axisInput(min, max) for a number, such as a lean or a trigger, in 255 steps across its range; vector2Input({ keys: { up, down, left, right } }) for a stick’s two numbers, a move or a ship’s thrust, at most 1 long, two keys held at once making a diagonal 1 long; and angleInput() for a direction that wraps, a camera’s heading or a twin-stick’s aim, in 65,536 steps a turn, which the page sets with setInput and which holds its last value when her input stops. The plugin exposes each as a typed handle, sprint.inputs.sprint, named sprint.sprint. A tick of a game’s inputs packs into at most maximumInputBytes, 96 bytes, across its plugins: a button a bit, an axis a byte, a vector or an angle two, with at most 32 buttons; the world throws past either as it is made, naming each plugin’s share. A value that is no finite number, such as a heading from a zero-length vector, throws from setInput, naming the input, rather than holding 0.
  • A one-shot as a button: buttonInput({ edge: true }). An edge button reads true on the one tick its key went down, however long the key stays down, and setInput(handle, true) presses it once. A press of it that reaches the room up to 17 ticks late still runs, once, on the room’s next tick, so a jump is never lost to a late packet; a repeat of her last input never presses it again. The character’s jump, characters.inputs.jump, is one. A one-shot that carries a payload, or one that needs a result she can show, is a predicted command.
  • Several changes within a tick, in order: readInputChanges(step, character, handle). A button pressed and let go inside one tick, 17 ms, reads held for that tick; readInputChanges lists each change, down or up, with the room’s clock in seconds as it changed, atSeconds, as a press’s own time reads. A rhythm game judges a tap against the beat, and a fighting game’s buffer reads a double tap, from it. Up to maximumInputChanges, 8, ride one tick across the game’s buttons; a ninth waits for the next tick. They reach the room and her own page alone.
  • readInput(entity, handle): the value of the entity’s controller on the tick being stepped, the player who controls it: boolean for a button, number for an axis and for an angle in radians, { x, y } for a vector. A player entity reads her own, and an entity released from control reads each input at rest. A vector’s value is written again by the next read of that input, so copy its numbers to keep them. On a correction’s re-run it reads the replayed tick’s own input. Read it per entity, in the predicted system that steps it.
  • A precise point is not held input. A world position or a pose that must be exact, a tabletop drag, a throw to a point, a stroke, is a command’s payload rather than an input, a vector3() field exact to a 64-bit number: an axis’s steps resolve about 8 mm across a 2 m table and about 40 cm across a 100 m map. A cursor is drawn on her own page, and what it does is a command.
  • No edge needed. The sprint keeps no held field: the input is the held state. A held mechanic with no state of its own, such as a crouch that only slows her, walks a query of ControlledTrait, made once at module scope with createQuery(ControlledTrait), since every entity a player controls holds it: step.updateEachPredicted(characters, (_traits, character) => ...). The spent latch clears on any tick she is not holding, a rule on the level rather than on the edge.
  • controls({ keys: { cameraLock: [] } }): Shift is the camera’s lock until the game frees it, as Roblox games turn shift lock off to sprint on Shift.

What the room does with a held input, in each case:

Case What happens
Her input for a tick is late The room repeats her last input for up to 15 ticks: a held Shift stays held.
Her input stops for more than 15 ticks The room steps her on a still input, every input at rest, so the sprint ends.
A release arrives late It takes effect on the next tick the room steps her without an input, and her next input says released.
She drops Every input goes to rest at once. A rejoin holds it again from the first tick her page names.
She respawns while holding Shift The same character comes back, and the next tick’s input says held: no new command.

Two limits follow from a held input being a level:

  • A tap shorter than a stall can be lost on the room. A key pressed and released inside a 300 ms stall reaches the room as released on both sides. A one-shot that must happen is a predicted command, which runs up to its lateTicks late and gets a result either way.
  • A modified page can toggle a button on every tick. Anything a button starts on its rising edge takes a cooldown or a cost. block.ts opens a parry window as her guard goes up, read against a predicted trait, with a cooldown of half a second.

An input is the room’s and her own page’s alone, as Roblox keeps a player’s input off other clients. One that a pose on other screens needs declares seenByAll: true:

// packages/room/test/outside/block.ts (part)
export const guard = definePlugin({
name: "guard",
inputs: {
block: buttonInput({
keys: ["KeyF"],
touch: { label: "Block" },
seenByAll: true,
}),
},
onCharacterSpawn: (_step, character) => {
character.add(BlockTrait);
},
systems: { input: { block: { system: blockCharacters, predicted: true } } },
});

A view then reads readInput(character, guard.inputs.block) for any character, on every page, as the room last stepped her. Of another player’s character, an input not seen by all throws on a development page, naming the fix, and reads at rest on a built one. Choose the input where the pose is just what she holds, a raised guard or a bow drawn. Choose a predicted trait declared seenByAll where the rules decide the pose, such as a crouch the room keeps her in under a low roof. An axis travels as one of 255 values across its range, its rest, the value in its range nearest 0, and its two ends among them, so the page and the room step on one number: aim.ts draws a bow by an axis from 0 to 1. A game’s own control sets an input from code with setInput(handle, value), and a test with holdInput(game, handle, value). <InputButtons /> in the game’s touch layout draws a button for every input, command and page action that declares touch.

A command that leads to damage runs on both ends, and the damage runs on the room alone. An outcome is anything that changes another player, the world or the score: damage, a spawn, loot, a point. Gate each outcome with step.authoritative, which is true on the room and in a single-player game, and false on a client in a room. For the engine’s outcomes the type holds the gate: dealDamage and the others take the room’s step first, and a predicted system’s step is the room’s only inside if (step.authoritative).

The following strike names a target within 3 m, reserves 15 focus on the command’s tick and winds up for half a second. Then it spends the focus, starts a 4 s cooldown, and deals 30 damage to the target the command named, where nothing stands between them:

packages/room/test/outside/strike.ts
import type { Entity, World } from "koota";
import {
Refusal,
checkCost,
createQuery,
checkTarget,
commitCost,
dealDamage,
defineCooldown,
defineReservation,
defineResource,
defineCommand,
definePlugin,
defineTrait,
entity,
isCoolingDown,
readCommands,
reserveCost,
startCooldown,
type PredictedStep,
type ResourceCost,
} from "@spawnite/engine/core";
// A costed strike with damage of its own, built as a creator's plugin is,
// from the engine's public entry alone: a strike at a target within 3 m
// reserves 15 focus and winds up for half a second, then spends it,
// starts a 4 s cooldown and deals 30 damage to the target it named, on the
// room alone, where nothing stands between them. A strike while one winds
// up is refused, as the engine's abilities refuse one.
/** The resource a strike costs. */
export const focus = defineResource("outsideFocus", { maximum: 100 });
const cost: ResourceCost = { [focus]: 15 };
const hold = defineReservation("outsideStrike");
/** Metres from her to the target she may strike. */
export const strikeRange = 3;
/** Seconds of wind-up left, and the target the strike named. */
export const StrikeTrait = defineTrait(
"outsideStrike",
(): { secondsLeft: number; target: Entity | null } => ({
secondsLeft: 0,
target: null,
}),
{ predicted: true, entities: ["target"] },
);
const strikeCooldown = defineCooldown("outsideStrike");
const strikers = createQuery(StrikeTrait);
export const strike = definePlugin({
name: "strike",
commands: {
strike: defineCommand(
{ target: entity() },
{
predicted: true,
entity: true,
refusals: [
Refusal.Casting,
Refusal.CoolingDown,
Refusal.NoResource,
Refusal.NoTarget,
Refusal.OutOfRange,
],
},
),
},
resources: [focus],
onCharacterSpawn: (_step, character) => {
character.add(StrikeTrait);
},
systems: {
rules: {
strike: {
system: strikeCharacters,
predicted: true,
answers: ["strike"],
},
},
},
});
function strikeCharacters(world: World, step: PredictedStep) {
for (const command of readCommands(step, strike.commands.strike)) {
// `target` is this world's own entity: the room found it by the
// key her page sent, and refused one that named none.
const { entity: character, payload } = command;
const state = character.get(StrikeTrait);
if (!state || state.secondsLeft > 0) {
command.refuse(Refusal.Casting);
continue;
}
if (isCoolingDown(character, strikeCooldown)) {
command.refuse(Refusal.CoolingDown);
continue;
}
const short = checkCost(character, cost);
if (short) {
command.refuse(Refusal.NoResource, short);
continue;
}
const refusal = checkTarget(world, character, payload.target, {
range: strikeRange,
});
if (refusal) {
command.refuse(refusal);
continue;
}
reserveCost(character, hold, cost);
character.set(StrikeTrait, {
secondsLeft: 0.5,
target: payload.target,
});
command.accept();
}
step.updateEachPredicted(strikers, ([state], character) => {
if (state.secondsLeft <= 0) return;
state.secondsLeft -= step.deltaSeconds;
if (state.secondsLeft > 0) return;
const { target } = state;
state.target = null;
commitCost(character, hold);
startCooldown(character, { key: strikeCooldown, seconds: 4 });
// The damage is an outcome: the room's alone, at the target as it
// stands now, which may have died or gone behind a wall.
if (
step.authoritative &&
target &&
!checkTarget(world, character, target, { sight: true })
)
dealDamage(step, target, { amount: 30, source: character });
});
}

The client sends the command with the target as its own entity, such as the one TargetTrait holds: sendCommand(world, strike.commands.strike, { target: wolf }).

What the player sees, and when:

Moment On the client On the room
The command 15 focus reserved, the wind-up starts The same, on the same tick
Half a second later The focus spent, the cooldown starts The same, and the target takes 30 damage
The room’s next send arrives The target’s health bar drops

Five parts of the strike are new:

  • The command names an entity. entity() declares a field that holds one. The client passes its own entity, and the wire carries the key the room files it under, so an entity’s number, which differs from one world to the next, never travels. The read resolves the key to the room’s own entity as it yields the command, so payload.target is always an entity that exists. A command whose key names nothing then, such as a target the room destroyed while the command was on its way, never reaches the system: the read refuses it entity-gone, its detail the field’s name. An entity the client spawned for itself has no key on the room, so her page refuses that command entity-gone itself and never sends it. entity({ optional: true }) reads as Entity | null for a command that may name none, and entities(4) reads as a list of at most four, which the read refuses whole if any one is missing. A list may be empty and may name one entity twice, so a system that acts on each once removes the repeats.
  • The target is checked in one call. A client can name any entity, so the room checks what the command names. checkTarget(world, from, target, check) returns the refusal to pass command.refuse, or undefined: Refusal.NoTarget for a target missing, the character herself, without TargetableTrait, down or out of health, then NotHostile, OutOfRange and NoLineOfSight, each only where check asks for it with hostile: true, a range or sight: true. The engine’s own cast makes the same checks through it. A target with no health, such as a door or a chest, counts as standing, so a mechanic that acts only on characters checks target.has(HealthTrait) too. sight traces from the character’s middle to the target’s against the cover a shot meets, the ground, walls and props: another walker never blocks it, and a world with no physics blocks nothing. The engine’s monsters carry TargetableTrait, and a player’s character or an NPC carries it only where a game adds it: TargetableTrait({ faction: Faction.Friendly }) for an ally a heal may name, added to each character in onCharacterSpawn. hostile checks only for a hostile target, so a heal leaves it out and checks target.get(TargetableTrait)?.faction === Faction.Friendly itself. Check on the command’s tick what the player sees as she sends it, such as range, and check again as the action lands what may have changed, such as sight.
  • An entity in a predicted trait is named in entities. StrikeTrait keeps the target from the command to the release, and entities: ["target"], which names a field holding a list of entities the same way, makes each end compare it by its key and restore the room’s key as its own copy of that entity. A predicted trait whose record holds an entity and leaves entities out fails to typecheck. A key the client holds no entity for, such as one the room never streams, reads as null there and never counts as the room’s, so the client corrects on every update until it changes; in development, the console names the trait, the field and the key.
  • The cost is held under a key. defineResource defines the resource once, at module scope, and returns its key; resources: [focus] on the plugin gives it to every character. A cost names resources by their keys, { [focus]: 15 }, and a plain name such as { focus: 15 } fails to typecheck. reserveCost(character, hold, cost) holds the cost under the strike’s own reservation, and commitCost and refundCost settle that reservation alone, so a strike and an engine cast on one character never spend each other’s cost. Resources covers them.
  • The outcome takes the room’s step. dealDamage(step, target, damage) compiles only inside if (step.authoritative), or in a system on the room alone, whose step is the room’s already. Give your own outcome, such as a score, the same first parameter, authority: Authority, and the compiler holds it to the room too. A spawn and a destroy stay outside the type, since a client spawns its own views: gate them with step.authoritative yourself.

When the room changes the player’s character

Section titled “When the room changes the player’s character”

Some effects on a character start on the room: a monster’s slow, a trap’s root, an ally’s haste. The client cannot predict what it has not heard of, so such an effect always reaches the character one trip late, as one correction. Build it so the client follows the room from then on:

  1. The room writes the effect as state every client receives. A system on the room alone sets a trait with no predicted option, such as a SlowedTrait with the seconds left, and counts it down.
  2. A predicted system reads that state and applies it. It reads the trait on each step and changes what the character does, such as its velocity. On a re-run, it reads the trait’s current value.

The engine’s own slow works this way: the room adds a modifier to the character’s stats, which every client receives, and a predicted system copies the stats into the character’s movement on each step. When the slow lands, the client is corrected once, to where the room had the slowed character.

A knockback and a teleport the room starts, such as a monster’s slam or a trap, are one exception. They call pushCharacter or teleport from a system on the room alone, with the room’s step, inside if (step.authoritative); outside that check, the step is not the room’s, and the call is a type error:

// A world system: a monster's slam throws the character back and up.
if (step.authoritative)
pushCharacter(step, victim, {
velocity: away.multiplyScalar(8).setY(4),
seconds: 0.25,
endSpeed: 0,
priority: 1,
});

The client learns of the knockback as one correction, then predicts the rest of the push from the room’s word.

A one-off change to a predicted trait of yours, such as a pickup that refills her charges, is the other exception. grantPredicted(step, character, trait, value) sets the trait from a system on the room alone, adding it where she lacks it, and takes the room’s step, so her page never calls it. The room’s next send names the traits it granted, so her client takes the correction it brings as the grant it is. The following refill gives her one charge every second:

packages/room/test/outside/refill.ts
import type { World } from "koota";
import {
createQuery,
definePlugin,
defineTrait,
grantPredicted,
ControlledTrait,
readEach,
type AuthoritativeStep,
} from "@spawnite/engine/core";
// A refill the room grants, built as a creator's plugin is, from the
// engine's public entry alone: her charges are predicted on her page, and
// a rule on the room alone gives her one more every second, which her page
// learns of as one correction a trip later.
/** Her charges, which her page predicts and the room refills. */
export const RefillTrait = defineTrait(
"outsideRefill",
{ charges: 0 },
{ predicted: true },
);
const holders = createQuery(RefillTrait, ControlledTrait);
/** The room's steps between two refills: one second. */
const refillTicks = 60;
export const refill = definePlugin({
name: "outside-refill",
onCharacterSpawn: (_step, character) => {
character.add(RefillTrait);
},
systems: {
rules: {
// No `runsOn`, so it runs on the room alone, with the room's step.
grant: (world: World, step: AuthoritativeStep) => {
if (step.tick % refillTicks !== 0) return;
readEach(world, holders, (_, character) => {
grantPredicted(step, character, RefillTrait, (held) => ({
charges: held.charges + 1,
}));
});
},
},
},
});
  • It costs one correction. Her client predicts nothing of a grant, so it learns of it one trip late and replays the ticks since, her own commands among them, on top of it, as for a knockback. A grant that leaves the value as it was costs nothing.
  • It is for a one-off. An effect that lasts, such as a slow, follows the two steps above: a grant writes once, and a predicted system that reads streamed state applies the effect on every tick her client replays.
  • It throws on a misuse the type cannot see, naming the fix: a step kept for a later call, an entity no player controls, or, past the type, a trait declared anyEntity, which koota’s own set writes.

A koota write of a game’s predicted trait from a room system fails to typecheck. An anyEntity trait, which koota’s functions take, is still checked at run time: in development, the room names a system on the room alone that writes one on a character, as predicted-write, and the client reports the correction as room-write. Neither check names a write through pushCharacter, teleport or grantPredicted.

A player can drive something other than her character, such as a kart, a boat or a ship, and her page predicts it as it predicts her walk. Write the vehicle’s movement yourself, with moveAndSlide, which moves any kinematic body and slides it along what it meets. It adds nothing you did not ask for: no gravity, no steps and no ground snap. The following kart is a whole plugin, which the engine’s tests drive against a real room on a 150 ms round trip with jitter:

packages/room/test/outside/racer.ts
import type { Entity, TraitRecord, World } from "koota";
import { Quaternion, Vector3 } from "three";
import {
addLinearVelocity,
axisInput,
box,
buttonInput,
castRay,
CharacterTrait,
control,
createQuery,
definePlugin,
defineTrait,
destroy,
inputContext,
moveAndSlide,
readControlled,
readInput,
RigidBodyType,
Simulation,
spawn,
TransformTrait,
type Authority,
type EntityAuthority,
type JoinStep,
type PredictedEntity,
type PredictedStep,
} from "@spawnite/engine/core";
// A kart racer's kart, built as a creator's plugin is, from the engine's
// public entry alone: each player who joins gets a kart her page
// predicts, steered by her throttle, steering and drift. Four rays find
// the ground under its wheels, the kit owns its own velocity and gravity,
// and `moveAndSlide` moves it and slides it along what it meets. A bump is
// an outcome, so the room alone shoves what the kart hits.
/** The kart's own motion, which the room and her page must agree on:
* within 5 cm/s of speed and 0.002 rad of heading, and the drift exactly.
* Seen by every page, so a rival's page can draw her drifting. */
export const RacerTrait = defineTrait(
"outsideRacer",
{ velocity: () => new Vector3(), yaw: 0, isDrifting: false },
{
predicted: { tolerance: { velocity: 0.05, yaw: 0.002 } },
seenByAll: true,
anyEntity: true,
},
);
/** The kart's box, and where its centre rides above the ground. */
export const kartSize = [1.4, 0.6, 2.2] as const;
export const kartHeight = 0.35;
const drivenKarts = createQuery(RacerTrait, TransformTrait);
// Under each corner, in the kart's own frame.
const wheels = [
new Vector3(-0.6, 0, -0.9),
new Vector3(0.6, 0, -0.9),
new Vector3(-0.6, 0, 0.9),
new Vector3(0.6, 0, 0.9),
];
// Scratch, so a step allocates nothing; every value is written before it
// is read, so none carries from one tick to the next.
const from = new Vector3();
const down = new Vector3(0, -1, 0);
const up = new Vector3(0, 1, 0);
const forward = new Vector3();
const side = new Vector3();
const shove = new Vector3();
const closingVelocity = new Vector3();
const heading = new Quaternion();
const ignored: Entity[] = [];
export const racers = definePlugin({
name: "racers",
inputs: {
throttle: axisInput(-1, 1, {
keys: { negative: ["KeyS"], positive: ["KeyW"] },
}),
steer: axisInput(-1, 1, {
keys: { negative: ["KeyA"], positive: ["KeyD"] },
}),
drift: buttonInput({ keys: ["Space"] }),
},
contexts: {
// Above the character's walking context, so W drives the kart.
driving: inputContext({
priority: 10,
inputs: ["throttle", "steer", "drift"],
}),
},
onPlayerJoin: (step, player) => {
takeWheel(step, player, spawnRacer(step, findFreeStart(step.world)));
},
// Her kart goes with her, so no empty kart blocks the track.
onPlayerLeave: (step, player) => {
for (const kart of readControlled(step.world, player, RacerTrait))
destroy(step, kart);
},
systems: {
physics: { drive: { system: driveKarts, predicted: true } },
},
});
/** The first start of a grid three metres apart along x that no kart
* stands within two metres of, so a joiner never lands on a kart. */
function findFreeStart(world: World) {
const karts = world.query(RacerTrait, TransformTrait);
for (let slot = 0; ; slot++) {
const x = slot * 3;
const isTaken = karts.some((kart) => {
const at = kart.get(TransformTrait)?.position;
return at !== undefined && Math.hypot(at.x - x, at.z) < 2;
});
if (!isTaken) return { x, z: 0 };
}
}
/** Places a kart at rest on the ground at `x`, `z`, facing -z. */
export function spawnRacer(
step: Authority | JoinStep,
{ x, z }: { x: number; z: number },
) {
return spawn(step, {
position: [x, kartHeight, z],
body: { bodyType: RigidBodyType.Kinematic },
collider: { shape: box({ size: [...kartSize] }) },
traits: [RacerTrait],
});
}
/** Hands `kart` to `player`, predicted on her page and driven from her
* driving context, from the next tick: a kart someone else drives
* changes hands in that one tick. */
export function takeWheel(step: EntityAuthority, player: Entity, kart: Entity) {
control(step, player, kart, {
simulation: Simulation.Predicted,
context: racers.contexts.driving,
// Her camera follows the kart.
viewTarget: true,
// Fast and small: a looser pose, eased over a longer gap.
poseTolerance: { position: 0.1 },
smoothing: { snapDistance: 4 },
});
}
function driveKarts(_world: World, step: PredictedStep) {
step.updateEachPredicted(drivenKarts, ([kart, transform], entity) =>
driveKart(step, entity, kart, transform),
);
}
function driveKart(
step: PredictedStep,
entity: PredictedEntity,
kart: TraitRecord<typeof RacerTrait>,
transform: TraitRecord<typeof TransformTrait>,
) {
const { deltaSeconds } = step;
const throttle = readInput(entity, racers.inputs.throttle);
const steer = readInput(entity, racers.inputs.steer);
ignored[0] = entity;
let wheelsDown = 0;
for (const wheel of wheels) {
from.copy(wheel)
.applyQuaternion(transform.rotation)
.add(transform.position);
const ground = castRay(step, {
from,
direction: down,
maxDistance: 0.6,
ignore: ignored,
});
if (ground !== null) wheelsDown++;
}
const grip = wheelsDown / wheels.length;
heading.setFromAxisAngle(up, kart.yaw);
forward.set(0, 0, -1).applyQuaternion(heading);
side.set(1, 0, 0).applyQuaternion(heading);
const speed = kart.velocity.dot(forward);
// The branch's outcome is the discrete field, compared exactly.
kart.isDrifting = readInput(entity, racers.inputs.drift) && speed > 8;
kart.yaw -= steer * (kart.isDrifting ? 2.4 : 1.6) * grip * deltaSeconds;
kart.velocity.addScaledVector(
forward,
(throttle * 18 - speed * 0.6) * grip * deltaSeconds,
);
// Tyres bleed off a sideways slide; a drift keeps more of it.
const slip = kart.velocity.dot(side);
kart.velocity.addScaledVector(
side,
-slip * (kart.isDrifting ? 2 : 8) * grip * deltaSeconds,
);
// The kit owns gravity: it falls while no wheel touches.
kart.velocity.y =
grip > 0
? Math.max(kart.velocity.y, 0)
: kart.velocity.y - 9.8 * deltaSeconds;
heading.setFromAxisAngle(up, kart.yaw);
const result = moveAndSlide(step, entity, {
velocity: kart.velocity,
rotation: heading,
});
// A bump is an outcome: the room alone shoves what it hit, a rival's
// kart or a character, and their pages hear of it as a correction.
// Read before the slide's velocity replaces the kart's own.
let shoved: Entity | null = null;
if (step.authoritative)
for (const hit of result.hits) {
const struck = hit.entity;
if (struck === shoved) continue;
if (!struck?.has(RacerTrait) && !struck?.has(CharacterTrait))
continue;
// Away from the kart at the speed the two closed at, so a
// scrape that closes at nothing shoves nothing: a rival's
// kart takes half of it, level, and a character is thrown
// clear, faster than the kart and up off the ground.
const rival = struck.get(RacerTrait);
const isKart = rival !== undefined;
closingVelocity.copy(kart.velocity);
if (rival) closingVelocity.sub(rival.velocity);
const closing = -closingVelocity.dot(hit.normal);
if (closing <= 0) continue;
shove
.copy(hit.normal)
.multiplyScalar(-closing * (isKart ? 0.5 : 1.2));
shove.y = isKart ? 0 : 4;
addLinearVelocity(step, struck, shove);
shoved = struck;
}
// What the slide left, plus any shove the room applied.
kart.velocity.copy(result.slideVelocity).add(result.addedVelocity);
}

The kart differs from the dash in the following places:

  • Its own entity, so its trait is anyEntity. A trait declared predicted: true alone is held by a player’s character and nothing else. The kart is another entity, so its trait adds anyEntity: true, and spawn gives it a kinematic body for moveAndSlide to move.
  • The control makes it hers. control(step, player, kart, { simulation: Simulation.Predicted }) hands it to her from the next tick, and her page predicts it from then. The control’s context names the plugin’s input context, at a priority above the character’s walking context, so W drives the kart rather than her character, and viewTarget: true points her camera at the kart. Called for a kart another player drives, it changes hands in one tick, and each page corrects at most once.
  • The prediction checks do not watch it. They watch her character alone, so a system that writes the kart’s trait outside its predicted system is not named; it shows only as corrections.
  • Its state is its velocity and heading. The kit keeps the velocity it carries from one tick to the next in its predicted trait, adds no gravity while a wheel touches, and carries forward what the slide left, result.slideVelocity, so a wall it slides along takes its speed into the wall and keeps the rest.
  • A looser pose, eased over more. At 20 m/s, 10 cm off is invisible, so poseTolerance: { position: 0.1 } avoids corrections a player would never see. smoothing: { snapDistance: 4 } eases a correction of up to 4 m even at a crawl, rather than jumping past the default 2 m; at speed, 0.4 s of it eases farther.
  • A bump is an outcome. Inside if (step.authoritative), the kit reads result.hits and shoves what the kart struck with addLinearVelocity, on the room alone. The struck kart reads the shove from result.addedVelocity on its next move, and its player’s page learns of it a round trip late, as a correction. A shove her page did not know of eases over 0.1 s, so a hit reads as a hit.

A game built around a vehicle also decides the following, which the kit leaves to it:

  • Her character. A game where she only drives lists characters({ autoSpawn: false }), so no character spawns beside her kart. A game where she walks to a kart and climbs in keeps her character, and the driving context takes exclusive: true, which leaves every input of the walking context at rest while she drives.
  • A one-shot action of the vehicle, such as a boost, is a buttonInput declared edge: true and read with readInput(kart, ...), which reads true on the one tick its key went down. A one-shot that needs a result is a predicted command declared entity: true, which her key sends through the entity she steers: her kart while she drives it.
  • Its timers. Keep a boost’s seconds and its cooldown in the vehicle’s own predicted trait, as the dash keeps its charges, with a flag for each branch.
  • The HUD. useControlled(RacerTrait) returns the karts her player controls, for a speedometer.
  • Leaving. The kit’s onPlayerLeave destroys the kart she controls, so no empty kart stays on the track.

What a player feels, measured by the kit’s tests at a 150 ms round trip with 20 ms of jitter:

  • Driving alone, she sees no correction, lap after lap, at about 10 m/s round a tight circle.
  • A rival who bumps her parked kart at 12 m/s costs her page one correction, and the rival’s page none. Her drawn kart closes the 1.4 m gap over 0.1 s, about 0.23 m a frame at 60 frames a second.
  • A scrape costs a correction on some acknowledgements while the karts touch, about 11 to 15 on the driver’s page and 10 or 11 on the rival’s in two seconds of contact, and none once they part.
  • A character the kart strikes is corrected once or twice on her page. The kit throws her clear at about 21 m/s, a gap of 5 to 6 m on her page, past her character’s 2 m snapDistance but inside 0.4 s of her speed, so her drawn character slides across in 0.1 s, about a sixth of the gap a frame.
  • A kart the room launches at 25 m/s, as a blast her page never runs, is corrected once, by about 5.8 m, and slides across in 0.1 s the same way.

A flyer differs from the kart in three settings. flyer.ts is a dragon at 30 m/s:

  • No gravity. moveAndSlide adds none, so the dragon needs no override.
  • A floor snap to stay landed. floorSnapDistance: 0.05 keeps a dragon at rest reading isGrounded on every tick, so it does not flip between landed and flying.
  • Wider smoothing. At 30 m/s a correction slides up to 12 m on its speed alone; its control also sets smoothing: { snapDistance: 8 }, so a move the room alone makes while it hovers slides too.

A predicted mechanic follows five rules. The type holds rule 2, rule 5, the predicted draw of rule 4, and the queries of rule 3: code that breaks one of them with a trait of yours declared predicted: true fails to typecheck. In development, the client and the room check the rest, and every rule for an anyEntity trait, and name the system that breaks one, as Debug prediction describes.

  1. State lives in predicted traits. Keep every value a predicted system carries for a character from one step to the next in a trait declared predicted: true. No module variable, no closure, no trait made with koota’s trait().
  2. Only a predicted system writes a character’s predicted trait. A system without predicted: true never re-runs, so a correction would lose its write. The type holds it: koota’s functions refuse a PredictedTrait, and only the PredictedEntity a predicted system is handed writes one. The exceptions are a knockback or a teleport the room starts through pushCharacter or teleport, and a one-off change through grantPredicted.
  3. A predicted system steps the entities step.updateEachPredicted hands it: on the room every entity a player may control, on her page each entity her player controls in Simulation.Predicted, all of them together on a re-run, bases before riders and otherwise in one order on every world, and never another player’s. The type refuses a predicted query to updateEach and world.query; the development checks name a predicted system that steps an anyEntity trait otherwise.
  4. A predicted system computes from its inputs alone. Count time with step.deltaSeconds and step.tick, and draw a predicted number with step.random(character), outside if (step.authoritative): inside that check the type refuses it, since a draw on the room alone would shift her page’s. Do not call Math.random, random(world) or Date.now in one, except where step.authoritative is true, for a roll the player must not know ahead, such as loot.
  5. Outcomes run on the room alone. The type holds this for the engine’s outcomes and for any of yours that takes an Authority: they take the room’s step, which a predicted system has only inside if (step.authoritative). Gate a spawn and a destroy with step.authoritative yourself.

Five more things to know:

  • The rate is a flood guard, not a game rule. A player may send 20 of one predicted command a second, past a burst of two, and her page sends at most four predicted commands on one tick. Limit the mechanic with a cooldown or a cost, as the dash does. Set the rate to at least twice the fastest rate a player can honestly send: for a mechanic sent 10 times a second, defineCommand(fields, { predicted: true, entity: true, perSecond: 40 }). A stop that ends what a start began is a command of its own, at the most rate a command takes, so it is never refused too-fast.
  • One system answers each command. It names the command in its answers, and the world refuses to start where two systems answer one command or none does. A system that wants to know what happened reads an event the answering system emits.
  • Time what a command starts by the step, never by command.tick, the tick she sent it for. A command that reaches the room late runs up to its lateTicks after it, so a cooldown counted from command.tick would start early.
  • Hold a held action with an input, not two commands. A sprint built from a command down and a command up stays on where the room refuses the release late. A buttonInput repeats its level every tick, as Hold a key shows.
  • An anyEntity trait is predicted only where a player predicts its entity. Her page predicts each entity her player controls in Simulation.Predicted: her character, and a kart she drives. On an entity no player predicts, such as a monster, an anyEntity trait is plain room state: the engine’s movement, cooldowns and resources are anyEntity traits a monster holds too, and there the room alone steps them. A trait of yours declared predicted: true alone is held by a player’s character and nothing else.

Check a predicted mechanic for the following:

  • Every value its predicted system keeps for a character is in a trait declared predicted: true.
  • The system’s entry sets predicted: true. Its traits are declared without anyEntity unless a monster holds them too, or they belong to an entity other than her character, such as a kart, so the type keeps every other system off them.
  • A one-off change the room makes goes through grantPredicted, and a lasting effect through streamed state a predicted system reads.
  • It changes the character’s movement with addSpeedModifier, pushCharacter or teleport, and never writes her velocity or transform itself.
  • Its one system answers each of its commands, settles every command it reads, and checks every condition before its first write.
  • Damage, a heal, loot and the score take the room’s step, and spawns and destroys sit behind step.authoritative.
  • It draws a predicted number with step.random(character), calls no Math.random, random(world) or Date.now outside that gate, and times by step.tick.
  • The plugin is in the game’s plugins list, and its keys are on its declarations, keys on a command, an input or a page action, never in a keydown listener.
  • A held action is a buttonInput read with readInput, and anything its rising edge starts has a cooldown or a cost.
  • How prediction works: what a tick, the lead and a correction are, with the dash traced tick by tick.
  • Debug prediction: what a mechanic that fights the room looks like, and each check’s fix.
  • Predicted systems: the reference for predicted: true, phases and system order.