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

Scaffold files

A scaffold is one part of a game, written into your game for you to change: a behaviour, an entity, an NPC, a scene, a HUD part, a shader or a map. A template is a whole game to start from, which Whole games to start from lists.

Each scaffold tool of the MCP writes one of these files into a game, with Feature replaced by the name it was given and feature by that name in kebab case, or in camel case for a behaviour. add_behaviour, add_npc and add_scene also write a page stub where the devtools’ Wiki tab reads the part’s page, wiki/behaviour/<name>.md, wiki/npc/<name>.md and wiki/scene/<name>.md; the Wiki lists no entity, panel or shader, so those tools write none. The registry serves each as an item under /r, so spawnite add behaviour writes the same file unrenamed.

Every scaffold tool takes dryRun: true, and spawnite add takes --dry-run: the call refuses what a real one refuses, such as a file that exists or an engine component’s name, and returns the same lines with each file’s content, writing nothing.

add_behaviour writes src/behaviours/<Name>.tsx: a trait, the behaviour defineBehaviour makes of it under the name in camel case, such as healthBar, which says where it runs, and the component that adds the trait to the nearest Entity.

For the devtools, it carries a description that the inspector shows under its name, an ai describer that gives its entity’s node in the AI tree, and a range that gives speed a slider. Its runsOn: RunContext.Client says only that its component may take a callback; where its system runs is the system’s own entry, as Write your own behaviour explains, and that page builds a whole behaviour, trait, system and view, with its tests.

src/behaviours/Feature.tsx
import { trait } from "koota";
import { defineBehaviour, RunContext, useBehaviour } from "@spawnite/engine";
/** What Feature does to its entity, in one line. */
export const FeatureTrait = trait({ speed: 1 });
export const FeatureBehaviour = defineBehaviour({
// The name its trait dumps under, Add behaviour offers it by and the
// Wiki lists it by, which its page at wiki/behaviour/feature.md takes.
name: "feature",
trait: FeatureTrait,
runsOn: RunContext.Client,
source: "src/behaviours/Feature.tsx",
description: "What Feature does to its entity, in one line.",
ai: ({ speed }) => ({
is: "a feature",
facts: [speed > 0 ? "moving" : "still"],
actions: ["look at it"],
}),
ranges: { speed: { min: 0, max: 10, step: 0.1 } },
});
export interface FeatureProps {
/** What speed means, and its unit. */
speed: number;
}
export function Feature(props: FeatureProps) {
useBehaviour(FeatureBehaviour, props);
return null;
}

With system: true, or --system from the cli, it also writes the behaviour’s system beside it, src/behaviours/<Name>System.ts: a rule the step runs over every entity that holds the trait, as a respawn counts its wait or a flinch plays a clip on a hit, where a behaviour that only animates keeps its useFrame in the component. The tool adds it as the last rule of the game’s own plugin, src/rules.plugin.ts, so it runs after the engine’s rules and reads what they wrote this step. Where the game has no such file, the tool writes it, and the first time it returns the lines that list the plugin in src/game.ts, the one list the page and the room both run. The rule’s entry declares no runsOn, so in a game with a room the room alone runs it and each page draws what the room streams; Where a system runs says when a rule declares a page too. The step runs each phase’s systems in the order a plugin writes them, so the plugin’s file is where the order is read.

src/behaviours/FeatureSystem.ts
import { createQuery, type World } from "koota";
import { updateEach, type StepOptions } from "@spawnite/engine/core";
import { FeatureTrait } from "./Feature";
const features = createQuery(FeatureTrait);
/** What Feature does to each of its entities every step, in one line:
* here, its speed eases toward rest. It is a rule of the game's plugin in
* src/rules.plugin.ts, whose entry declares no `runsOn`: in a game with a
* room, the room alone runs it and streams what it changed. */
export function stepFeature(world: World, { deltaSeconds }: StepOptions) {
updateEach(world, features, ([feature]) => {
if (feature.speed > 0)
feature.speed = Math.max(0, feature.speed - deltaSeconds);
});
}

add_entity writes src/components/<Name>.tsx: an Entity with a mesh and one behaviour to start from.

