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:
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:
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 plugins
Section titled “The engine’s plugins”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, andwalkTo.stats():addStatModifier,removeStatModifiers,setStatBaseand<Stats>.cooldowns():startCooldown.resources():changeResource,declareResource,registerHealthand<Resource>, and a plugin’sresourcesas the world is made.machines(): a machine’s record added to an entity.behaviours():dealDamage,dealHealingand each of its components, such as<Spin>,<Health>and<Chase>.inventory():equipItem,activateItem,<Inventory>,<Loot>and<Lock>.weapons(),npcs(),tracks()androunds():registerWeapon,registerShotSettings,spawnNpc,launchTrackMover,<TrackMover>,startRound,finishRoundandfailRound.- A
<CharacterSpawn>prop no listed plugin reads, such asweaponswithoutweapons()orbagSlotswithoutinventory().
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.
Readable plugins
Section titled “Readable plugins”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.
Plugins and kits
Section titled “Plugins and kits”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.
Change an engine system
Section titled “Change an engine system”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 withoutanchorIfPresent, throws as the world is made, naming both.replaces: handleon 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 markedpredicted: true, and an unpredicted one only by an unpredicted entry with the samerunsOn, 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.systemsand 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:
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.
A game’s own plugin
Section titled “A game’s own plugin”definePlugin makes a plugin from its name and its parts. The following plugin adds one system to the rules of the step:
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, asfrost-novais. The dump and the devtools list the plugin under it, and each of its systems is namedplugin.systemafter it:regeneration.healabove.description: one line on what the plugin does, whichspawnite simulate,spawnite play stateand 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:inputreads the player,motionturns intent into movement,rulesjudges the result, andphysicsmoves the characters and settles the bodies. ThePhaseenum holds the four. Each phase is a record, and a key is the system’s name after the plugin’s, sorules: { tendWardens }runstendWardensassiege.tendWardens. A value is the function, or a record with the function assystemand any ofdescription,before,after,predicted,runsOn,replacesandanswers. 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.beforeandafter: the systems of the same phase that this one runs before or after. Name another plugin’s system by its handle:behaviours.systems.capHealthfor the engine’s, as above,core.systems.physicsfor the core’s, orsiege.systems.spawnfor one of a plugin of your own, sincedefinePluginhands back a handle per system undersystems, beside itscommands. Name one of the same plugin and phase by its key, asafter: ["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 inanchorIfPresentwhere 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:truefor 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 plugindefinePluginmade, a plugin’s factory such asweaponsorrounds, or a system’s handle such asbehaviours.systems.capHealth. A plugin it requires orders the plugins’setupcalls, 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 withafter. 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 withdefineCommand, and answered by the one system that names it inanswers, as Send the room a command shows. A command declaredpredicted: trueruns 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, eachbuttonInput(),axisInput(min, max),vector2Input()orangleInput(), withkeysandtouchwhere a key or a touch button holds it. A predicted system reads one withreadInput(character, plugin.inputs.name), as Hold a key describes.buttonInput({ edge: true })reads true on the tick its key went down alone, ascharacters.inputs.jumpdoes, andreadInputChanges(step, character, handle)lists a button’s changes within the tick in order.characters()declares the character’s own five,move,heading,pitch,strafeandjump, 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 handleplugin.collisionLayers.namethat a body’scollisionLayernames and acollisionMasklists, as Layers describes. The core reserves two andcharacters()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, eachpageAction({ keys }), such as a HUD’s window or a scoreboard held open: a component runs what it does withuseAction(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’sregister*calls, such asregisterAbility. 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, aPredictedEntitywhose 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,onPlayerReconnectandonPlayerLeave: 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 withdefinePluginFactory, 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 distinctoptions, 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 writesrequires: [karts]. Two calls name one plugin when their recordedoptionsare the same JSON and each function and handle they were given is the same object: a handle such as aTeamcompares by identity, so two calls with different teams of one key are two plugins. A plugin whose options are required, asteams()’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 itschildren.save: what the plugin keeps in the player’s save, with the fieldsdefineSavetakes butinclude: aversion, aschema,read,restore,migrationsandscope. A game’s plugin keeps it underkits.<name>in the saved record, on once the game lists the plugin. The engine’s own saves use the same field, underengine.<name>, off until the game names them ininclude.definePluginthrows for a savedefineSavewould refuse.
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
requiresnames one the list lacks, such asabilities()withoutweapons(), 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 inanchorIfPresent, asrounds()scores beforenpcs()’s conversations withbefore: [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;
definePluginthrows for it, for a name in place of a handle and for a key its phase lacks, in a plugin written in JavaScript.
What a plugin adds to a character
Section titled “What a plugin adds to a character”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:
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.
What a plugin adds to a player
Section titled “What a plugin adds to a player”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.returningsays 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’scharactersplugin 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. Adestroyinside 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 thecharactersplugin 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.Leftfor a player who quit,HoldEndedfor one whose connection did not come back,Kicked, orRoomClosing.
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:
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.
The list in a scene’s file
Section titled “The list in a scene’s file”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.
Reading a world’s plugins
Section titled “Reading a world’s plugins”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 simulateand the MCP’ssimulateprint 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 asnav-chase.chase (replaces behaviours.chase). The dump holds them aspluginsandsystems. 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 readablerounds()is built from the public API as a game’s plugin is, so it prints asrounds (game), as a kit copied from it would.spawnite play stateand the MCP’sread_engine_stateprint both for a running page, anddescribe_entityreturns 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.walkmotion: stats.expireModifiers, stats.copyFields, core.move, core.blendCorrectionsrules: 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.countRunsWonphysics: characters.aim, characters.npcMovement, characters.movement, characters.countDown, core.physics, core.follow, core.restCollidersA 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.