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

Commands and what each page sees

A game in a room talks to it in three ways beyond the input every page sends each tick.

Moves, walks, shots and item verbs need no code of the game’s own. For anything else a player asks for, such as “I am ready”, “I take this card” or “buy two potions”, a game declares a command on a plugin with defineCommand, by the fields of its payload, and a system on the room answers it. The page awaits one result for each command it sends: accepted, with a value the command declares, or refused, with a reason. The game lists the plugin in its plugins, so the room’s world and every page’s know the same command:

packages/room/test/outside/market.ts
import type { World } from "koota";
import {
defineCommand,
definePlugin,
defineTrait,
findWalletHolder,
integer,
readCommands,
spendCoins,
type AuthoritativeStep,
} from "@spawnite/engine/core";
// A potion stall, built as a creator's plugin is, from the engine's public
// entry alone: a page asks to buy, and the reader on the room checks her
// wallet before it sells, then answers with the potions she now holds.
/** Coins one potion costs. */
export const potionPrice = 5;
/** The most potions her player may hold. */
export const mostPotions = 10_000;
/** The potions her player holds. */
export const PotionsTrait = defineTrait("potions", { count: 0 });
/** Sells each buyer her potions where she has room for them and her
* wallet holds their price, and refuses `bag-full` or `too-poor` before
* anything changes where she has not. */
function sell(world: World, step: AuthoritativeStep) {
for (const command of readCommands(step, market.commands.buy)) {
const { player, payload } = command;
const count = (player.get(PotionsTrait)?.count ?? 0) + payload.count;
if (count > mostPotions) {
command.refuse("bag-full");
continue;
}
const wallet = findWalletHolder(world, player);
if (!spendCoins(step, wallet, payload.count * potionPrice)) {
command.refuse("too-poor");
continue;
}
player.set(PotionsTrait, { count });
command.accept({ potions: count });
}
}
export const market = definePlugin({
name: "market",
commands: {
// The payload names what she wants, never its price: the reader
// reads the price and her wallet from the world.
buy: defineCommand(
{ count: integer(1, 10) },
{
refusals: ["bag-full", "too-poor"],
returns: { potions: integer(0, mostPotions) },
},
),
},
// A command's reader runs on the room alone, which decides the sale.
systems: { rules: { sell: { system: sell, answers: ["buy"] } } },
onPlayerJoin: (_step, player) => {
player.add(PotionsTrait);
},
});

The plugin registers the command as market.buy, its own name then the command’s key, and holds its handle at market.commands.buy. A page sends it with sendCommand(world, market.commands.buy, { count: 2 }), from @spawnite/engine, with the world from koota’s useWorld. The handle types the payload, the reasons and the value, so a missing or wrong field fails the typecheck. sendCommand returns a promise that never rejects:

  • CommandStatus.Accepted, with value set to what the reader passed to accept, here { potions: 2 }.
  • CommandStatus.Refused, with reason, one of the command’s refusals or the engine’s own CommandRefusal.
  • CommandStatus.Unknown, in a room alone: her page lost the room before the result came. The command may have run, so a game reads the world again rather than tell her it failed.

A HUD shows the outcome where it awaits the call, or reads useCommandResults(handle) for her last 16 results of one handle, each Pending from the moment she sends it, or useLatestCommandResult(handle) for the newest alone.

A command asks, and the room decides. The payload names what the player wants; the reader checks it against the world before it changes anything. A “buy” command carries the count, never the price, and the reader reads the price and her wallet from the world. A command never states a fact the room would have to take her word for, such as her score, her balance or her position. An amount she asks for or offers is a request, such as five coins for a trade, and the reader checks she holds it.

The system that answers a command sits under the phase it runs in, here rules, which judges what the step did, names the command’s key in answers, and reads the commands with readCommands(step, handle), from @spawnite/engine/core, in the order the room took them. Each command holds player, the sender’s player entity, and payload, checked, with each entity field resolved to the room’s own copy. A system finds every player with readPlayers(world), and a page finds its own with usePlayer() or readLocalPlayer(world). The reader settles each command it reads in one of three ways:

  • accept(value) runs it, with the value the command’s returns declares, or with nothing where it declares none.
  • refuse(reason, detail) answers it with one of the command’s refusals, and an optional short line of detail. The detail reaches her page, so it never names what she may not know. A refusal undoes nothing the reader already wrote, so a reader checks before it writes, as spendCoins checks and spends in one call.
  • defer() leaves it queued for the system’s next run, such as a purchase that waits for a shop to open.

