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

Plugins

A plugin is one named part of the game’s step: its systems, the commands a player sends it, and what it registers on the world. A game lists the plugins it runs in src/game.ts. Game, the harness, the room and spawnite simulate read that one list, so a page and its room step the same systems in the same order. The smallest list, for a game whose players walk, holds one plugin, as this src/game.ts does:

packages/engine/test/outside/walkers.ts
import { characters, type PluginList } from "@spawnite/engine/core";
export const plugins: PluginList = [characters()];

A game with health, weapons, abilities and a bag lists the engine’s plugin for each, and its own beside them, such as the regeneration plugin that A game’s own plugin builds:

src/game.ts
import {
abilities,
behaviours,
characters,
cooldowns,
inventory,
resources,
stats,
weapons,
type PluginList,
} from "@spawnite/engine/core";
import { regeneration } from "./regeneration.plugin";
export const plugins: PluginList = [
characters(),
stats(),
cooldowns(),
resources(),
behaviours(),
weapons(),
abilities(),
inventory(),
regeneration,
];

Game takes the list as plugins:

<Game name="sled" start="lobby" plugins={plugins}>

A headless test passes the list beside the scene, here a spawn function such as the spawnCoins that Headless tests defines:

const game = await createHeadlessGame({ scene: spawnCoins, plugins });

A room loads a scene’s file and nothing else of the game, so each scene’s file re-exports the list, as The list in a scene’s file shows.

A list may hold another list, and a falsy entry is skipped, so hardMode && hardModeRules is an entry. With no plugins, or an empty list, a world runs the core alone. Game makes its world from the list as it mounts, and a new world when the list holds other plugin objects, as a hot update to a plugin’s module makes; an engine plugin’s factory returns one object for each set of options, so a list written again on each render keeps its world.

The engine’s own systems are plugins, built with the same definePlugin a game calls, and a game lists each one it runs. Under them is the core: the step’s own machinery, which every world runs and no game lists, omits or replaces. Its systems are core.copyInput, core.move, core.blendCorrections, core.advanceDayClock, core.physics, core.follow and core.restColliders, and a game’s system orders against them by their handles under core.systems.

Plugin What it adds It requires Its systems
characters() Each character’s movement on its input or along its path, and its pushes and speed modifiers. nothing characters.walk, characters.respawn, characters.aim, characters.npcMovement, characters.movement, characters.countDown
stats() One formula for every stat, and the fields stats set, such as a resource’s maximum. nothing stats.expireModifiers, stats.copyFields
cooldowns() One timer abilities, items and resources share. nothing cooldowns.countDown
resources() Health’s regeneration, and each resource a game declares: mana, rage, stamina. stats(), cooldowns.systems.countDown resources.regenerate
machines() Each state machine’s wait and its when, as states() declares one. nothing machines.advance
behaviours() The engine’s behaviours, each its own system, and the Interact command. nothing behaviours.spin, bob, pickup, interact, trigger, capHealth, reapDead, respawnDowned, chase
weapons() The shots the room judges, and the projectiles in flight. behaviours() weapons.flyProjectiles
abilities() Casting: each cast a player asks for, its approach, its wind-up and its release. weapons(), cooldowns.systems.countDown, resources(), characters() abilities.approach, abilities.cast, abilities.keepRefusals
inventory() The bag, what an entity wears, the uses of its items, loot on the ground and the locks items open. stats(), cooldowns.systems.countDown, behaviours() inventory.useItems, inventory.loot
npcs() Each NPC’s routine, and the conversations a player opens. machines(), behaviours(), characters() npcs.converse, npcs.runRoutines
tracks() Each mover’s ride along its track, and the triggers it reaches. nothing tracks.copyInput, tracks.move, tracks.markTriggers
rounds() The round: its score, its clock, and its win or loss. machines() rounds.advance
controls() The engine’s own keys moved or dropped, so a game’s plugin can take them. nothing none
dragAndDrop() Drag and drop on the HUD: what a Draggable carries, onto a DropTarget. nothing none

A game whose players walk lists characters(), so the common game is one line, export const plugins = [characters()], and a game with health adds behaviours() to it. A card game or a twin-stick ship lists no characters(): a player who joins its room holds a seat with no body, and nothing spawns a character. A plugin that needs another names it in requires, and the world throws as it is made where the list leaves it out, naming the line to add.

