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

Write your own behaviour

A behaviour of your own has five parts: a trait that holds its state, a behaviour that names the trait, a component that adds the trait to an entity, a system that reads and writes the trait each step, and a plugin that carries the system. A view reads the trait to draw it. The following lamp has all of them: it lights while a character stands within 2 m of it, stays lit for 3 seconds after the last one leaves, then goes out.

spawnite add behaviour Glow --system writes the same parts as files of their own, to start from: the trait, the behaviour and its component in src/behaviours/Glow.tsx, a system in src/behaviours/GlowSystem.ts, and the system’s entry in src/rules.plugin.ts, the game’s own plugin. The scaffold is a starting point: for state other players see, shape it as the lamp below is, with defineTrait, the behaviour’s plugin and runsOn, and updateEach.

packages/room/test/outside/Lamp.tsx
import { createQuery, type World } from "koota";
import {
defineBehaviour,
definePlugin,
defineTrait,
findNearestControlled,
RunContext,
TransformTrait,
updateEach,
useBehaviour,
useEntity,
useTrait,
type AuthoritativeStep,
} from "@spawnite/engine";
/** A lamp's state: how near a character must stand, how long it stays lit
* after the last one leaves, whether it is lit, and the tick it goes out,
* -1 while a character stands near. */
export const LampTrait = defineTrait("lamp", {
radius: 2,
holdSeconds: 3,
isLit: false,
offTick: -1,
});
const lampEntities = createQuery(LampTrait);
/** Lights each lamp while a character stands within its radius, and puts
* it out `holdSeconds` after the last one leaves. Each field is written
* only when it changes, so a view that reads the lamp renders only then. */
function tendLamps(world: World, step: AuthoritativeStep) {
updateEach(world, lampEntities, ([lamp], entity) => {
const position = entity.get(TransformTrait)?.position;
if (!position) return;
const nearest = findNearestControlled(world, position);
if (nearest && nearest.distance <= lamp.radius) {
lamp.isLit = true;
lamp.offTick = -1;
} else if (lamp.isLit && lamp.offTick === -1) {
const holdTicks = Math.round(lamp.holdSeconds / step.deltaSeconds);
lamp.offTick = step.tick + holdTicks;
} else if (lamp.isLit && step.tick >= lamp.offTick) {
lamp.isLit = false;
lamp.offTick = -1;
}
});
}
/** The lamps: listed in the game's plugins, in src/game.ts. */
export const lamps = definePlugin({
name: "lamps",
description: "Lamps that light while a character stands near.",
systems: { rules: { tendLamps } },
});
export const LampBehaviour = defineBehaviour({
name: "lamp",
trait: LampTrait,
// Its component throws where the game does not list the plugin.
plugin: lamps.name,
// The room decides it, so its component takes no callback.
runsOn: RunContext.Server,
description: "Lights while a character stands near, and for a while after.",
ranges: {
radius: { min: 0.5, max: 10, step: 0.5 },
holdSeconds: { min: 0, max: 30, step: 0.5 },
},
});
export interface LampProps {
/** Metres from the lamp a character lights it within.
* @defaultValue `2` */
radius?: number;
/** Seconds it stays lit after the last character leaves.
* @defaultValue `3` */
holdSeconds?: number;
}
/** A lamp on its entity: the behaviour, and the bulb it draws. */
export function Lamp({ radius = 2, holdSeconds = 3 }: LampProps) {
useBehaviour(LampBehaviour, { radius, holdSeconds });
return <Bulb />;
}
/** The bulb: warm while the lamp is lit, dark while it is out. */
function Bulb() {
const isLit = useTrait(useEntity(), LampTrait)?.isLit ?? false;
return (
<mesh position={[0, 2, 0]}>
<sphereGeometry args={[0.25, 16, 16]} />
<meshStandardMaterial
color={isLit ? "#ffd166" : "#333333"}
emissive={isLit ? "#ffb347" : "#000000"}
/>
</mesh>
);
}

The game lists the plugin beside the engine’s in src/game.ts. In the example template, the list becomes plugins: [characters(), stats(), cooldowns(), resources(), behaviours(), machines(), inventory(), rounds(), kicks, runs, lamps], and a scene places a lamp as it places any behaviour: <Entity position={[0, 0, -1]}><Lamp /></Entity>. A game with a room re-exports plugins from the room’s scene file, as Run a room while you build says. A scene that places a lamp in a game that does not list lamps throws as it loads, with 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.

Every name the lamp imports from the engine comes from @spawnite/engine, the entry that holds the components, hooks, traits and functions a game is built from. @spawnite/engine/core holds its simulation names without React’s components and hooks, for a file of simulation code alone, such as a plugin a room runs with no views. The package’s other entries are /abilities, /weapons, /npcs, /inventory, /dragAndDrop, /tracks and /rounds for the plugins, /devtools, /vite, /testing, /replay, /three, /looks/<name> and /eslint, and Import entries says what each holds. createQuery and the World type come from koota, the entity library the engine is built on, which every game installs; @spawnite/engine also exports createQuery. The engine’s useTrait comes from @spawnite/engine, not koota/react: it also reads the engine’s predicted and core traits, which koota’s own refuses by type.

