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

Switching a system on

This page continues Packages and kits. It describes how a game switches on the systems that the engine and its kits ship.

A game switches a system on by listing a plugin. A plugin is one object that carries everything a system adds to a world: its systems by phase, the messages a page may send, its save key, what it registers on the world, and what it adds to a character at her spawn. The engine’s own systems and a game’s own are plugins alike, and so is one spell: any addition to a game is a plugin, from the whole ability pipeline to one frost nova, as Bevy’s game is plugins and Lyra’s Game Features carry abilities. A kit is a folder of plugins plus their looks. The maintainer decided the mechanism, the names and the shapes below on 2026-09-30.

definePlugin makes one. It checks what one plugin can know at the call, a name, known phases, one save key, no two systems under one name, and returns a branded Plugin, so Game and createGameWorld accept only what it made and a hand-written object fails to typecheck. What needs the whole list, a missing requires, an anchor, a cycle, a clash between plugins, the composer checks as the world is made. The engine’s own plugins are functions that take options, because a plugin has settings; a game’s own is a constant. The following sketch is the shape the first layer builds, with the names it introduces:

/** One slot of the fixed step, in the order the step runs them. */
export enum Phase {
/** Reads the player: the input copy, the approach, the steer. */
Input = "input",
/** Turns intent into motion: the stats, the velocities, the tracks. */
Motion = "motion",
/** Judges the result: items, behaviours, casts, projectiles, the round. */
Rules = "rules",
/** Settles the bodies against the physics world. */
Physics = "physics",
}
/** A system with what the composer needs beyond the function. */
export interface PluginSystem {
system: System;
description?: string;
/** Systems of the same phase this one runs before or after: another
* plugin's by its handle, `characters.systems.movement`, typed by its
* phase, or one of the same plugin and phase by its key. */
before?: readonly (SystemHandle | string)[];
after?: readonly (SystemHandle | string)[];
/** Where it runs; `Server` when left out: the authoritative world, a
* room's or a game played alone. A page fed from a room skips it, as
* the runner skips a server behaviour. `Client` is for presentation
* and prediction, never a second copy of a consequence. A character's
* system runs on both and declares none. */
runsOn?: RunContext;
/** Whether it steps a character on her own clock, a dash or a knockback,
* and so runs on her page's replay too, where `step.rerunEntities`
* names her set. A reaction to an event never sets it. */
character?: boolean;
}
/** Each phase's systems by name, in the order written: a function, or
* one with more. The key is the name after the plugin's. */
export type PluginSystems = Partial<
Record<`${Phase}`, Readonly<Record<string, System | PluginSystem>>>
>;
export interface PluginConfig<
Commands extends CommandDeclarations = NoCommands,
> {
/** `"abilities"`, `"siege"`, `"frost-nova"`: the dump lists it, and
* every system of the plugin is named `plugin.system` after it. */
name: string;
description?: string;
/** Plugins this one needs on the same world, by name. */
requires?: readonly string[];
/** The factory's options, normalized to JSON: what the welcome
* compares and the dump shows. A plugin with no options leaves it out. */
options?: JsonValue;
systems?: PluginSystems;
/** Commands a page may send, each made with `defineCommand(fields,
* options)` and answered by the one system that names it in
* `answers`; registered as `plugin.command`. The commands page,
* /platform/commands/, holds their whole shape. */
commands?: Commands;
/** What it keeps under its name in the save: a version, a schema,
* `read` and `restore` of the world, the character and the player, numbered
* `migrations`, and a scope, player or world. */
save?: PluginSave;
/** Per-world registrations, the `register*` calls: an ability, a
* resource, a machine's traits. A `define*` call stays at module scope. */
setup?: (world: World) => Disposer | void;
/** What it adds to a character as she appears, on the page, in the room and
* in the harness, from the scene's Player props. */
onCharacterSpawn?: (
character: PredictedEntity,
props: CharacterSpawnProps,
) => void;
/** Its React: default views, context and hooks' providers. Game
* mounts it around the scene on the page; headless it renders
* nothing. */
component?: ComponentType<{ children?: ReactNode }>;
}
export interface Plugin<
Commands extends CommandDeclarations = CommandDeclarations,
> extends Omit<PluginConfig<Commands>, "commands"> {
readonly [pluginBrand]: true;
/** One typed handle per declared command, `siege.commands.pick`,
* which `sendCommand` and `readCommands` take. */
readonly commands: {
readonly [Key in keyof Commands]: CommandHandleOf<Commands[Key]>;
};
}
/** Generic over the commands, so `siege.commands.pick` is typed by its
* payload and `siege.commands.unknown` fails to typecheck. */
export function definePlugin<
const Commands extends CommandDeclarations = NoCommands,
>(config: PluginConfig<Commands>): Plugin<Commands>;
/** What `Game` and `createGameWorld` take: plugins, nested lists of them,
* and falsy entries, flattened as Vite flattens its plugins. */
export type PluginList = readonly (Plugin | PluginList | false | undefined)[];
// The engine's own, each on its own entry point, each command typed as a
// game's own: `abilities().commands.typo` fails to typecheck.
export function abilities(
options?: AbilitiesOptions,
): Plugin<{ cast: CastDeclaration }>; // { globalCooldownSeconds, castsPerSecond, views: { castBar, targetFrame, damageNumbers } }
export function weapons(options?: WeaponsOptions): Plugin<NoCommands>;
export function npcs(): Plugin<{ choose: ChooseDeclaration }>;
export function inventory(options?: InventoryOptions): Plugin<NoCommands>;
export function tracks(): Plugin<NoCommands>;
export function rounds(): Plugin<NoCommands>;