Each factory carries its plugin’s handles, characters.systems.movement or rounds.systems.advance, for a game’s system to run before or after one, or to replace or omit it. core’s reference page tables every system by phase, in the order the step runs them with every engine plugin listed, with where it runs and what it does.

A game imports its plugin factories from @spawnite/engine/core, as src/game.ts above and the example template do, and a readable plugin’s from its own entry, such as rounds from @spawnite/engine/rounds. A factory is one object whichever entry exports it, so a list that takes abilities from @spawnite/engine/abilities beside the rest from core composes the same.

dragAndDrop() is all React, so it imports from @spawnite/engine or @spawnite/engine/dragAndDrop, never from core. abilities(), weapons(), npcs(), inventory() and tracks() each import from their own entry as well as from @spawnite/engine/core: import { abilities, useCast } from "@spawnite/engine/abilities". A plugin’s entry holds its factory and the traits, functions, components and hooks that belong to it, such as Targeting, SpellLook, useCast and useTarget beside abilities(). The root, @spawnite/engine, and @spawnite/engine/core export a closed plugin’s names too; a readable plugin’s names come from its own entry alone, such as import { rounds, useRound } from "@spawnite/engine/rounds".

A call into a plugin the list leaves out throws and names the line to add, where it would otherwise do something no system ever reads. A game’s own plugin refuses the same way with requirePlugin(world, name), which it calls first in each function a game calls to change the plugin’s state, as startRound does. A game lists its own plugin as the plugin itself or as a factory’s call, so for a game’s plugin the message names both forms: The plugin named "lamps" is off in this world: add it to plugins in src/game.ts, the plugin itself or a call to the factory that makes it. grantAbilities on a world listed without abilities() throws abilities() is off in this world: add abilities() to plugins in src/game.ts. The same holds for the following:

  • characters(): spawnCharacter, a scene’s <CharacterSpawn> as it mounts, and walkTo.
  • stats(): addStatModifier, removeStatModifiers, setStatBase and <Stats>.
  • cooldowns(): startCooldown.
  • resources(): changeResource, declareResource, registerHealth and <Resource>, and a plugin’s resources as the world is made.
  • machines(): a machine’s record added to an entity.
  • behaviours(): dealDamage, dealHealing and each of its components, such as <Spin>, <Health> and <Chase>.
  • inventory(): equipItem, activateItem, <Inventory>, <Loot> and <Lock>.
  • weapons(), npcs(), tracks() and rounds(): registerWeapon, registerShotSettings, spawnNpc, launchTrackMover, <TrackMover>, startRound, finishRound and failRound.
  • A <CharacterSpawn> prop no listed plugin reads, such as weapons without weapons() or bagSlots without inventory().

walkTo and startCooldown also throw where the list omits the system they need: characters.movement for a player’s character, characters.npcMovement for a character no player controls, or cooldowns.countDown. isSystemEnabled(world, handle) reads whether the list holds a system, or a replacement in its place, whichever end runs it, without throwing.

The engine’s package ships one of its plugins as readable source beside the closed, minified core: rounds(). Every other engine plugin is closed. A readable plugin sits in its own folder, node_modules/@spawnite/engine/readable/<plugin>/, so rounds() is in readable/rounds/, which holds the following:

  • The plugin’s TypeScript source, with its comments, each file headed by its license lines.
  • dist/: one unminified script per source file, which is what a game runs, with a declaration and a map for each, so an editor’s go-to-definition opens the source.

A readable plugin imports the engine through its public entries alone, @spawnite/engine and @spawnite/engine/core, and no part of the core imports it. Every call it makes is one a game’s own plugin can make, so it doubles as a worked example of a game’s own plugin, and a game can copy one as the start of its own, keeping the license lines, as the license says.

The rest of the engine stays closed: the core, which holds prediction, the room’s replication and the physics, and each plugin that still reaches into the core’s private modules. A plugin opens once it reaches the core through public entries alone; the others follow as each is moved onto them.

A plugin is installed and upgraded with the engine, listed by a game, and changed through its options. A kit is source copied into the game, which the game owns and edits, and an upgrade never touches it. The engine’s code splits into three layers, and these are their only names:

  • The core is the step’s own machinery, closed, which every game runs.
  • A plugin is listed by the game and changed through its options. A readable one, today rounds(), is built on the same public API a game uses and ships its source in the package.
  • A kit is a plugin or a part of one whose source the game holds.