For the devtools, it carries a name, its row in the entity tree, an ai prop, its node in the AI tree, and a devtools panel: its spin speed as a slider, its position as a readout and a Reset button, which the inspector shows above its behaviours.

src/components/Feature.tsx
import { useState } from "react";
import {
button,
Entity,
Spin,
useDevtoolsPanel,
type EntityProps,
} from "@spawnite/engine";
const startSpeed = 1;
/** What Feature is in the world, in one line. */
export function Feature({
position = [0, 0, 0],
}: Pick<EntityProps, "position">) {
const [speed, setSpeed] = useState(startSpeed);
// What the devtools show for it: at the top of the inspector, and over
// it in the world once it is pinned.
useDevtoolsPanel({
spinSpeed: { value: speed, onChange: setSpeed, min: 0, max: 10 },
position,
reset: button(() => setSpeed(startSpeed)),
});
return (
<Entity
name="Feature"
position={position}
ai={{ is: "a feature", actions: ["look at it"] }}
>
<Spin speed={speed} />
<mesh>
<boxGeometry args={[0.5, 0.5, 0.5]} />
<meshStandardMaterial color="gold" />
</mesh>
</Entity>
);
}

add_npc writes src/npcs/<Name>.tsx: an NPC named <Name> over its head, with routines for a day, a round of named points with a pause at the well, a greeting and a Talk prompt, copied from the mmorpg’s Mira. It returns the import and the <Name /> line to paste into a scene, inside its World.

src/npcs/Feature.tsx
import {
Dialog,
dialog,
Faction,
Interact,
Npc,
NpcEvent,
route,
routines,
} from "@spawnite/engine";
// Its day: a round with a stop at the well, a rest on the bench, and a
// greeting for whoever comes close. Its conversation is below.
// Each point is [x, y, z], or [x, z] standing on the ground.
const round = route({
stall: [4, 0, -2],
well: [6, 0, -6],
herbs: [2, 0, -7],
});
const bench = route({ bench: [1, 0, -3] });
const day = routines("feature")
.routine("morning", (r) =>
r
.walk(round, { pause: { well: 4 } })
.wait(3)
.then("rest"),
)
.routine("rest", (r) =>
r
.walk(bench)
.wait({ between: [20, 40] })
.then("morning"),
)
.routine("greet", (r) => r.face().say("Hello!").resume())
.on(NpcEvent.Approached, "greet")
.start("morning");
// What it says to a player who presses Talk. A line reads a value as
// {name}; {player} and {npc} are the engine's. The game's HUD draws it in
// a DialogPanel.
const talk = dialog("feature", { herb: "moonleaf" })
.node("hello", (n) =>
n
.say("Good day, {player}. I'm {npc}.")
.choice("What do you gather?", "herbs")
.choice("Goodbye.", "bye"),
)
.node("herbs", (n) =>
n.say("Mostly {herb}, by the well.").choice("Thanks.", "bye"),
)
.node("bye", (n) => n.say("Safe roads.").end())
.start("hello");
/** Feature, drawn as a capsule until `avatar` or `model` names a registered
* one. */
export function Feature() {
return (
<Npc
name="Feature"
title="Herbalist"
level={5}
faction={Faction.Friendly}
position={[3, 0, -2]}
routines={day}
>
<Interact prompt="Talk" />
<Dialog tree={talk} />
</Npc>
);
}

add_dialog gives an NPC that exists a conversation, for one written before add_npc wrote one into each new NPC. It writes src/npcs/<Name>Dialog.tsx: a Talk prompt and a short dialog() tree, in one component, <Name>Dialog. It returns the import and the <Name>Dialog /> line to paste into the NPC’s file, inside its <Npc>. It refuses an NPC with no file at src/npcs/<Name>.tsx, and one whose file already holds a <Dialog>, since an NPC holds one conversation.