One system answers each command. The composer refuses a world where two systems answer one command, where none does, or where a system reads a command another one answers: a system that wants to know what happened reads an event the answering system emits instead. A reader of a command her page does not predict runs on the room alone, so its system takes no predicted or runsOn; a predicted command’s reader is a predicted system, as Build a predicted mechanic shows. A command its system leaves unsettled is refused faulted, and in development the step throws, naming the system. readCommands(step, [shop.commands.buy, shop.commands.sell]) reads two handles of one plugin in one pass, in the order the room took them, and the reader branches on command.key; its system lists both in answers.

In a game played alone, sendCommand takes the same path: the world’s own next step admits the command with the same checks and runs the same reader, and the promise settles the same way, so single player needs no second code path. A scene’s bot sends a command with writeCommand(step, bot, { command, payload }), from @spawnite/engine/core.

A field is one of the following, each from @spawnite/engine/core, and a payload holds every field it declares and no other. A command with no payload declares {}.

  • integer(min, max) and number(min, max): a whole number, or any finite number, in range.
  • boolean(): true or false.
  • oneOf(["red", "teal"]): one value of a closed set, given as a list of strings, a string enum such as oneOf(DoorState), or a registry the world holds. The room refuses a value outside the set, so no command carries free text.
  • entity() and entities(maxLength): an entity on the room, or a list of at most maxLength. entity({ optional: true }) holds null where she names none.
  • controlled(maxLength): a selection of entities she controls, such as the units she ordered. The command’s result names in skipped those she no longer controlled.
  • vector3() and list(field, maxLength): a point, or a list of one single-value kind.

The wire carries an entity as the key the room files it under, and the room refuses entity-gone a command whose entity is gone.

defineCommand(fields, options) takes these options beside the fields:

  • refusals: the reasons its reader may refuse it with, kebab-case codes such as ["too-poor", "occupied"].
  • returns: the fields of the value an accepted command carries back, such as { building: entity() }. A returned entity her page does not hold reads as null there.
  • entity: true: the command acts through one entity she controls, which sendCommand names in its entity option and the reader reads as command.entity. The room runs it only while she still controls that entity.
  • lifetime: what ends it before it runs, a CommandLifetime. Session, the default with no entity, ends only when she leaves. Level also ends with the level she sent it in. Entity also ends when the entity it acts through is destroyed. Control, the default with entity: true, also ends when her control of that entity changes.
  • perSecond and burst: how many she may send each second, 4 by default, any number above 0 and up to 60, such as 0.5 for one every two seconds, and how many at once past that, 2 by default and at most 16. They guard against a flood, not a game rule.
  • timeoutSeconds: how long it may wait in its queue for its reader, 10 seconds by default.
  • predicted: true: her page runs it on its tick as a guess, and the room runs it on the same tick and decides. It needs entity: true, and its reader is a predicted system. lateTicks says how late it may reach the room and still run, 17 ticks by default.
  • keys and touch: the keys that send a command with no fields, by KeyboardEvent.code, and a button for it on a phone.

The engine refuses with its own reasons, the same in a room and alone, so a HUD can show a line for each: malformed for a field out of its kind or a payload past 16,384 bytes, too-fast past the rate, too-many past 64 of her commands with no result, not-active where the command’s plugin does not run, entity-gone, not-controlled, level-changed, player-left, room-closing, timed-out, late for a predicted command that reached the room past its lateTicks, and faulted. commandRefusalLines, from @spawnite/engine, holds a plain line for each, which a game spreads into its own: { ...commandRefusalLines, "too-poor": "Not enough coins" }. Her page refuses at once what it can check as the room would, so a command the room would refuse for its shape, its rate or her control never leaves the page.

A resend runs once. Each page numbers its commands, and after a broken socket her page resends what it never heard answered: the room answers a resend of a command it already settled with the first result, and never runs it again. A game writes no duplicate check of its own.

