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

Systems

Each fixed step runs the world’s systems in order. A system is a function of the world and the step, (world, step) => void. The step holds its length in deltaSeconds, its number in tick, the player’s input, and authoritative, which says whether this world decides outcomes. A game adds one in a plugin, under the phase it runs in.

A system runs inside the step, so it creates no three.js object and no array on each call. Koota’s world.query builds an array and a record per entity on each call, so a system walks its query with the engine’s walk instead, as the engine’s own systems and the behaviour scaffold’s FeatureSystem.ts on Templates do. updateEach and readEach take a query made once with createQuery: Koota’s, or the engine’s from @spawnite/engine/core for a query that names a predicted trait, since Koota’s refuses one. updateEach refuses a query of a predicted trait, and readEach hands that trait’s record read-only. Both hand each entity’s fields in one record they reuse, so a callback keeps a field, never the record. findEntity finds the first entity a query matches, and readField reads one field of one trait; neither builds anything. A game’s lint reports world.query in a system, engine/system-query: in a function a definePlugin call in the same file names under systems, a variable typed System, or a function that takes a step or the step’s options.

The step’s profile names each system as its plugin names it, plugin.system, such as core.physics or regeneration.heal. The devtools’ wiki shows the line the entry’s description gives beside that name. A headless game times its systems once its world has the StepProfileTrait trait, and readStepProfile(game.world) reads the times. spawnite simulate steps the scene on the plugins list the scene’s file exports, else on the one src/game.ts exports, else on the building blocks alone; --profile times that step.

What a throw does depends on where the system runs:

  • On a room the platform hosts, the room catches the throw at the system. The rest of that system’s tick is skipped, while what it wrote before the throw stays, and the step runs the systems after it. The system runs again next tick. The room logs a systemFaulted line naming the system, the tick, the message and the stack. It logs each message once per heartbeat, and at most four per system. The heartbeat counts every throw by system, so a system that fails every tick shows a count equal to the room’s steps.
  • In a development room, the throw closes the room, so you see it at once. The room stores every player’s save first, logs a failed line with the stack, and closes each page with roomFailedCloseCode. Its reason, read with readRoomFailedReason, says whether her save was stored. Her page joins again as a new player when the room starts again, and that join loads her save.
  • In a headless test or spawnite simulate, the step throws, as any function does.

A system that throws midway may leave what it wrote half done for that tick. A throw from the room’s own work around the systems, such as its send or a save, closes even a hosted room, and every player’s save is stored first. A loop that never ends cannot be caught: it stops the whole room.

In a game with a room, the room is the server and each player’s page is a client, the two ends that RunContext.Server and RunContext.Client name. This page calls them the room and the page. A system runs on the room alone unless its entry declares otherwise. The room judges the rule and streams the result, and each page draws it. Set runsOn on the entry to run the system elsewhere:

import { definePlugin, RunContext } from "@spawnite/engine/core";
export const moves = definePlugin({
name: "moves",
systems: {
rules: {
// Nothing declared: the room alone.
scoreKills,
// Works out a `local` trait on each end, which nothing rolls back.
footing: { system: readFooting, runsOn: RunContext.Both },
// Only draws, so the room skips it.
trailSparks: { system: trailSparks, runsOn: RunContext.Client },
},
},
});

The following table shows where each declaration runs:

runsOn The room A page of a room A game with no room
Left out, or RunContext.Server Runs Skipped Runs
RunContext.Both Runs Runs Runs
RunContext.Client Skipped Runs Runs

Declare RunContext.Both for a system the room and a page each run for themselves and that writes nothing her page predicts, such as one that works out a local trait. A system that writes what her page predicts, her character’s velocity or any other predicted trait, is a predicted system instead: predicted: true runs it on both ends and again in a correction, as Predicted systems describes. A RunContext.Both system cannot write a predicted trait of yours, since Koota’s functions refuse one, and one that writes an anyEntity predicted trait of her character is reported by the prediction checks. A single-player game has one world that is both the room and the page, so it runs every system and needs no declaration.