Where games want different settings, a piece is a plugin; where they want different code, it is a kit. For example, a game that wants a longer round sets seconds on <Round>, a setting of rounds(); a game that wants a round with lives and no clock copies the readable rounds() into a kit and changes its code. A piece is also a plugin when other plugins or the platform build on its types, as abilities and weapons build on cooldowns, or when a fix must reach every game by an upgrade. A genre’s movement feel or its rules is a kit. A readable plugin can be copied out into a kit: the copy keeps the plugin’s name and its place in the step, and the game lists the copy in the engine plugin’s place. A closed plugin ships no source to copy, so a game that wants different code from one replaces or omits the system it differs on, as Change an engine system shows. Unity ships packages beside samples a project imports and edits, and Unreal ships plugins beside starter projects a team copies; a plugin and a kit are the same split.

A game takes one system of a listed plugin out with omit, or puts its own in a system’s place with replaces, each by the system’s handle.

A game that keeps its dead as corpses lists omit(behaviours.systems.reapDead) beside behaviours(), and a plugin of its own that reads health at zero; a game with its own chase lists a plugin whose chase entry says replaces: behaviours.systems.chase, such as the one below.

characters() splits its work so each part goes alone. characters.systems.movement moves each player’s character, and a game with its own movement, such as a platformer’s, replaces it and keeps the rest: characters.systems.npcMovement still moves every character no player controls, characters.systems.aim still writes her aim into her pose, and the walk command still sets her path. The NPCs move before the players, so a player standing on an NPC is carried by its move; a game’s system that moves a base, such as a ferry, or steers or pushes an NPC, anchors before: [characters.systems.npcMovement], which places it before both movements. A roguelike that ends the run on a death lists omit(characters.systems.respawn), which keeps each player’s character down, while behaviours.systems.respawnDowned still stands every other entity with a respawn, such as a crate, an NPC or a kart a player drives. A world that lists behaviours() and no characters() has no player’s character to respawn, and the behaviours stand everything else.

  • omit(handle) removes one system of a listed plugin from every world the list makes. A whole plugin is left out by not listing it. An omit of a system that another listed plugin requires, or anchors to without anchorIfPresent, throws as the world is made, naming both.
  • replaces: handle on a system’s entry hands the replaced system’s place to it: its slot in the step, its anchors, and every anchor and requirement another plugin writes to the handle. The replacement keeps its own name, nav-chase.chase, and the dump names what it replaced. A predicted system is replaced only by an entry marked predicted: true, and an unpredicted one only by an unpredicted entry with the same runsOn, as the types require; for a system of another kind, omit the engine’s and add the game’s beside it. A replacement may itself be replaced, as a game replaces a kit’s replacement, and two plugins that replace one handle throw, naming both.
  • The core is neither omitted nor replaced: a game anchors to core.systems and no more.

The chase below takes the engine’s place, from the engine’s public entry alone. The engine’s chase points a chaser straight at the nearest player and moves it on the navmesh, sliding along whatever it meets, so a wall square across that line holds it. This one asks the navmesh for a route and walks its corners. Against a real room, it walks a wolf round a wall the engine’s chase stops at:

packages/room/test/outside/navChase.ts
import type { World } from "koota";
import { createQuery, Not } from "koota";
import { Vector3 } from "three";
import {
behaviours,
ChaseTrait,
definePlugin,
DownedTrait,
findNearestControlled,
findPath,
NavMeshTrait,
readEach,
TransformTrait,
VelocityTrait,
} from "@spawnite/engine/core";
// A chase that walks the navmesh, built as a creator's plugin is, from
// the engine's public entry alone, in place of the engine's straight
// chase: it takes behaviours.chase's handle, so its slot in the step and
// every anchor to that handle are its own. Each chaser walks toward the
// nearest controlled entity along the corners the navmesh routes it by,
// round what blocks the straight line.
const chasers = createQuery(ChaseTrait, TransformTrait, Not(DownedTrait));
const unmoved = createQuery(ChaseTrait, Not(VelocityTrait));
// Along the ground, as the engine's chase measures.
const search = { level: true };
// Written in place per chaser.
const heading = new Vector3();
/** How close a corner may be before the chaser heads for the next one. */
const cornerReach = 0.4;
function chaseAlongNavMesh(world: World) {
readEach(world, unmoved, (_traits, chaser) => chaser.add(VelocityTrait));
const handle = world.queryFirst(NavMeshTrait)?.get(NavMeshTrait)?.handle;
readEach(world, chasers, (_traits, chaser) => {
const chase = chaser.get(ChaseTrait);
const position = chaser.get(TransformTrait)?.position;
const velocity = chaser.get(VelocityTrait);
if (!chase || !position || !velocity) return;
const nearest = findNearestControlled(world, position, search);
const target = nearest?.entity.get(TransformTrait)?.position;
if (!handle || !nearest || !target || nearest.distance <= chase.reach) {
velocity.set(0, 0, 0);
return;
}
const route = findPath(handle, { from: position, to: target });
const corner = route.corners?.find(
(point) =>
Math.hypot(point.x - position.x, point.z - position.z) >
cornerReach,
);
heading.subVectors(corner ?? target, position);
heading.y = 0;
velocity.copy(heading.normalize().multiplyScalar(chase.speed));
});
}
export const navChase = definePlugin({
name: "nav-chase",
description: "Walks each chaser along the navmesh to the nearest player.",
systems: {
rules: {
chase: {
system: chaseAlongNavMesh,
replaces: behaviours.systems.chase,
description:
"Points each chaser along the navmesh's route to the nearest controlled entity, round what blocks the straight line.",
},
},
},
});