Roblox’s RemoteFunction is the closest shape: a call the client awaits. A command differs from it in four places. Its result is typed and always arrives, refusals included, so no game writes its own reply. A resend runs once. A lifetime gives a race with a death or a level change one stated outcome. The engine’s refusals for rate, shape and lost control come with a reason the page can show, where Unreal disconnects the client and Godot logs.

An action the page should show at once, such as a dash, is a predicted command: the same defineCommand with predicted: true, sent with sendCommand or a key, read with readCommands(step, handle) in a predicted system, and shown in useLatestCommandResult first as her page’s guess, then as the room’s result. Build a predicted mechanic builds one.

A button that seems to do nothing either sent no command, sent one the room refused, sent one that has no result yet, or sent one the room accepted whose effect her page does not show. Every command has exactly one result, so its reason says what happened. In a game played alone and in a room, read it in this order:

  1. spawnite play state prints a Commands: line for her page’s own player: how many the page sent, accepted, refused by each reason, unknown and with no result yet, and the last refusal with who refused it and on which tick, such as the last refused: market.buy, too-poor, by market.sell at tick 1204. No Commands: line means the button never called sendCommand: spawnite play press market.buy --payload '{"count":2}' sends the command with no button, which tells the button’s wiring from the command.
  2. Read the reason. One the command declares, such as too-poor, came from its reader, the system named after by. An engine reason names its cause: too-fast for the rate, not-controlled for an entity she does not control, not-active for a plugin listed in the game that does not run now, as one an activeIn level leaves out, or whose answering system was omitted, timed-out for a reader that kept deferring it or a schedule that ran past its timeoutSeconds, and the rest as the list above says. A plugin the game does not list at all never gets this far: sendCommand throws, naming the plugin to add.
  3. spawnite play dump --commands prints the last 64 commands one by one, each with its result, its reason and detail, the tick her page sent it on, the tick its result was settled on, and who settled it, a CommandSettler: page where her page refused it before it left, room for the engine’s own refusal, or system with the system’s name in system. Under pending it lists her commands with no result: one with isSent: false has not left her page, because the page is not joined or her socket is down, and goes once she rejoins. In a game played alone, where one world decides, the dump also holds the tick each was admitted on and queues, each command still waiting for its reader with its age and the ticks left before timed-out; in a room those live on the room, which a page’s dump does not reach.
  4. A command accepted whose effect does not show ran on the room: read what its reader wrote with spawnite play dump --where, and check that the trait reaches her page, as Choose who a trait reaches says.
  5. A predicted command whose entry reads isUndone: true ran on her page as a guess and the room refused it, so its effect was taken back. Its reason says why the two ends decided apart.

The devtools’ Commands section, at the foot of the rail, lists the same timeline while you play. In a test, readCommandTimeline(world) from @spawnite/engine returns those entries, and dumpState(world).commands the whole section. A development world keeps the timeline; a built game keeps only the counts. A room’s own log counts its commands by reason in each heartbeat, as the development room says.

A page that joins a room already playing gets the room’s world whole in its welcome, and so does a page that rejoins or a replay that opens mid-fight. Every entity a welcome brings carries the engine’s FromWelcomeTrait trait on that page, and an entity that spawns after it does not, so a view plays a spawn effect only for what spawns while the player watches: a monster the welcome brought stands up at once rather than climbing out of the ground. The mark is the page’s own, and no dump or hash carries it. Unreal marks a level’s own actors the same way, with bNetStartup; Roblox, Unity’s Netcode and Godot leave it to the game.

The same holds for an entity that goes. A welcome takes the whole stream down before it brings the world back, so an entity the new world leaves out, such as a monster that died before the moment a replay seeks to, is gone from the page as if the room had taken it away. The world’s WelcomesTrait trait counts the welcomes the page has taken. A view reads the count as its entity mounts and again as the entity goes: a count that moved means a welcome replaced the stream, so the view plays no death or despawn effect and the page shows only what the world holds. Unreal’s replays skip cosmetic effects the same way while they scrub, with IsFastForwarding.

A command goes from a page to the room; an event goes from the room to every page. A hit, a trigger crossed, a coin taken or a game’s own moment, such as a chain of lightning between two monsters, is an event the room emits. The room sends every event emitted since its last send beside the next delta, one entry per emit, and each page’s useEvent hears each once, to play a sound or draw a flash. A game makes its own with defineEvent and emits it with emitEvent, on an entity or about the world, and needs no code of its own to send it.