A page’s world lacks the traits only the room holds, so a room’s rule that ran on a page would write from what is missing. The default keeps that rule off the page. Unreal runs gameplay code on the server unless a function is marked for a client, and Roblox runs a Script on the server and a LocalScript on a client. Unity’s Netcode and Godot leave the check to the code, with IsServer and is_multiplayer_authority().

The devtools’ wiki says where each system runs under its name.

An outcome, such as damage, a heal, loot or a score, runs only on the world that decides outcomes: the room, or a game with no room. The type holds this rule. Every outcome function takes an Authority first, dealDamage(step, target, { amount: 30 }) and dealHealing(step, target, { amount: 20 }), and only the room’s step is one:

  • A system whose entry leaves runsOn out receives the room’s step, an AuthoritativeStep, so it calls an outcome with no check.
  • A system that runs on both, or a predicted system, receives a step that may be her page’s. step.authoritative is true on the room and in a game with no room, and false on a page in a room, and if (step.authoritative) narrows the step to the room’s. An outcome outside that check fails to typecheck, so a page that predicts a hit never deals it.
  • Code outside a step, such as a message handler or a test, calls requireAuthority(world), which throws on a page in a room. Ask isAuthoritative(world) first where the code also runs on a page. isRoomWorld(world) says whether the world is a room’s own, the one its pages join, which a game played alone is not: a component that spawns what a room holds for its players, such as <Round>’s round, which waits there for the first page, asks it.

Give a game’s own outcome, such as a score, the same first parameter, authority: Authority, so its callers pass the room’s step too, and read its world from it with readAuthorityWorld(authority, "awardScore"), which throws, naming your function, for an authority no step and no requireAuthority made. Should a cast or a plugin in JavaScript get past the type, the engine’s outcome functions throw on a page in a room, naming the function and the fix. Every step also carries step.tick, the step’s number on the world’s clock. Unreal’s HasAuthority() and Roblox’s RunService:IsServer() answer the same question at run time; here the compiler asks it. A view holds no step, so a game’s lint reports an outcome the engine exports, such as addCoins or dealDamage, called from a component or a hook, engine/view-outcome: the page sends the room a command with sendCommand, and a system decides.

A page of a room never holds a trait defined with serverOnly. A system that runs there and reads one finds nothing, acts on that, and the room corrects what it did at every send, 60 or 30 times a second. In development, the page throws on the first such read instead, and names the system and the trait:

moves.sprint read "stride" on a page, where it never is: the trait is serverOnly, so only the room holds it. Move the read to a system whose entry leaves runsOn out, which runs on the room alone. Where the page needs the value too, define the trait without serverOnly so the room streams it, with predicted: true where her page predicts it on her character, or with local: true so the room and the page each keep their own.

The check covers a system declared RunContext.Both or RunContext.Client, and a predicted system, through query, queryFirst, get, has, set and add on the world and on an entity, and through the engine’s updateEach. It runs where the world is a dev world: the development page, a headless game and a test. A built game carries no check. A game with no room holds every trait itself, so nothing is refused there. Outside a system, the dump and the devtools read every trait as before.

To fix the error, take the first of the following that fits:

  • The rule belongs to the room. Move the read to a system whose entry leaves runsOn out.
  • The page needs the value. Define the trait without serverOnly, so the room streams it.
  • Her page predicts the value on her character. Define the trait with predicted: true, as Predicted systems describes.
  • The room and the page each work the value out, and nothing rolls it back. Define the trait with local: true, as Choose who a trait reaches describes.

The check knows a trait made with defineTrait. A trait made with Koota’s trait has no name and no options, so the check cannot tell who holds it.