Freezing a character. A countdown, a meeting or a stun holds a character still with a system of the game’s own: a predicted system in the physics phase, before characters.systems.npcMovement, which runs there ahead of characters.systems.movement, that adds a speed modifier of more: -1 while the game’s own predicted stun trait lasts, and the room grants that trait with grantPredicted. Her page counts the stun down as the room does, so the grant reaches it as one correction and the hold makes none; gravity and a push still move her. A game that never walks its characters omits both characters.systems.npcMovement and characters.systems.movement: omitting movement alone stops only players’ characters. One whose characters move by rules of its own replaces it with a predicted entry.

definePlugin makes a plugin from its name and its parts. The following plugin adds one system to the rules of the step:

src/regeneration.plugin.ts
import { createQuery } from "koota";
import {
behaviours,
definePlugin,
HealthTrait,
updateEach,
} from "@spawnite/engine/core";
const healthy = createQuery(HealthTrait);
/** Health that comes back a point a second. */
export const regeneration = definePlugin({
name: "regeneration",
description: "Health that comes back over time.",
systems: {
rules: {
heal: {
description: "Heals everything with health up to its maximum.",
after: [behaviours.systems.capHealth],
system: (world, { deltaSeconds }) => {
updateEach(world, healthy, ([health]) => {
health.current = Math.min(
health.maximum,
health.current + deltaSeconds,
);
});
},
},
},
},
});