src/npcs/FeatureDialog.tsx
import { Dialog, dialog, Interact } from "@spawnite/engine";
// What Feature says to a player who presses Talk. A line reads a value as
// {name}; {player} and {npc} are the engine's. The game's HUD draws it in
// a DialogPanel.
const talk = dialog("feature", { herb: "moonleaf" })
.node("hello", (node) =>
node
.say("Good day, {player}. I'm {npc}.")
.choice("What do you gather?", "herbs")
.choice("Goodbye.", "bye"),
)
.node("herbs", (node) =>
node.say("Mostly {herb}, by the well.").choice("Thanks.", "bye"),
)
.node("bye", (node) => node.say("Safe roads.").end())
.start("hello");
/** Feature's conversation: the Talk prompt and the tree it starts. Put it
* inside Feature's `<Npc>`. */
export function FeatureDialog() {
return (
<>
<Interact prompt="Talk" />
<Dialog tree={talk} />
</>
);
}

add_scene writes src/scenes/<Name>.tsx and returns the import and the <Scene> line to paste into src/app/app.tsx, as Scene describes. The scene re-exports the game’s plugins from src/game.ts, since a room and spawnite simulate load the scene’s file alone.

The scene carries its phases as a machine, <Name>Machine: an intro of three seconds, then play. The scene spawns it on an entity of its own as it opens, and its hint reads the phase from the machine’s tag. Add a state for each phase the scene has, and move it on with send, a wait or a when rule.

src/scenes/Feature.tsx
import { useHas, useQueryFirst, useWorld } from "koota/react";
import { useEffect } from "react";
import {
Camera,
CameraTarget,
Hud,
Panel,
CharacterSpawn,
Slot,
states,
Text,
World,
destroy,
isAuthoritative,
requireAuthority,
spawn,
} from "@spawnite/engine";
// A room and `spawnite simulate` load this file alone, so it exports the
// game's plugins.
export { plugins } from "../game";
/** The feature scene's phases: an intro of three seconds, then play. Each
* state is a tag, so a system finds the scene in play with
* `world.queryFirst(FeatureMachine.is.playing)`, and moves it on with
* `FeatureMachine.send`. Add a state for each phase the scene has. */
export const FeatureMachine = states({
id: "feature",
description: "The feature scene's phases: an intro, then play.",
initial: "intro",
states: {
intro: { wait: { seconds: 3, then: "playing" } },
playing: {},
},
});
/** Spawns the scene's phases on an entity of their own as the scene
* opens, as Round spawns its round, and takes them away as it closes. A
* page in a room reads the room's, which its stream brings. */
function FeaturePhases() {
const world = useWorld();
useEffect(() => {
if (!isAuthoritative(world)) return;
const authority = requireAuthority(world);
const phases = spawn(authority, { traits: [FeatureMachine.trait] });
return () => destroy(authority, phases);
}, [world]);
return null;
}
/** The line at the bottom, which reads the phase from its tag. */
function FeatureHint() {
const phases = useQueryFirst(FeatureMachine.trait);
const intro = useHas(phases, FeatureMachine.is.intro);
return (
<Hud>
<Panel slot={Slot.Bottom}>
<Text>{intro ? "Get ready" : "Feature"}</Text>
</Panel>
</Hud>
);
}
/** What happens in the feature scene, in one line. */
export function Feature() {
return (
<World map="meadow">
<FeaturePhases />
<CharacterSpawn />
<Camera follow={CameraTarget.ViewTarget} />
<FeatureHint />
</World>
);
}

add_panel writes src/devtools/<Name>Panel.tsx and returns the import and the entry to paste into the panels list in src/app/devtools.tsx, as Devtools describes.

For the devtools, it is a tab of its own once its entry is in the panels list that src/app/devtools.tsx passes to <Devtools>.

src/devtools/FeaturePanel.tsx
import { Text } from "@spawnite/engine";
/** What the Feature panel shows, in one line. */
export function FeaturePanel() {
return <Text tabular>- feature</Text>;
}

add_hud writes src/hud/<Name>.tsx: a Panel at slot, top by default, holding a Text and the player’s health Bar, built from the engine’s own parts, which a game restyles through its tokens. It returns the import and the <Name /> line to put inside a <Hud>, in the app or a scene, as the example’s coin readout stands. The mmorpg’s target frame and damage numbers are the shape it starts from.