A game’s own systems are the same unit, and so is one spell:

src/siege/plugin.ts
export const siege = definePlugin({
name: "siege",
requires: ["weapons"],
commands: {
ready: defineCommand({}),
pick: defineCommand({ slot: integer(1, 3) }),
buy: defineCommand({ gun: oneOf(GunId) }),
},
systems: {
rules: {
adoptWardens: {
system: adoptWardens,
before: [cooldowns.systems.countDown],
description: "Makes each new character a warden.",
},
gatherStandingWardens,
readSignals,
advanceSiege,
tendWardens: { system: tendWardens, runsOn: RunContext.Server },
},
},
onCharacterSpawn: (_step, character) => character.add(WardenTrait),
});
// src/spells/frostNova.ts, beside its look component
export const frostNova = definePlugin({
name: "frost-nova",
requires: ["abilities"],
setup: (world) =>
registerAbility(world, "frost-nova", {
kind: AbilityKind.Area,
radius: 6,
castSeconds: 0,
cooldownSeconds: 12,
cost: { [mana]: 25 },
damage: { min: 25, max: 30 },
slow: { percent: 50, seconds: 3 },
}),
});

A plugin is data, not a builder. The engine keeps builders where a thing is a sequence or a tree authored stepwise and the chain’s type guides the next call, dialog(name).node().start() and routines(name).routine().on().start(), and data where a thing is a record: a plugin, an ability, an item, a level, a machine’s config. A record as one literal is read whole, checked as written by TypeScript’s excess-property check, emitted by the MCP’s add_* tools and read back by the dump, and it has half the surface of a method per field. What a builder adds, discovery by autocomplete and conditional construction, plain JavaScript gives a literal through spreads and the flattening above.

The list lives in src/game.ts, which exports plugins. The page passes it to <Game plugins={plugins}>, and the scene file re-exports it beside its component, export { plugins } from "../game", where the room and spawnite simulate read it. The room’s welcome carries the composed step, the listed plugins’ names, the ordered plugin.system names and each plugin’s options, which its factory writes as JSON, beside the game’s build id, and a page whose composition differs throws with both lists, so the two cannot differ, and a conditional entry or an option that differs between the page’s build and the room’s is caught, not only a missing name. A Game with no plugins prop runs the core and nothing else, and every engine plugin is one a game lists, as the composition step decides: every template writes the line its genre needs, so the list a game reads is the list that runs, and no default hides behind a bare <Game>. baseSystems, nameSystem and the systems prop go away before the tag: one way to add a system, and the flat list stays reachable as the composer’s output, on the world’s SystemsTrait trait, for the profile and the dump.