A plugin’s config takes the following fields, and only name is required:

  • name: lower case letters, digits and dashes, starting with a letter, as frost-nova is. The dump and the devtools list the plugin under it, and each of its systems is named plugin.system after it: regeneration.heal above.
  • description: one line on what the plugin does, which spawnite simulate, spawnite play state and the devtools’ Wiki tab print beside its name.
  • systems: the plugin’s systems under the phase each runs in. The step runs four phases in order: input reads the player, motion turns intent into movement, rules judges the result, and physics moves the characters and settles the bodies. The Phase enum holds the four. Each phase is a record, and a key is the system’s name after the plugin’s, so rules: { tendWardens } runs tendWardens as siege.tendWardens. A value is the function, or a record with the function as system and any of description, before, after, predicted, runsOn, replaces and answers. The step runs a phase’s systems in the order the keys are written. The name is the key, never the function’s own name, because a build renames a function and keeps a key, and the page and its room must name each system alike.
  • before and after: the systems of the same phase that this one runs before or after. Name another plugin’s system by its handle: behaviours.systems.capHealth for the engine’s, as above, core.systems.physics for the core’s, or siege.systems.spawn for one of a plugin of your own, since definePlugin hands back a handle per system under systems, beside its commands. Name one of the same plugin and phase by its key, as after: ["spawn"]. A handle carries its phase, so an anchor across phases, a key the phase lacks, or a name such as "behaviours.chase" fails to typecheck. Wrap a handle in anchorIfPresent where the system orders against another only when that one runs: it skips the anchor where the game lists no such plugin or omits the system. Within a phase the step runs the core’s and the engine plugins’ systems first, in the engine’s order wherever the game lists them, then each of the game’s plugins’ systems in the order of the list. An anchor moves a system no further than it must. Unity orders a system with [UpdateAfter(typeof(X))] and Bevy with .after(x), each naming the system by its type or its function rather than by a string, and the handle does the same here.
  • predicted: true for a predicted system, one that steps a character on the character’s own clock, such as a dash.
  • runsOn: where the system runs in a game with a room, as Where a system runs shows. Left out, the room alone.
  • requires: what this one needs on the same world, by handle: a plugin definePlugin made, a plugin’s factory such as weapons or rounds, or a system’s handle such as behaviours.systems.capHealth. A plugin it requires orders the plugins’ setup calls, each after the ones it requires, and leaves their systems in the order of the list: a system that must follow another plugin’s says so with after. A system it requires must run: an omit of it throws, and a replacement meets it.
  • replaces: on a system’s entry, the handle of another plugin’s system whose place it takes, as Change an engine system shows.
  • commands: the commands a page may send the plugin, each made with defineCommand, and answered by the one system that names it in answers, as Send the room a command shows. A command declared predicted: true runs on her page on the tick she sent it and on the room on the same tick, and its answering system is a predicted system, as Build a predicted mechanic shows.
  • inputs: the held inputs a page sends with every tick, each buttonInput(), axisInput(min, max), vector2Input() or angleInput(), with keys and touch where a key or a touch button holds it. A predicted system reads one with readInput(character, plugin.inputs.name), as Hold a key describes. buttonInput({ edge: true }) reads true on the tick its key went down alone, as characters.inputs.jump does, and readInputChanges(step, character, handle) lists a button’s changes within the tick in order. characters() declares the character’s own five, move, heading, pitch, strafe and jump, which its systems read. A tick of a game’s inputs packs into at most 96 bytes across its plugins, maximumInputBytes, with at most 32 buttons, and the welcome compares them, so a page and a room that lay them out apart refuse each other.
  • collisionLayers: the names of the collision layers its bodies stand in, each a handle plugin.collisionLayers.name that a body’s collisionLayer names and a collisionMask lists, as Layers describes. The core reserves two and characters() declares two, so a game’s plugins share the other 12, numbered in list order, and the welcome compares the numbering.
  • actions: the keys the page acts on and the room never hears, each pageAction({ keys }), such as a HUD’s window or a scoreboard held open: a component runs what it does with useAction(plugin.actions.name, { onPress, onRelease }). As the world is made, every key the game’s inputs, commands and actions bind is checked against the others and the engine’s, and a clash throws with the fix.
  • setup: a function of the world for the plugin’s register* calls, such as registerAbility. It runs as the world is created and again after each reset, and the function it returns undoes it.
  • onCharacterSpawn: a function of the authority a character was spawned under, the character, a PredictedEntity whose predicted traits it may add, and { player, props }, her player and the scene’s <CharacterSpawn> props, for what the plugin adds to each character, as What a plugin adds to a character shows.
  • onCharacterRespawn: a function of the same authority, the character and { player }, run as a downed character stands again, for what a plugin resets each life.
  • onPlayerJoin, onPlayerReady, onPlayerDisconnect, onPlayerReconnect and onPlayerLeave: functions of a step and a player entity, for what the plugin does as a player joins, drops, comes back and leaves, as What a plugin adds to a player shows.
  • options: the options a plugin’s factory was called with, as JSON. The dump shows them, and a page and its room compare them. A plugin that takes options makes its factory with definePluginFactory, as the engine’s own plugins do: export const karts = definePluginFactory(({ laps = 3 }: KartsOptions = {}) => definePlugin({ name: "karts", options: { laps }, ... })). The factory makes one plugin for each distinct options, so a list written again on each render, and a kit that lists it with the game’s own options, name one plugin, and it carries the plugin’s handles, karts.systems.drive, and its name, so another plugin writes requires: [karts]. Two calls name one plugin when their recorded options are the same JSON and each function and handle they were given is the same object: a handle such as a Team compares by identity, so two calls with different teams of one key are two plugins. A plugin whose options are required, as teams()’s are, passes the options its handles are read from as a second argument, definePluginFactory((options: TeamsOptions) => ..., { handlesFrom: { freeForAll: true } }), and a game’s call must then pass options of its own.
  • component: a React component Game mounts around its children, for the plugin’s own views or providers. The components nest in the order of the list, the first outermost, and each renders its children.
  • save: what the plugin keeps in the player’s save, with the fields defineSave takes but include: a version, a schema, read, restore, migrations and scope. A game’s plugin keeps it under kits.<name> in the saved record, on once the game lists the plugin. The engine’s own saves use the same field, under engine.<name>, off until the game names them in include. definePlugin throws for a save defineSave would refuse.