src/hud/Feature.tsx
import {
Bar,
Panel,
PanelVariant,
Slot,
Text,
useHealth,
useCharacter,
} from "@spawnite/engine";
/** What the Feature shows a player, in one line: here, her health. */
export function Feature() {
const character = useCharacter() ?? null;
const health = useHealth(character);
// Nothing until she has spawned with health to show.
if (!health) return null;
return (
<Panel slot={Slot.Top} variant={PanelVariant.Bare} className="gap-1">
<Text size="base">Feature</Text>
<Bar
label="Health"
value={health.current}
maximum={health.maximum}
showValue
/>
</Panel>
);
}

add_shader writes src/shaders/<Name>.tsx: a fragment that includes one LYGIA function, and a <Name>Material component that draws it as the material of the mesh it sits in, as Shader describes. Put the material inside a mesh, then edit the fragment and its uniforms.

src/shaders/Feature.tsx
import { Shader } from "@spawnite/engine";
// Edit from void main() on. The engine declares uTime, uPointer and vUv
// ahead of it, and one uniform per uniforms key below.
const fragment = `#include "lygia/generative/snoise.glsl"
void main() {
float noise = 0.5 + 0.5 * snoise(vec3(vUv * uScale, uTime * uSpeed));
gl_FragColor = vec4(vec3(noise), 1.0);
}`;
export interface FeatureMaterialProps {
/** How fast the pattern changes, per second of world time. */
speed?: number;
/** How many cells of the pattern cross the mesh. */
scale?: number;
}
/** The material of the mesh it sits in: what Feature draws, in one line. */
export function FeatureMaterial({
speed = 0.5,
scale = 4,
}: FeatureMaterialProps) {
return (
<Shader
fragment={fragment}
uniforms={{ uSpeed: speed, uScale: scale }}
/>
);
}

add_map writes src/maps/<name>.json: a flat, empty map, 64 m across unless size says otherwise, or with from: "meadow" a copy of the engine’s meadow. It has no page stub. It returns the registerMaps line to paste into src/app/app.tsx only while the app file lacks one; every game template carries it. spawnite add map <name> [--size <metres>] [--from meadow] does the same from the cli. Open the game with ?map=<name> in development to see the map alone, as Devtools describes.

src/maps/Feature.json
{
"seed": 1,
"size": 64,
"cell": 1,
"hills": { "amplitude": 0, "wavelength": 40, "detail": 0 },
"scatter": {
"treeRound": 0,
"treeTall": 0,
"bushRound": 0,
"rockBoulder": 0,
"rockFlat": 0,
"grassTuft": 0,
"seedOffset": 0
},
"regions": {},
"paths": {},
"views": {},
"props": {},
"rim": 0
}

spawnite create <folder> --template <name> writes a whole game, and spawnite list lists the templates:

Template What it is Players
example A lobby, then a run through three coins to a goal ring; the game the tour walks through Alone, or in a room with --room
arena Players in one room, with monsters that chase them and coins to race for In a room
holdfast Holdfast, co-op wave survival for up to four In a room
crazy-eights Crazy Eights at a 3D card table against bots Alone
depthfield Depthfield, a survivor arena with a boss Alone
sled Sled, a sled launched down a snowy track Alone
mmorpg A meadow with a mage, wolves and an NPC Alone
hack-and-slash A hack and slash drawn with 2D sprites Alone

A plugin is a capability a game lists among its plugins, and a kit is source copied into a game for its creator to change. Of the engine’s plugins, rounds alone ships as readable source, under the engine package’s readable/rounds folder. You may read it, copy it into your game and change the copy, and the copy stays under the Spawnite License, while the lines you write in it are yours. Every other engine plugin ships closed.

Each template and scaffold file carries the license it names, and the templates are MIT No Attribution (MIT-0): use, change and share them with no credit. Code you write yourself is yours, and you may share it as a kit under any license you choose. A kit that holds a copy of a readable plugin may go only to people who have accepted the Spawnite License, privately or through a channel Spawnite provides for sharing plugins and kits, and never to a public registry or website.