A kit requires and a game lists. A kit’s plugins name abilities in requires and never construct abilities() themselves, so each engine plugin appears once, in src/game.ts, with the game’s options, and a duplicate name throws naming both lines rather than a merge deciding whose options win. The composer flattens nested lists and skips falsy entries, so plugins: [abilities(), ...mageKit, import.meta.env.DEV && debugTools] reads as an agent expects from Vite. No mergePlugins ships: Bevy’s PluginGroup.set() exists because a group constructs its members, and here the game constructs each one.

Three requirements hold whatever the game does:

  • A call into a plugin that is off throws naming the line. Each plugin’s public functions check the world holds the plugin, and the error says what to add and where: “abilities are off in this world: add abilities() to plugins in src/game.ts”. A function that takes only an entity, grantAbilities(character, ...), finds its world through the engine’s findEntityWorld, which reads the world koota packs into an entity’s id, so no signature changes for the check. A message a page sends for a plugin that is off is refused the same way, since the plugin never registered it.
  • The room switches on the same set as the page. The one exported list and the welcome check above.
  • A creator with no code never writes the line. Each template lists the plugins its genre uses, and the MCP’s add_npc, add_item and add_dialog add the plugin a new file needs to the list when it is missing, as a layer says.

Every system is named plugin.system, the engine’s and a game’s alike: abilities.cast, siege.adoptWardens. The name is the key the plugin lists the system under, rules: { adoptWardens }, as Pinia keys a store’s actions and XState a machine’s: a build renames a function and keeps a key, so the page and its room name each system alike. runsOn on an entry lands with the doors. The core’s systems are core.*, such as core.move and core.physics, and each engine plugin’s carry its name, such as characters.movement, stats.expireModifiers and cooldowns.countDown. The phase is in no name: it is a field on the entry and a heading in the devtools. So behaviours.castAbilities becomes abilities.cast under the Rules heading, and a game’s siege.advanceSiege keeps its name. A message registers as plugin.message, so the engine’s engine.cast becomes abilities.cast and holdfast’s ready becomes siege.ready, and two plugins cannot register one name.

A command is declared with defineCommand by its payload’s fields, pick: defineCommand({ slot: integer(1, 3) }), with the helpers integer, number, boolean, oneOf and the entity kinds, no payload as {}, and four a second by default. The plugin registers it as plugin.command and exposes a typed handle, siege.commands.pick, which the sender and the reader use, so the name is written once and the payload is typed at both ends: sendCommand(world, siege.commands.pick, { slot: 2 }) from a screen, which takes the world because it delivers to the world it owns, or sends to the room the world is fed from, and readCommands(step, siege.commands.pick) in the one system that names it in answers, at the position the plugin chooses. definePlugin is generic over the declared commands, so each handle’s payload type is its declaration’s and a command the plugin never declared fails to typecheck. A reaction to a command is a system like a reaction to an event, and no handler per command exists: Roblox’s OnServerEvent and Godot’s @rpc give each message a function, and here that would be a second mechanism whose position in the step the plugin could not move. Holdfast’s three pick-1, pick-2 and pick-3 signals are one command with a slot. Commands holds the whole shape.

The dump and describe_entity list the world’s plugins with their names and descriptions, the devtools’ wiki tab shows each phase’s systems grouped by plugin, and the step’s profile names each line plugin.system.

Two verbs say where a thing is registered, and the maintainer decided them on 2026-09-30. define* describes a thing once, at module scope, when its file is imported, and returns it: definePlugin, defineLevels, defineBehaviour, which replaces the Behaviour annotation plus registerBehaviour, and defineTrait(name, schema, options) and defineEvent, which make koota’s trait and register it for the dump, the devtools and the stream in one call, with the doors’ ownerOnly, serverOnly and entities as the options, so no trait goes unregistered the way InteractedEvent did. A resource is defined, defineResource, and so is a reservation’s key, defineReservation. register*(world, ...) adds to one world and returns a Disposer: registerAbility, registerHealth. Koota’s bare trait() stays for scratch state nobody dumps. Every trait’s name ends in Trait, ControlledTrait, CastingTrait, WardenTrait, which a lint enforces on an export made by defineTrait, defineEvent or trait(), and the layer that moves the engine’s systems into plugins renames the engine’s own. A trait is koota’s word for a piece of state on an entity, and the engine keeps koota’s word rather than an alias, because a game imports trait, createQuery and useQuery from koota and reads its types and errors, where a second name for one thing would cost an agent a translation. The ECS word, component, is taken in a React game by <CharacterSpawn> and <Npc>.