In a multiplayer game, mark each system that steps a player’s character as a predicted system: set predicted: true on its entry in the plugin. The engine runs a predicted system on one kind of step besides the world’s: a replay. When the room corrects where her page has her, her page runs her latest moves again from the corrected spot. The room steps every character once on each of its ticks, on her input for the tick or, where it has none, on her last input and then a still one, so a character’s steps and the world’s are the same steps on the room.

The room and her page both step her, so a predicted system runs on both and declares no runsOn. definePlugin refuses predicted: true beside RunContext.Server or RunContext.Client.

On a replay, only the predicted systems run, in the order the step runs them, each once a tick over the entities step.rerunEntities names: everything her page predicts, her character and any kart or pet she controls predicted. An unmarked system runs once on each of the world’s steps and never on a replay. A single-player game has no room, so it never replays, and the mark changes nothing there.

To tell the two kinds apart, ask which clock the system runs on:

  • A dash’s charges are a predicted system’s. It recharges them as she moves. Left unmarked, it would not run on her page’s replay, so her page would replay her steps without the recharge and refuse a dash the room ran, and the room would correct her again.
  • A monster’s AI is not. It steps the monster once per world step. Marked, it would run again on every replay on a page that predicts it, and the monster would move faster there whenever the room corrects her. The regeneration.heal system in the plugin above runs on the world’s clock too, and leaves predicted out.

A predicted system follows these rules:

  • Walk the characters with step.updateEachPredicted(query, callback), which hands the callback each character the step may touch that the query matches, with the query’s traits and the character as a PredictedEntity, whose predicted traits it may write: every character on the room and in a game played alone, her own predicted set alone on her page, and on a replay the set step.rerunEntities names, in one order on every world, bases before riders. It never hands over an entity no player controls. In a dev world, the engine prints a warning naming a predicted system that moves an entity outside the set on a replay, as one that walks anyEntity traits with updateEach can. It is the one pass that writes a predicted trait of yours.
  • Keep the system’s state for a character in a trait declared predicted. Her page keeps every predicted trait of her character for each step, and when the room’s value differs, it restores every one of them to the room’s exact value and replays the steps since. A timer in a predicted trait is where the room had it after the replay, so the replay counts it down again from there. A timer in any other trait is not restored, so the replay counts it down a second time. Keep no state for a character in a module variable or a closure either: the replay never restores it, and no check names it.
  • Draw a number both ends predict with step.random(character), the same on the room and her page for one tick and character. Draw a hidden roll, such as loot, or read the clock only where step.authoritative is true, so the room alone draws it and her page takes the result. In a room in development, the prediction checks name a predicted system that calls Math.random, random(world) or Date.now there, or that writes a trait of hers it does not predict.
  • Leave a character’s predicted traits to the predicted systems. The type holds it for a trait declared predicted: true: only the PredictedEntity a predicted system is handed writes it, and a one-off change the room makes goes through grantPredicted, as When the room changes the player’s character shows. For an anyEntity trait, in every dev world, the room’s and a headless game’s among them, the prediction checks name a game’s system that is not a predicted system as it writes one.

Type the function as a PredictedSystem, whose step is a PredictedStep. TypeScript then refuses it in an entry that leaves out predicted: true.

The following plugin recharges a dash’s charges as a predicted system, in the rules phase, where a timer counts. The dash itself is a push: its command’s reader calls pushCharacter, which holds the character’s velocity for the dash’s length, as Change the character’s movement describes. Never write her velocity yourself: the engine’s steer sets it from the input on every tick.

src/dash.plugin.ts
import type { World } from "koota";
import {
createQuery,
definePlugin,
defineTrait,
type PredictedStep,
} from "@spawnite/engine/core";
/** A character's dash charges, and the seconds toward the next: predicted, so
* her page counts them as the room does and rolls them back with her. */
const ChargesTrait = defineTrait(
"charges",
{ charges: 2, recharge: 0 },
{ predicted: true },
);
const chargers = createQuery(ChargesTrait);
/** Brings a charge back every three seconds, up to two. */
function rechargeDashes(_world: World, step: PredictedStep) {
step.updateEachPredicted(chargers, ([state]) => {
if (state.charges >= 2) return;
state.recharge += step.deltaSeconds;
if (state.recharge < 3) return;
state.charges += 1;
state.recharge = 0;
});
}
/** A dash's charges for each character. */
export const dash = definePlugin({
name: "dash",
description: "A dash's charges for each character.",
onCharacterSpawn: (_step, character) => {
character.add(ChargesTrait);
},
systems: {
rules: {
rechargeDashes: {
system: rechargeDashes,
description: "Brings a dash charge back every three seconds.",
predicted: true,
},
},
},
});