defineTrait(name, fields, options) makes a koota trait and gives it a name. The name is the trait’s key in the world’s dump, in the devtools and in a room’s stream. A trait from defineTrait reaches every client in a room unless its options say otherwise:

  • ownerOnly: true sends it to the player whose character holds it, and nobody else.
  • serverOnly: true keeps it in the room.
  • local: true has the room and each client keep their own, and no stream carries it.
  • predicted: true has the player’s client predict it on their own character, as Networking describes.

Koota’s own trait(fields) has no name. It is not dumped and not streamed, so the room and each client each hold their own copy, unless a defineBehaviour names it, which dumps it and streams it to every client under the behaviour’s name. A behaviour’s state that other players see uses defineTrait, so its name and its stream are said where it is made.

A trait of fields keeps each field in a column of its own. A field holds a number, a string, a boolean, or an object a function makes, such as () => new Vector3().

defineBehaviour({ name, trait, ... }) runs once, when its file is imported, and returns the behaviour. It names the behaviour in the devtools’ Add behaviour and Wiki tab, puts its trait in the dump under the trait’s own name, and holds its props to ranges in the inspector. plugin names the plugin whose system runs it: its component throws as the scene loads where the game does not list that plugin, so a lamp never sits dark because a line was left out. description is the line the inspector shows under its name.

runsOn means one thing on a behaviour and another on a system:

Where it is written What it decides
On a behaviour, defineBehaviour Where its consequence is trusted, and so whether its component may take a callback
On a system, its plugin entry Where the system runs: in the room alone, on the clients too, or on the clients alone

A behaviour’s runsOn takes the following values:

  • RunContext.Server: the room decides it, so its component takes no callback prop. The lamp is one.
  • RunContext.Both: the room decides it, and the player’s client runs it too, to predict it.
  • RunContext.Client, the default when left out: it only changes what a client shows, such as a spin.

Use defineBehaviour for a behaviour whose component adds its one trait and nothing more. Add behaviour gives an entity the trait alone, so a behaviour that also needs a body or a second trait would run there without it.

useBehaviour(behaviour, value) is the one call a behaviour’s component makes. It adds the trait to the nearest <Entity> with value as it mounts, and removes it as it unmounts. Between renders, it compares the fields of the new value with those it last wrote, one by one, a shallow comparison: a new object with the same fields writes nothing, so a render with the same props keeps an edit made in the inspector, and a changed prop wins over it. It writes only the fields value holds, so the lamp’s isLit and offTick, which its component never passes, stay as the system left them. On a client in a room, it writes nothing: the room’s copy of the scene added the trait, and the stream carries it.

A system is a function of the world and the step. A plugin lists it under a phase: input, motion, rules or physics, which the step runs in that order. A behaviour’s rule goes in rules. The rules phase runs before physics moves the characters, so the lamp reads where a character stood before this step’s move, one step behind the physics.

readEach(world, query, callback) and updateEach(world, query, callback) walk every entity the query matches. The callback takes ([records], entity, index): one record for each trait of the query, in the query’s order, then the entity and its place in the walk.

  • readEach hands each record to read. For a trait of fields, the record is reused for the next entity, so keep a field, never the record, and a write to the record changes nothing.
  • updateEach hands the same records, and writes each one back once the callback returns. Write through the record, as the lamp writes lamp.isLit. An entity.set on a trait the walk holds is overwritten by that write-back; entity.set on any other trait, and reads of any trait, are safe.
  • An entity the callback destroys during an updateEach is skipped, and nothing is written back to it.
  • updateEach compares each written record with what it held. Where a field changed, it tells each view that reads the trait with useTrait once the walk ends. Where nothing changed, no view renders.

A system counts time from the step: step.deltaSeconds, which is 1/60 s, and step.tick, the number of the step, which the room and every client share. Never read the wall clock, Date.now() or performance.now(), or draw from Math.random() in a system: the room, a client re-running a predicted step, and a replay’s rerun each run the step again, and must reach the same result. The game’s lint warns of Math.random in a system; random(world) draws from the world’s own stream.

findNearestControlled(world, position, { accepts, level }) returns the controlled entity nearest to position, as { entity, distance }, or null where the world holds none. A controlled entity is any entity a player controls or controlled, every character <CharacterSpawn> spawns among them. The distance is measured in 3D, from the entity’s position, at a character’s feet; level: true leaves height out, and accepts skips each entity it returns false for.

A plugin’s system with no runsOn runs in the room, and in the game itself when it plays alone, and never on a client in a room; its step is an AuthoritativeStep, which every function that decides an outcome takes. runsOn: RunContext.Both runs it on every client too, and RunContext.Client on the clients alone, for a system that only draws; such a step may be a client’s, so an outcome in it sits inside if (step.authoritative). A game played alone runs every system, whatever it declares.