src/quests.plugin.ts
import { trait } from "koota";
import { definePlugin } from "@spawnite/engine/core";
import { z } from "@spawnite/schema";
/** The quests a character has finished, by id. */
export const QuestsTrait = trait(() => ({ done: new Set<string>() }));
export const quests = definePlugin({
name: "quests",
save: {
version: 1,
// Kept on each saved entity, her character under `main`.
scope: "entity",
schema: z.object({ done: z.array(z.string()) }),
// An array, since JSON keeps no Set.
read: ({ entity }) => ({
done: [...(entity.get(QuestsTrait)?.done ?? [])],
}),
restore: ({ entity }, saved) => {
const done = new Set(saved.done);
if (entity.has(QuestsTrait)) entity.set(QuestsTrait, { done });
else entity.add(QuestsTrait({ done }));
},
},
});

The world’s creation checks the list and throws on each of the following, with a message that names the fix:

  • Two plugins with one name, or a plugin named core. The same object listed twice counts once, so a plugin that a kit requires and the game also lists is fine, and so does an engine plugin’s factory called twice with the same options; with different options it throws. A plugin of the game’s own may take an engine plugin’s name, as a kit copied from that plugin’s source does, and then stands in for it.
  • A plugin whose requires names one the list lacks, such as abilities() without weapons(), and plugins that require each other in a cycle.
  • An anchor to a system that does not run, of a plugin the game does not list or one the list omits. The message names the plugin and the game’s list, such as spell.tick runs before weapons.flyProjectiles, but the game lists no plugin named weapons: it lists siege, spell. Add weapons() to plugins, or anchor with anchorIfPresent(weapons.systems.flyProjectiles) where spell.tick orders against it only when it runs. A plugin that runs with or without another, and must sit on one side of it when both run, wraps the handle in anchorIfPresent, as rounds() scores before npcs()’s conversations with before: [anchorIfPresent(npcs.systems.converse)].
  • An anchor to a system the listed plugin of that name has not got, as when a factory’s options left the system out. The message lists the systems the plugin has.
  • An omit or a replacement that cannot hold: one of a plugin the game does not list, of a system the plugin has not got, of the core’s, one system both omitted and replaced, two replacements of one system, a replacement of another kind, and replacements that loop.
  • An anchor to a system of another phase, and systems that anchor each other in a cycle. A plugin written in TypeScript meets the phase as a type error first; definePlugin throws for it, for a name in place of a handle and for a key its phase lacks, in a plugin written in JavaScript.

A plugin’s onCharacterSpawn runs each time a character spawns: when the characters plugin spawns a player’s, in a room and in a game played alone alike, when a game calls spawnCharacter, and when a headless game places one. A page in a room runs none: it spawns no character, and the room’s stream brings hers with what the room’s hooks added, so a hook run there again would write its defaults over what the room sent, such as a resource she has spent. The hook takes the authority she was spawned under, her entity, as a PredictedEntity, and { player, props }: her player, null for a character no player plays, and the props of the scene’s <CharacterSpawn> with what spawnCharacter laid over them, so one function gives her the same traits wherever she spawns:

src/siege/siege.plugin.ts
import { definePlugin, defineTrait, weapons } from "@spawnite/engine/core";
export const WardenTrait = defineTrait("warden", { class: "scout" });
declare module "@spawnite/engine" {
interface CharacterSpawnProps {
/** The class each warden starts as. */
wardenClass?: "scout" | "sapper";
}
}
export const siegePlugin = definePlugin({
name: "siege",
requires: [weapons],
onCharacterSpawn: (
_step,
character,
{ props: { wardenClass = "scout" } },
) => {
character.add(WardenTrait({ class: wardenClass }));
},
});

The scene then writes <CharacterSpawn weapons={["rail"]} wardenClass="sapper" />. The declare module block types the plugin’s own prop, on the Player and in the hook alike.

The hook runs once per spawned entity, once the character holds her base traits: her transform, her health, her stats and her respawn. It runs before her save restores onto her, so a save’s value wins over what the hook set. A respawn stands the same entity again and runs onCharacterRespawn(step, character, { player }) instead. The hooks run in the order of requires, each after the plugins it names, and otherwise in the order of the list. In a room, the room keeps the props the scene’s Player was given and hands them to the hooks for every player who joins. The hooks get every prop a room’s checkpoint can keep: each but a function, such as children or material, and a body handed to avatar as itself rather than by name. Keep a plugin’s own prop to data, a number, a string, a list or a record, so a room’s checkpoint can keep it.