The step’s profile and the devtools name the system dash.rechargeDashes.

A predicted command reaches the predicted system that answers it on its tick: readCommands(step, handle) yields the tick’s commands, each with entity, a PredictedEntity, payload, tick, accept and refuse. Her page keeps each command and hands it to every replay of the tick the room ran it on, so a dash started by a command replays with the command, and one the room refused replays on no tick. Build a predicted mechanic describes how the room settles each one.

A predicted system keeps its state in a predicted trait, and a replay restores that trait before it runs the system again, so the system never asks whether a step is a replay. A re-run’s step.rerunEntities names her set; it happens in her page’s replay after a correction. The multiplayer page says what a predicted trait is and how it travels.

On a page and in a game played alone, the step carries the player’s input as input, a StepInput: one tick of every input the game’s plugins declare, the character’s move, heading and jump among them where the game lists characters(), each at its place in the composition. A room’s own step carries no input, because each player’s character holds what that player’s page last sent. A system reads a value by its handle, never by its place.

Which system reads the input depends on what the player drives:

  • A controlled entity that is no character, such as a car on a track: the step copies the character’s move into each track mover the player steers, steer from its x and throttle from its y, and a system reads them off the mover, as the TrackMover’s throttle does.
  • A player’s character: a predicted system reads the character it steps, with readInput(character, plugin.inputs.name), for a key the game declares in its plugin’s inputs. A room then steps each character on its own player’s keys, as Hold a key describes.

An event is something that happened, kept in a log each world holds. Every emit is one event, delivered once to each system and once to each view that listens, in the order emitted; two emits are never merged. A system reads it with readEvents, a view hears it with useEvent, spawnite play dump lists it under the dump’s events, and in a room it reaches every page that holds its entity. The engine’s own include DamagedEvent, DiedEvent, HealedEvent, TriggerEnteredEvent, TriggerExitedEvent, ChaseReachedEvent, ChaseExitedEvent, TakenEvent, InteractedEvent and ItemAcceptedEvent. A game makes its own with defineEvent, which takes the name the log, the dump and the room know it by, then the record’s defaults, or a function that makes them, or none for an event with no record. Every field that holds an entity, or a list of them, goes in entities, one level into a record or a list of records included, with what a page that does not hold the entity receives, WhenAbsent.Null or WhenAbsent.Withhold, or the event fails to typecheck: it travels as the entity’s key, and a page reads it back as its own copy of that entity. A list the default holds empty names what one of its records is in elements, so a page reads a streamed list by it and a vector in a record comes back a vector.

src/Chain.tsx
import type { Entity } from "koota";
import { defineEvent, useEvent, WhenAbsent } from "@spawnite/engine";
/** On the monster a chain of lightning starts from: each monster it
* jumped to after, in order. */
export const ChainedEvent = defineEvent(
"chained",
(): { hits: Entity[]; damage: number } => ({ hits: [], damage: 0 }),
{ entities: { hits: WhenAbsent.Null } },
);
/** The game's own effect: an arc from each monster to the next. */
declare function drawArcs(monsters: readonly Entity[]): void;
/** Draws each chain on this page, once, as the frame hears it. */
export function ChainArcs() {
useEvent(ChainedEvent, ({ entity, record }) => {
if (entity) drawArcs([entity, ...record.hits]);
});
return null;
}