A plugin is data for the step and React for everything a player sees, and the two halves meet through the world: the plugin’s systems write traits, and its components and hooks read them, as useCast reads the casting trait today. A plugin may carry a component, and Game nests the plugin components in list order around all of its children, the scenes and the HUD alike, inside the world’s context and under the loading screen, which stays Game’s own, so a provider covers a HUD as it covers a scene. A plugin’s component always renders its children; headless, its views render nothing and the children still mount, so a room’s scene is never swallowed. Simulation-side setup belongs to the world’s lifetime, never to the component’s effects, so StrictMode’s double mount changes nothing in the step. A plugin’s React can then wrap, provide context, mount its default views and portal into the HUD, and its hooks read it below. abilities() brings Targeting, SpellLook, the cast bar, the target frame and the damage numbers that way; a game switches one off with abilities({ views: { castBar: false } }) and mounts its own in the scene. Headless, a plugin’s component renders nothing, as every view does. A plugin’s hooks and components are named exports beside its factory, abilities, useCast, useTarget, useCooldown, Targeting, SpellLook, as drei exports hooks beside components, and a kit’s plugin ships its own the same way.

React’s hierarchy keeps what it carries today: composition, context, lifetime and colocation. A behaviour component configures one entity as a child, <Entity name="Wolf"><Health maximum={60} /><Flinch clip="hit-left" /></Entity>, and its system runs once for every entity that carries the trait. The plugin list carries none of that, because the simulation has no tree: the step is a flat ordered list of systems the room runs with no React, and createHeadlessGame steps a world with no renderer. So a system is declared in a plugin and never added from a hook. A useSystem beside useFrame would make the set of systems a function of what mounted: unreadable from a file, so the dump, spawnite simulate and the welcome check could not know it; absent in a harness that mounts nothing; and ordered by mount order until anchors fix it, so the anchors are needed anyway. What a hook would give, a system that lives while a component is mounted, a guard in the system gives, as the round’s does today. useFrame stays for drawing.

Mounting the registration in React was the one real cost of JSX scenes, and the plugin removes it: the mmorpg registers its mana in a layout effect so it runs before the Player’s spawn, and comments say “mount it after the Player”. With setup and onCharacterSpawn every simulation-side registration runs at the world’s creation and at the character’s spawn, in one order on the page and in the room, and React composes the scene and draws. Describing entities as React components was not a mistake: React is the tree language every web agent reads and writes, React Three Fiber is how three.js is done in React, the MCP’s tools write TSX, and the devtools’ tree mirrors the React tree. The alternative, a scene as data with views bound by trait, as Godot, Unity, Roblox and Bevy do, stays open for what the editor makes, maps and their props, and nothing in this shape blocks it.

A plugin file is self-contained and named for what it is: flinch.plugin.tsx holds the trait, the component, the system and the definePlugin call, as *.test.ts and *.stories.tsx name their kind here and NestJS names *.module.ts. A human sees it in the tree, a tool finds it by name, and the dump’s source link points at it. Folders say the kind, each a pool any plugin may draw from: spells/, classes/, hud/, behaviours/, monsters/, npcs/. A class lists its spells, and a second class lists the same file:

src/
game.ts plugins = [abilities(), weapons(), npcs(), ...mage, flinch]
spells/
frostNova.plugin.tsx any class may list it; its look beside the plugin
arcaneBolt.plugin.tsx
charge.plugin.tsx
classes/
mage.plugin.tsx mage = [frostNova, arcaneBolt, moonbeam, mageHud]
warrior.plugin.tsx warrior = [charge, shieldWall, warriorHud]
hud/
spellBar.plugin.tsx the class's layout of its spells
behaviours/
flinch.plugin.tsx
monsters/wolves.tsx npcs/mira.tsx scenes/meadow.tsx maps/