The room streams a trait a game defines to every page, under its name. Two traits, two events or two behaviours may not share a name: a second definition under a name another of its kind holds throws and names both. A trait and its behaviour may share one, since the stream knows a trait by the trait’s name. A module that hot reload runs again, or a watched room imports again after an edit, defines its own anew, and each new definition takes its name. Three options on defineTrait narrow who receives a trait, and a fourth names the fields that hold an entity:

import type { Entity } from "koota";
import { defineTrait } from "@spawnite/engine/core";
// Every page: the default.
export const WardenTrait = defineTrait("warden", { coins: 0, tier: 1 });
// Her own page alone: on her player entity, or on an entity she controls.
export const HandTrait = defineTrait(
"hand",
() => ({ cards: [] as string[] }),
{ ownerOnly: true },
);
// The room alone: a rule reads it, and no page draws it.
export const StrideTrait = defineTrait(
"stride",
{ speed: 5 },
{ serverOnly: true },
);
// Her page predicts it on her character, and receives it alone; `seenByAll`
// streams it to every page too. Only a predicted system and the room's
// `grantPredicted` write it; `anyEntity: true` lets a monster hold it.
export const DashTrait = defineTrait(
"dash",
{ charges: 2, recharge: 0 },
{ predicted: true },
);
// The room and each page keep their own: no stream carries it, and no
// rollback restores it.
export const WalkTargetTrait = defineTrait(
"walkTarget",
{ x: 0, z: 0 },
{ local: true },
);
// Holds an entity: the dump and the stream write the field as that
// entity's key, and each page reads the key back as its own copy.
export const MarkTrait = defineTrait(
"mark",
{ entity: null as Entity | null },
{ entities: ["entity"] },
);

The following table shows where each trait goes:

Option The dump Every page Its owner’s page
None Shows it Streamed Streamed
ownerOnly Shows it No Streamed
serverOnly Shows it No No
local Shows it Keeps its own Keeps its own
predicted Shows it No Her rollback
predicted and seenByAll Shows it Streamed Her rollback

Use ownerOnly for what one player alone may see, such as a hand of cards: another player’s page never receives it, so no changed page can read it. The room sends it to her page from her player entity and from each entity she controls, such as her character, so a trait for her alone, such as a trade offered to her, sits on her player entity. Use serverOnly for what a rule reads and no page draws or predicts. A trait names each field that holds an entity in entities. An entity’s number means nothing on another world, so a streamed trait carries each such field as the key the room files the entity under, and a page reads the key back as its own copy of that entity. A field whose entity is gone reaches the page as null. So does a key that names an entity the page never received, such as one with no streamed trait, and in development the page warns once; it reads the entity only when the room next changes the field. An event carries an entity to a page, as Tell every page what happened describes.

Use local for what the room and a page each work out for themselves and nothing rolls back, such as where a cast waiting on a walk sends her: each world keeps its own, and neither copy is sent. State of her character that her page predicts step by step, such as a dash’s timer, is predicted instead, which rolls it back with her movement.

A page never holds a serverOnly trait, so a system that reads one there acts on its absence. A system that reads one runs on the room alone, which is the default. In development, a system that runs on a page throws on its first read of one, as A page’s read of a room-only trait describes.

Define each trait once, at module scope, in a module the room and the page both import. The name is a dump key: letters and digits, starting with a lower case letter. Setting two of ownerOnly, serverOnly, local and predicted throws, as seenByAll or anyEntity without predicted does. Every trait whose record holds an entity, whatever its option, names each such field in entities, typed with | null, or it fails to typecheck, and so does every event: a destroy sets each named field to null and drops it from each named list, and each one a stream carries reaches a page, where the room’s number for an entity names another entity. A predicted trait is a PredictedTrait, which koota’s own functions refuse, unless anyEntity: true keeps it koota’s own, as Build a predicted mechanic says.

Unreal marks a property Replicated with a condition such as COND_OwnerOnly, and an unmarked property is each machine’s own, as local is here. Unity’s Netcode gives a NetworkVariable a read permission of Everyone or Owner. Godot filters a synchronizer’s visibility for each peer, and Roblox decides by where an instance is stored.