A system in the room emits it with emitEvent(step, ChainedEvent, { hits, damage }, { entity: first }) as it deals the damage to each monster, and ChainArcs on every page draws the arcs, between the monsters where they stand now.

  • Each system reads every event once. readEvents(step, event) returns every event emitted since that system last read the event, so a system that runs before the emitter reads it the next step and one after reads it the same step, and a system that skips ticks, by its own counter or a schedule, reads every event since its last read. The world keeps an event for a system that has read it before for up to maximumUnreadSteps, one second; a read later than that gets what is left, counted in the room’s heartbeat and named in development. A system never reads its own emits of the run that made them: it reads them on its next run. The array is valid until the system returns.
  • A predicted system reads no events. A replay after a correction runs predicted systems again, which would read each event again; readEvents refuses a predicted step by type. A reaction that changes predicted state runs in an ordinary system on the room, which runs once a step, and writes the predicted value with grantPredicted, which her page takes as one correction.
  • An event is an outcome, so emitting one takes an authority: the step inside if (step.authoritative) or in a system with no runsOn, requireAuthority(world) between two steps, such as in a room’s message handler, or findAuthority(world, entity), which on a page returns an authority over an entity only that page holds, as Unreal gives a client authority over an actor it spawned that is not replicated, and null for a streamed one. An emit between two steps is read by every system of the next step. No page emits an event about an entity the room streams, so neither its prediction nor its replay can play one twice: the room’s event reaches her page a round trip after she acts.
  • Each emit is one event. Two hits on one monster in one step are two DamagedEvents, each with its own record: who dealt it, the weapon, the game’s own data and where it landed, as the weapon page shows. A moment that touches many entities may still carry them in one event, as the chain does, which each page draws between their current positions.
  • A view hears each event once, after the frame. The frame loop hands each world’s events to its views once a frame, after the frame’s steps and its animation, however many steps the frame ran. A view hears each event emitted after it subscribed; unmounting drops the rest. A listener’s throw is reported and the other listeners still hear the event, and an event a listener emits is heard at the next frame. A room never draws, so it keeps nothing for views.
  • A view hears every entity’s event unless it passes { entity }, even a view mounted under one <Entity>. An effect that belongs to one entity, such as a coin’s sparks, passes { entity: useEntity() }, as the Pickup page’s coin does.
  • An event outlives its entity. event.entity reads null once the entity is destroyed, and so does an entity field that names it, or a list leaves it out, even after a new entity takes its slot, while { entity } still matches it. Put what a reader needs in the record: DamagedEvent carries where the hit landed, so a damage number shows for a killing blow. On a page in a room, the entity stands until its readers are done, as the next bullet says.
  • Where it goes is the event’s delivery. EventDelivery.Reliable, the default, reaches every page that holds its entity, kept through a page that falls behind or hides its tab, up to maximumHeldEvents. EventDelivery.Cosmetic is dropped by a page more than a second behind or with its tab hidden, as a spark or a tracer should be. EventDelivery.ServerOnly stays on the room, and useEvent refuses it by type. EventDelivery.Local is the drawing world’s own, emitted with emitEvent(world, ...) outside any step, as the engine’s ClipMarkEvent is as a clip passes a mark, and readEvents refuses it by type. scope: EventScope.World makes an event about the world, with no entity, such as the engine’s ShotJudgedEvent.
  • In a room, each send carries every event emitted since the last, one entry per emit, with the tick it was emitted at. A page holds them for its next step’s systems and its frame’s views. When the stream removes an entity that an event the page holds still names, as its entity or in an entity field, the entity stays in the page’s world until the page’s next step has read the event and, in a game’s frame, its views have heard it. Only then does the page remove it. So a page reads a death with the dead entity standing, even when one send carries the death and the removal, or one frame lands both sends. It stands as the page last held it: a write the room made in the tick it destroyed the entity never reaches the page. A page’s reader may therefore find an entity standing that the room’s reader, running later in the same step as the destroy, read as null. A system that skips ticks and reads later reads null, as it would on the room. An event whose entity the room destroyed before the send arrives marked gone, so a page that never held the entity reads it as null. A player who joins hears every event emitted after the tick her welcome’s snapshot stands at, and none before, since the snapshot holds their consequences, except an event sent to her by name with to, which her welcome carries from the step her join landed in.
  • A world holds at most maximumHeldEvents events, and maximumHeldEventBytes bytes of records, at once. Past either, a room refuses an emit, which throws in development naming the system emitting most and is counted in the heartbeat in a deployed room, and a page that has fallen behind drops its oldest, with a development warning.
  • A reader can tell it missed events. readEvents returns its list with gap, true where the read came more than maximumUnreadSteps after the system’s last read, or its page fell past the bound behind the room. After a gap, read the state the events describe rather than trust the events: whether a trigger stands occupied, not that a missed exit never happened.
  • A record is the reader’s to keep. The engine copies each record as it is emitted and never reuses one, so a view may keep it, in React state or anywhere else, and a reader never sees it change.
  • An event can go to chosen players. emitEvent(step, event, record, { to }) takes a player’s PlayerEntity, or a list of them, and sends the event to their pages alone, in emit order among the rest. Every system of the world that emitted it reads it. A player who left before the emit receives nothing, an empty list reaches no page, and the room tells a command’s sender her cause only of an event it sends her. A game played alone hands its views, and its systems that run on the client alone, only what reaches its own player, as her page in a room receives it.
  • A page that does not hold an entity receives what the event declares. An event on a living entity a page does not hold never reaches it. A WhenAbsent.Withhold field naming such an entity keeps the event from that page; a WhenAbsent.Null field reads null there, and a list leaves the entity out, so an event never hands a page an entity it was not sent. A null the list held stays, so a list beside it keeps its places. An entity destroyed since the last send counts as held by the pages that held it, so a field naming it still reaches them, null once their readers are done with it; an event on a destroyed entity reaches every page, marked gone.