The engine’s own plugins give a character her kit the same way: weapons() reads weapons, abilities() reads abilities, and inventory() reads bagSlots. A game that leaves a plugin out of its list gets none of what that plugin adds: with no inventory(), a character has no bag. Unreal’s Game Features add components to an actor class as a feature activates, and Roblox raises CharacterAdded for a script to add to each character.

A player is an entity the core makes as she joins: a room makes one for each connection, and a game played alone makes one for its player once her save has loaded. It holds who she is and whether her connection holds, in traits a game reads and never writes: PlayerTrait holds her account, her name, her seat, her role and her avatar, and ConnectionTrait says connected, or held with the tick her hold ends on. Read them with readTrait(player, PlayerTrait); koota’s own set, add and remove refuse them by type, and a development world throws on a write that slips past the type. A game adds whatever belongs to the person rather than to her character: a score, a team, a hand of cards. readPlayers(world) lists every player, as Roblox’s Players:GetPlayers() does.

Each hook runs on the room’s tick, or as a game played alone starts, in the order of requires and otherwise of the list. The join and ready hooks receive a JoinStep, and the others the room’s step:

  • onPlayerJoin(step, player, { returning }) runs once her player entity is made and before her save restores, so what it adds is a default her save overwrites. returning says her account held a seat in this room earlier.
  • onPlayerReady(step, player) runs once her save has restored, so it reads what she saved. The engine’s characters plugin spawns her character here, so a saved choice can pick what she spawns as.
  • onPlayerDisconnect(step, player) runs as her connection drops and the room starts holding her seat; onPlayerReconnect(step, player) runs as she takes it back within the hold.
  • onPlayerLeave(step, player, { reason }) runs before the core destroys her player entity, in the reverse order. A destroy inside a leave hook waits until every leave hook has run and her save has read her record, so a game’s hook reads her character though the characters plugin asked to destroy it, and what a hook hands on as she goes, as a survival game drops her bag as loot, is not in her save: LeaveReason.Left for a player who quit, HoldEnded for one whose connection did not come back, Kicked, or RoomClosing.

A JoinStep reaches the joining player and what her join spawns, and nothing else: spawn(step, prefab) takes it, and so does every call that takes an EntityAuthority, such as destroy, attach and emitEvent for an event on one entity, for her or an entity her join spawned. A call that takes an Authority, such as kick or dealDamage, fails to typecheck, a call on any other entity throws, and while her join runs every other authority over the world is refused where it is used, one from requireAuthority(step.world) or one kept from before among them. She adds her own traits with player.add. A join hook that throws refuses her join: a development room throws at once, and a room the platform hosts takes out every entity her hooks spawned and every event they emitted, frees her seat and closes her page naming the plugin, so the world is as it was before she joined. Change the rest of the world from a system, whose step reaches it, once her join has run. A drop, rejoin or leave hook that throws is logged, and the room goes on. kick(step, player, { message }) removes a player as the step ends, her save stored first, and her page shows the message:

packages/engine/test/outside/scoreboard.ts
import {
defineTrait,
definePlugin,
kick,
LeaveReason,
readPlayers,
readTrait,
PlayerTrait,
type AuthoritativeSystem,
} from "@spawnite/engine";
/** A player's points this round: on her player entity, so a respawn or a
* card game with no character keeps them. */
export const ScoreTrait = defineTrait("score", { points: 0 });
/** Points a player reaches to win the round, which ends it for everyone. */
const winningPoints = 10;
/** Ends the round once a player reaches `winningPoints`: every player is
* kicked with the winner's name. */
const endRound: AuthoritativeSystem = (world, step) => {
const players = readPlayers(world);
const winner = players.find(
(player) => (player.get(ScoreTrait)?.points ?? 0) >= winningPoints,
);
if (!winner) return;
const name = readTrait(winner, PlayerTrait)?.name ?? "Someone";
for (const player of players)
kick(step, player, { message: `${name} won the round.` });
};
/** The names of the players who quit, rather than lost their connection. */
export const quitters: string[] = [];
export const scoreboard = definePlugin({
name: "scoreboard",
description: "A score on each player, and the round's end at 10 points.",
onPlayerJoin: (_step, player) => player.add(ScoreTrait),
onPlayerLeave: (_step, player, { reason }) => {
if (reason === LeaveReason.Left)
quitters.push(readTrait(player, PlayerTrait)?.name ?? "");
},
systems: { rules: { endRound } },
});

