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:
- The state, a predicted trait.
- The action, a predicted command, declared with
defineCommand. - The rule, a predicted system that answers the command and steps the state.
- The key that sends the command.
- 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.
Before you start
Section titled “Before you start”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.
Step 1: Declare the state
Section titled “Step 1: Declare the state”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: truefor state other players should see, such as a stance. - Only a character’s own step writes it.
defineTraitreturns aPredictedTrait, which koota’s own functions refuse: a plain entity’sget,set,add,hasandremove,world.spawn,world.query, and koota’screateQueryanduseTraitall fail to typecheck with it. The character a predicted system is handed is aPredictedEntity, whose methods take it as well as every other trait, and the room’s one-off change goes throughgrantPredicted. Read it anywhere withreadTrait(entity, trait), the engine’suseTraitand the engine’screateQuery, 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.
Step 2: Declare the action
Section titled “Step 2: Declare the action”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 ofdefineCommandis the payload’s fields,{}here because a dash carries nothing. A command that names a slot would declaredefineCommand({ 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, whichsendCommand,readCommandsanduseLatestCommandResultshare.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 needsentity: 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 asnoCharge, and the engine’s sharedRefusalcodes, such asRefusal.CoolingDown.command.refusetakes 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, byKeyboardEvent.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 }besidekeysgives it a button on a phone.onCharacterSpawn: adds the state to each character as the room spawns it, and in a single-player game. Itscharacteris aPredictedEntity, 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: truemakes 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:
perSecondandburst: 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 itlate.- 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 notimeoutSeconds.
Choose the phase
Section titled “Choose the phase”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.
Step 3: Write the rule
Section titled “Step 3: Write the rule”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
lateTickslate runs on the room’s next tick instead, and its result names that tick asranTick. - 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 holdsentity, thePredictedEntityit acts through, whose predicted traits the system may write,player, the sender, and the typedpayload. 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()andcommand.refuse(reason)settle it. The system settles every command the read yields before it returns: one left open is refusedfaulted, 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.causenames the command. An event the room emits with{ cause: command.cause }reaches every page as usual, and her page alone readsevent.causeas her command’s request id,nullon every other page, so a view tells her own dash from another player’s. Emit it on the room alone, insideif (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 aPredictedEntity, and writes them back. It is the one pass that writes a predicted trait: the engine’supdateEachandworld.queryrefuse a query that names one, andreadEachhands its record read-only. Build the query with the engine’screateQuery, 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 ofcreateChanged,createAddedandcreateRemoved, 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 anyseenByAlltrait, and the room’s stream moves them. - On a correction’s re-run, every entity
step.rerunEntitiesnames, 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
anyEntitytraits. - What it hands over is for this step alone. On the client and in a re-run,
moveCharacter,moveAndSlide,teleportandcommitPoserefuse aPredictedEntitythat 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.deltaSecondscounts time. A predicted system never reads a clock or callsMath.random: both ends must compute the same result from the same values. For a number it predicts, such as a dash’s spread, it callsstep.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 fromcommand.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.
Step 4: Send the command
Section titled “Step 4: Send the command”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 itsperSecondandburstallow, 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"] }) }, withpageActionfrom@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 }), oruseActionHeld(plugin.actions.scores)for a value that is true while it is held, both from@spawnite/engine.onPressruns once for each press of the key; withrepeat: trueit also runs on a held key’s repeats, withrepeattrue. Outside React,listenToActiontakes the same arguments and returns what stops it. - Hear the engine’s own with
useAction(engineActions.interact, ...), on whatever keycontrols()gave it.
A start and a stop
Section titled “A start and a stop”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.
Step 5: Show it on the HUD
Section titled “Step 5: Show it on the HUD”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:
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:
Pending, from the moment she sends it until her page runs it.- Her page’s guess,
AcceptedorRefusedwithisPredicted: true, from her page’s run on its tick. - 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.
Step 6: Check it against a room
Section titled “Step 6: Check it against a room”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 stateprints the room’s corrections of the character on itsRoom: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 fromspawnite 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:
spawnite play start --replace --latency 150 --jitter 20 --stall 500/10spawnite play resumespawnite play press KeyFspawnite 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 ason 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.
Change the character’s movement
Section titled “Change the character’s movement”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 |
Speed for a while
Section titled “Speed for a while”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 removedaddSpeedModifier(step, character, { key: aimSlow, more: -0.4, seconds: 0.5 }); // ×0.6 for 0.5 sremoveSpeedModifier(step, character, sprintSpeed);- Keys multiply, and a key replaces itself. A sprint at
more: 0.5and an aim atmore: -0.4run 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: -1stops her, a root. Amoreunder -1 throws.- It lays over the room’s slow. The engine’s slow,
slowCharacter, is a modifier on hermoveSpeedstat, 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’smaxwhere 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.
A push: a dash, a knockback, a launch
Section titled “A push: a dash, a knockback, a launch”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.yabove 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
prioritythan 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.
A blink
Section titled “A blink”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:
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: falsestops both, androtationturns 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.
Hold a key: a sprint, a block, an aim
Section titled “Hold a key: a sprint, a block, an aim”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:
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; andangleInput()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 withsetInputand which holds its last value when her input stops. The plugin exposes each as a typed handle,sprint.inputs.sprint, namedsprint.sprint. A tick of a game’s inputs packs into at mostmaximumInputBytes, 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 fromsetInput, 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, andsetInput(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;readInputChangeslists 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 tomaximumInputChanges, 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:booleanfor a button,numberfor 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
heldfield: 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 ofControlledTrait, made once at module scope withcreateQuery(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
lateTickslate 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.tsopens 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.
Keep outcomes on the room
Section titled “Keep outcomes on the room”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:
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, sopayload.targetis 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 itentity-gone, itsdetailthe field’s name. An entity the client spawned for itself has no key on the room, so her page refuses that commandentity-goneitself and never sends it.entity({ optional: true })reads asEntity | nullfor a command that may name none, andentities(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 passcommand.refuse, or undefined:Refusal.NoTargetfor a target missing, the character herself, withoutTargetableTrait, down or out of health, thenNotHostile,OutOfRangeandNoLineOfSight, each only wherecheckasks for it withhostile: true, arangeorsight: 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 checkstarget.has(HealthTrait)too.sighttraces 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 carryTargetableTrait, 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 inonCharacterSpawn.hostilechecks only for a hostile target, so a heal leaves it out and checkstarget.get(TargetableTrait)?.faction === Faction.Friendlyitself. 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.StrikeTraitkeeps the target from the command to the release, andentities: ["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 leavesentitiesout 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.
defineResourcedefines 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, andcommitCostandrefundCostsettle 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 insideif (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 withstep.authoritativeyourself.
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:
- The room writes the effect as state every client receives. A system on the room alone sets a trait with no
predictedoption, such as aSlowedTraitwith the seconds left, and counts it down. - 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:
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 ownsetwrites.
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.
Predict a vehicle: a kart
Section titled “Predict a vehicle: a kart”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:
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 declaredpredicted: truealone is held by a player’s character and nothing else. The kart is another entity, so its trait addsanyEntity: true, andspawngives it a kinematic body formoveAndSlideto 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’scontextnames the plugin’s input context, at a priority above the character’s walking context, so W drives the kart rather than her character, andviewTarget: truepoints 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 readsresult.hitsand shoves what the kart struck withaddLinearVelocity, on the room alone. The struck kart reads the shove fromresult.addedVelocityon 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 takesexclusive: 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
buttonInputdeclarededge: trueand read withreadInput(kart, ...), which reads true on the one tick its key went down. A one-shot that needs a result is a predicted command declaredentity: 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
onPlayerLeavedestroys 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
snapDistancebut 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.
moveAndSlideadds none, so the dragon needs no override. - A floor snap to stay landed.
floorSnapDistance: 0.05keeps a dragon at rest readingisGroundedon 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.
The rules
Section titled “The rules”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.
- 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’strait(). - Only a predicted system writes a character’s predicted trait. A system without
predicted: truenever re-runs, so a correction would lose its write. The type holds it: koota’s functions refuse aPredictedTrait, and only thePredictedEntitya predicted system is handed writes one. The exceptions are a knockback or a teleport the room starts throughpushCharacterorteleport, and a one-off change throughgrantPredicted. - A predicted system steps the entities
step.updateEachPredictedhands it: on the room every entity a player may control, on her page each entity her player controls inSimulation.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 toupdateEachandworld.query; the development checks name a predicted system that steps ananyEntitytrait otherwise. - A predicted system computes from its inputs alone. Count time with
step.deltaSecondsandstep.tick, and draw a predicted number withstep.random(character), outsideif (step.authoritative): inside that check the type refuses it, since a draw on the room alone would shift her page’s. Do not callMath.random,random(world)orDate.nowin one, except wherestep.authoritativeis true, for a roll the player must not know ahead, such as loot. - 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 insideif (step.authoritative). Gate a spawn and a destroy withstep.authoritativeyourself.
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 refusedtoo-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 itslateTicksafter it, so a cooldown counted fromcommand.tickwould 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. AbuttonInputrepeats its level every tick, as Hold a key shows. - An
anyEntitytrait is predicted only where a player predicts its entity. Her page predicts each entity her player controls inSimulation.Predicted: her character, and a kart she drives. On an entity no player predicts, such as a monster, ananyEntitytrait is plain room state: the engine’s movement, cooldowns and resources areanyEntitytraits a monster holds too, and there the room alone steps them. A trait of yours declaredpredicted: truealone is held by a player’s character and nothing else.
Review checklist
Section titled “Review checklist”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 withoutanyEntityunless 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,pushCharacterorteleport, 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 noMath.random,random(world)orDate.nowoutside that gate, and times bystep.tick. - The plugin is in the game’s
pluginslist, and its keys are on its declarations,keyson a command, an input or a page action, never in akeydownlistener. - A held action is a
buttonInputread withreadInput, and anything its rising edge starts has a cooldown or a cost.
What to read next
Section titled “What to read next”- 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.