Roblox’s BindableEvent, Unreal’s delegates, Unity’s UnityEvent and Godot’s signals call a listener inside the emit, which in a room runs only where the emit runs. Here an event is a log that systems read, as Bevy’s buffered messages are, each reader from where it last read, so the dump lists it, the room streams it and a replay replays it. An agent from Bevy 0.17 reads this engine’s event as Bevy’s message, not Bevy’s observer. emitEvent takes its verb from Godot’s emit and Node’s EventEmitter, and does what Bevy’s MessageWriter::write does: it hands the event to the systems that read it, and calls no listener.

A dev room keeps a checkpoint of its world, and a continue writes it back into a fresh room to run a moment again. The checkpoint holds every trait of every entity, the world’s own traits, the physics world and the world’s random stream, so keep a system’s state in those places:

  • Keep a system’s state in a trait. A count, a timer or a list a system keeps in a variable of its module, or in a closure it made, starts afresh in a continue, which then parts from the room. Put it in a trait, on an entity or on the world with world.add. A scratch vector written before each read is safe.
  • Draw from the world’s stream. random(world) draws from 0 up to 1, randomRange between two numbers and randomInt a whole number between two, both included, as Godot’s randi_range and Roblox’s NextInteger do, and shuffle shuffles a list in place, as Godot’s Array.shuffle does. The stream’s state is the world’s WorldRandomTrait trait, so a continue draws what the room drew. Every world starts from the same seed, as a test’s world should, and a room draws a fresh seed with Math.random as it starts, so no two runs play alike. seedRandom starts it from a seed of your own, as Unity’s Random.InitState does. Math.random stays fine for what only the page draws, a spark’s scatter or a sound’s pitch.
  • Read no wall clock in a system. Count the step’s deltaSeconds instead.

A game’s lint warns on Math.random, and on a variable of the module a system writes, in any function that takes the world or is typed System. spawnite replay check proves the rest: it names the first trait where a continue parts from the room.