A game whose players control something other than a character, or nothing, such as a card game, lists no characters(): a room then spawns no character for a player who joins, and her player entity is all she has. A game that spawns its players’ characters itself, later, lists characters({ autoSpawn: false }), so its characters still move. A game played alone spawns its character from its <CharacterSpawn> either way. A test adds a player with addPlayer(world, { name }), and drops, takes back and removes her with dropPlayer, rejoinPlayer and removePlayer, each running the same hooks. Roblox raises PlayerAdded and PlayerRemoving for a script, and Unreal’s game mode calls PostLogin and Logout; here the player is an entity, so a game adds traits to her rather than subclassing, and a scoreboard is a query.

A room loads a scene’s file and nothing else of the game, so each scene’s file re-exports the list: export { plugins } from "../game";. A game with rooms re-exports its room settings the same way, export { plugins, room } from "../game";, where src/game.ts declares them with defineRoom, as Set the room’s players and send rate says. A game that changes its physics scene declares it there too, export const physics = definePhysics({ gravity: [0, -20, 0], solverIterations: 8, destroyBelowHeight: -100 });, passes it to <Game physics>, and re-exports it beside the plugins, export { plugins, physics } from "../game";. Each setting left out takes its default: gravity 9.8 m/s² down, Rapier’s 4 solver passes a tick, and a dynamic body destroyed below -500 m, null to keep every one. A page and its room compare the settings at the welcome and refuse each other when they differ. A dynamic body that falls below destroyBelowHeight emits FellOutOfWorldEvent on its entity, with the height it fell to, and the next physics step destroys it unless a system has put its body back above the height with teleport. spawnite simulate reads that export first, then src/game.ts itself. A room and spawnite simulate refuse a scene’s file that exports systems, with an error that says how to move those systems into a plugin.

The plugins a world was made from are data an agent reads, the engine’s first, each with its name, description, requires, options, systems, isEngine and omitted, the systems the list omits. So is every system of its step, the core’s among them, in the order the step runs them, each with its name, plugin, phase, runsOn, predicted, isEngine, and replaces, the system whose place it took, where it took one:

  • spawnite simulate and the MCP’s simulate print the plugins under the hash, each marked as the engine’s or the game’s with what the list omits, then the systems, one line per phase, a replacement as nav-chase.chase (replaces behaviours.chase). The dump holds them as plugins and systems. The hash covers the entities alone. A plugin is marked the engine’s, isEngine, when it is one of the engine’s closed plugins: the readable rounds() is built from the public API as a game’s plugin is, so it prints as rounds (game), as a kit copied from it would.
  • spawnite play state and the MCP’s read_engine_state print both for a running page, and describe_entity returns the plugins beside an entity’s traits.
  • The devtools’ Wiki tab lists each system under its plugin, with the phase it runs in, the system it replaces, and each plugin’s omitted systems.

The example game lists characters(), stats(), cooldowns(), resources(), behaviours(), machines(), inventory(), rounds() and its own runs and kicks, and spawnite simulate --scene run --seconds 1 prints its step as follows:

Systems by phase, in the order the step runs them:
input: core.copyInput, characters.walk
motion: stats.expireModifiers, stats.copyFields, core.move, core.blendCorrections
rules: core.advanceDayClock, cooldowns.countDown, inventory.takeItemCommands, inventory.useItems, behaviours.spin, behaviours.bob, behaviours.pickup, inventory.loot, behaviours.interact, behaviours.takeInteracts, behaviours.trigger, behaviours.capHealth, behaviours.reapDead, behaviours.respawnDowned, characters.respawn, behaviours.chase, resources.regenerate, rounds.takeRequests, rounds.advance, machines.advance, kicks.kick, runs.countRunsWon
physics: characters.aim, characters.npcMovement, characters.movement, characters.countDown, core.physics, core.follow, core.restColliders

A page that joins the room of a game on plugins checks its list against the room’s. The room’s welcome names the plugins it lists, the systems its step runs, in order, and each plugin’s options, so an omit or a replacement on one side alone changes the names it compares. A page whose own step differs throws an error that prints both lists, since a page and a room that run different systems part on the first step either runs alone.

Bevy groups an app’s systems into a Plugin the app adds, and Unreal’s game feature plugins add abilities and components to the game that turns them on. Here the list is one array in one file, because a room, a headless run and an agent each read it without the page. The packages design page holds the reasoning and what the other engines do.