A kit is a manifest over that pool, not a folder: it names its files, the engine plugins it requires and the kits it requires, and spawnite add kit mage writes the files and pulls spell-bar once, as shadcn’s dialog pulls button through registryDependencies. A path belongs to one kit: the registry gives each file one identity, refuses two kits that carry different files at one path, and a file two kits share is one kit the others require, so add_kit never overwrites one kit’s file with another’s; the lock records the owning kit per path. A manifest covers its import closure, through its own files or the kits it requires, so a class that lists a spell another kit owns names that kit, and add_kit typechecks the game after writing and reports an import it broke. A class group lists what is the class’s own, and a game lists a shared plugin once; a repeat of the same object is folded, as the lifecycle says. “Just the spells” is spawnite add kit mage-spells, and a variant is another kit in the registry or an agent’s edit of the copy. The node analogy holds for dependencies and breaks on versions, on purpose: a kit is copied, the registry resolves its dependencies at add time, and nothing is resolved at build time. The kit pipeline’s manifest grows those two fields. After adding, the files are the game’s, and no folder says which kit they came with, as in shadcn. The templates and the MCP’s add_behaviour, add_npc, add_item and add_kit write this shape, so an agent meets one layout in every game.

Two words name what the registry writes into a game, and they keep their plain meanings. A template is a shape with a placeholder that a tool fills: a game template new_game starts a project from, and a blank add_behaviour or add_npc writes with Feature renamed to the name it was given. A kit is finished content copied as it is, one file or many, with dependencies: the mage spells, the spell bar, the shooter’s arsenal. One tool lists both, list_registry, in three sections, game templates, blanks by the tool that writes them, and kits, each entry with its name, description and what it requires; it replaces list_templates, whose “features” named nothing. An agent calls it once before it writes a new file, and “add a fireball” leads to abilities(), the mage spells and the abilities page rather than to a blank. search_wiki finds a kit’s page as it finds a behaviour’s, and the dump lists a world’s plugins with their descriptions. Granular kits are what make the match work: a kit the size of one feature, mage-spells or spell-bar, is one an agent can match to a request, where a kit the size of a class is not.

A copy that is behind updates without the creator. add_kit writes a lock entry per kit, its name, the registry version it was copied at and its files’ hashes. When the MCP server or spawnite starts it reads the registry once, as list_registry does, compares the lock, and writes one line into the instructions the agent reads at session start: which kits are behind and which files changed. Nothing checks per tool call, the check is cached as npm’s update notice is, and offline it is skipped; spawnite check repeats it before a publish. The agent then runs spawnite update kits, a three-way merge per file with the copied version as the base, the game’s edit as ours and the registry’s new version as theirs, as git merge-file does; a line the game never touched takes the fix silently, and a conflict is the agent’s to resolve, which it reads well. The registry keeps every version it served, so the base is always there. Re-adding a kit takes the same path: the same version is a no-op, a newer one merges, and the lock’s base advances only once every conflict is resolved, so a re-add never overwrites an edit. Whether a change was a bug fix or a look is nothing the agent has to know: the update applies both, and the registry entry’s one-line changelog lets the agent say what changed. No kit ships on npm: a package cannot be edited in place, and spells and a HUD’s layout are exactly what a game edits, so every kit would be ejected on its first day, as Unity’s embedded packages are, for two states and no gain.

Little logic lives in a kit, by the line: the pipeline, the shot, the bag, the runner and the shared views are engine code one release fixes for every game, and a kit holds spells, looks, a class’s layout and a game’s scripts, which a game changes anyway. A fix to a kit reaches a game when its agent re-adds the kit and reads the diff, and reaches players when the creator publishes; an engine fix reaches a published game through the shared engine its version resolves to, with no republish. That asymmetry is why anything with a bug worth fixing once sits on the engine side of the line, and why a kit is kept in the game it came from, where the game’s typecheck and lint check it against every engine release.

A plugin puts each system under one phase, and names the systems it must run before or after by their handles, as Bevy’s .before() and .after() and Unity Entities’ [UpdateBefore] and [UpdateAfter] do: another plugin’s as characters.systems.movement or siege.systems.spawn, typed by its phase, and one of the same plugin and phase by its key. Within a phase the composer sorts by those anchors and keeps the listed order where nothing constrains it, the engine’s systems before a game’s. The engine’s own plugins carry the orderings the step’s comments state today, so abilities.cast stays before weapons.flyProjectiles when a game lists weapons() before abilities(). An anchor that names a system of a plugin the game did not list throws, naming the plugin and the game’s list, unless it is wrapped in anchorIfPresent, for a plugin that orders itself around one the game may leave out; an anchor that names a system a listed plugin does not have throws too. The composer keeps character through its wrappers, so a game’s dash still runs on her replay. Depthfield’s dash is input: { takeDashRequest: { system: takeDashRequest, after: [core.systems.copyInput] } }, in the input phase, which runs before characters.systems.movement in the physics phase.