The bulb reads the lamp with useTrait(useEntity(), LampTrait), and renders again each time a field of the lamp changes. The lamp writes each field only when its state turns, so the bulb renders a handful of times, never once a step. A field a system rewrites every step, such as a countdown in seconds, renders its view every step; keep such a field apart from what the view reads, or count in ticks, as offTick does.

The lamp’s system runs in the room, which decides when each lamp is lit, and LampTrait reaches every client, so every player sees the same lamp lit. The bulb is a view: each client draws it from the lamp it holds. Played alone, the game runs the system itself.

Each name is checked as it is made, and a name that breaks its rule throws, naming the rule:

  • A plugin’s name is lower-case letters, digits and dashes, and starts with a letter: "lamps" and "frost-nova" pass, and "Lamps" and "frost_nova" throw.
  • Every other name is letters and digits, with no dash, no underscore and no space. A trait’s, an event’s, a behaviour’s, a prefab’s and a collision layer’s start with a lower-case letter: "lamp" and "kartBlue" pass, and definePrefab("returning-coin", ...) throws on its dash. A system’s key may start with either case.
  • A trait and its behaviour may share a name, as LampTrait, defined as "lamp", and LampBehaviour, named "lamp", do. The two names live apart: the trait’s is its key in the dump and the stream, and the behaviour’s is its row in Add behaviour.
  • Two traits, two behaviours or two events may not share a name: a second definition under a name another of its kind holds throws, naming both, except where the game’s own module runs again and defines it anew, as a hot reload or a watched room’s restart does.

spawnite simulate runs a scene in Node and stops when a condition over its entities holds. --scene street loads src/scenes/Street.tsx, the name in PascalCase, and mounts the component that file exports as Street. With the lamp in such a scene, 4 m ahead of the character’s spawn, the following command holds W until any lamp is lit and prints the lamps:

Terminal window
spawnite simulate --scene street --keys w --until "Object.values(entities).some((entity) => entity.lamp?.isLit)" --fields lamp

In the example game on 2026-10-05, it printed The condition held on step 28; it returned true. and Hash 3000188150, stepped in 63 ms., then the plugins and the systems by phase, the lamps’ lamps.tendLamps last among the rules, and the lamp with "isLit": true.

The whole output
The condition held on step 28; it returned true.
Hash 3000188150, stepped in 63 ms.
Plugins, the engine's first, then the game's in the order it lists them:
stats (engine): One formula for every stat, and the fields stats set.
characters (engine): The characters: their movement, their pushes and speed modifiers, and the character each player gets as she joins.
cooldowns (engine): One timer abilities, items and resources share.
behaviours (engine): The engine's behaviours: spin, bob, pickup, interact, trigger, health and chase.
inventory (engine): The bag, what an entity wears, the uses of its items, loot, and locks.
resources (engine): Health, and each resource a game declares: mana, rage, stamina.
machines (engine): The state machines: the round, the NPC runner, a game's own.
rounds (game): The round: its score, its clock, and its win or loss.
kicks (game): Kicks a ball within reach on F.
runs (game): Counts each run the player wins, which her save keeps.
lamps (game): Lamps that light while a character stands near.
Systems by phase, in the order the step runs them:
input: core.copyInput
motion: stats.expireModifiers, stats.copyFields, core.move, core.blendCorrections
rules: core.advanceDayClock, cooldowns.countDown, inventory.useItems, behaviours.spin, behaviours.bob, behaviours.pickup, inventory.loot, behaviours.interact, behaviours.trigger, behaviours.capHealth, behaviours.reapDead, behaviours.respawnDowned, behaviours.chase, resources.regenerate, rounds.advance, machines.advance, kicks.kick, runs.countRunsWon, lamps.tendLamps
physics: characters.movement, characters.countDown, core.physics, core.follow, core.restColliders
{
"4": {
"lamp": {
"holdSeconds": 3,
"isLit": true,
"offTick": -1,
"radius": 2
}
}
}

The lamp’s test files run it alone and in a room. Alone, a character walks to the lamp, then away, and the lamp stays lit for exactly its 180-step hold, plus the one step the rules phase reads behind the physics; a view beside the bulb renders on each change of the lamp and on nothing else; and a scene in a game that does not list lamps throws as it loads. In a room, the room lights the lamp while a player’s character stands near it, and the player’s client reads it lit, then out after its hold. The files printed the following on 2026-10-05:

✓ |@spawnite/room| test/outsideLamp.test.ts > lights while a character stands near, stays lit for its hold after it leaves, then goes out 519ms
✓ |@spawnite/room| test/outsideLamp.test.ts > throws as the scene loads where the game does not list the lamps plugin 12ms
✓ |@spawnite/room| test/outsideLampRoom.test.ts > lights the room's lamp while a player's character stands near, and every client draws it lit, then out after its hold 636ms