The other engines order by name or by position. Bevy, Unity Entities and Unreal’s tick prerequisites name the system to run before or after; Roblox’s BindToRenderStep takes a number and breaks ties at random; Godot orders autoloads top to bottom and nodes by process_priority; PlayCanvas and Phaser run in list order; Vite sorts its array into pre, normal and post buckets. This platform names the anchor, because a name is one value an agent reads in the step’s profile and writes in the plugin, and a position is an order it has to reason about across every plugin in the list.

The four phases take the place of the step’s four places, and Phase.Motion replaces move and Phase.Rules replaces behaviours, because a phase is one part of a step in plain words, as Unity’s PlayerLoop and Unreal’s tick groups are documented, and because “behaviours” named one system in the slot rather than the slot. Update, the word Unity, Unreal, Bevy and Phaser use for the middle, is not used: there it is where all game logic goes, and here that is Rules.

createGameWorld(plugins) composes once, as the world is made, and the following rules hold for every plugin:

  • A dependency is declared. requires names plugins by name. A missing one throws at the world’s creation, naming the plugin and the line to add: “abilities() needs weapons(): add weapons() to plugins before it”. Nothing is added on a game’s behalf, so the list a game reads is the list that runs. A cycle, health requires weapons and weapons requires health, throws at the world’s creation naming the chain, because the dependency order is the order of setup and onCharacterSpawn, and a cycle has none. Two plugins that react to each other do it through the traits and events each writes on the world, which a system reads whether or not the plugin that writes them is on, so neither needs the other in requires. Unreal and Unity declare dependencies in a manifest and refuse a cycle at build time; Bevy checks is_plugin_added at runtime; the others have nothing.
  • A duplicate throws, a repeat is folded. The same Plugin object reached twice, as when two class groups both list castBar, is composed once, since it is one plugin. Two different objects under one name throw naming both entries, as Bevy panics on a plugin added twice. Phaser, PlayCanvas and Godot warn and ignore the second, which hides the mistake; Vite runs both copies, which doubles the work.
  • A conflict throws naming both. Two plugins registering one command name, one save key, one system name or one trait dump name throw naming the two plugins, so a game’s respawn can no longer shadow the engine’s.
  • A cycle throws naming the chain. Anchors that form a cycle within a phase throw with the systems in the loop.
  • Start, reset and stop. At the world’s creation the composer adds the PluginsTrait and SystemsTrait traits, registers each plugin’s commands, and calls each setup in dependency order, keeping the disposers. A reset runs the disposers first, then koota’s reset, then lays the building blocks’ subscriptions and query tracking again as the world does today, then the plugins’ traits, commands and setups in the same order, all before any entity spawns. destroy runs the disposers. A plugin keeps its per-world state on world traits, such as the command and ability registries, so a server runs one world per room in one process and a test makes one per case, as they do today. A definition that is per process, an item, a behaviour, a machine, is declared at module scope when its file is imported, as registerItem and defineBehaviour are called today, never in setup: a world’s disposer must not remove a definition another world still uses, and two different definitions under one name throw. The dump shows a world only what its listed plugins own.
  • A character’s spawn. Wherever a character appears, on the page, in the room and in the harness, the engine calls every plugin’s onCharacterSpawn in dependency order once she holds her base traits and before any save is restored onto her, with the scene’s Player props, which each plugin types by augmenting CharacterSpawnProps as the camera design augments its element’s props. The room’s CharacterSpawnTrait keeps the whole props record the mounted Player wrote, the engine’s fields and each plugin’s, rather than a fixed list of fields, and the harness’s placeCharacter passes an empty record, so the three spawn paths run one code. So <CharacterSpawn abilities={["bolt"]} weapons={["rail"]}> stays as it is, and a registration no longer waits on a React effect’s order, as the mmorpg’s mana does today. Unreal’s Game Features add components to an actor class when the feature activates; Roblox raises CharacterAdded.