A tour of the example game
The example game is the project spawnite create --template example writes, and the one the game-builder skill starts every game from. It opens on a lobby where a robot stands on a grid. The Play button opens a run: walk to three coins in a line and collect them before a 20-second clock runs out, past a ball you can kick and a tree. The Platforms button opens a climb up five boxes. Each sample below is the example’s own file, or part of one, and the example’s tests under test/ run it.
Every game on the engine has the following shape. A game holds scenes, a scene holds a world, and a world holds entities, each with the behaviours that make it act:
flowchart TB
Game --> Scene --> World --> Entity --> Behaviour
Scene -.-> UI
Solid arrows are nesting. The dotted arrow is UI, which draws on the screen over the world. How a game fits together explains each level.
To have an agent build a game with you instead, see Make your game.
1. Create the project
Section titled “1. Create the project”Create a project from the example template, then install it. The packages come from npm, so the install needs no token:
npx @spawnite/cli create my-game --template examplecd my-game && pnpm installThe UI’s panels and buttons take a theme, and a shipped template takes the glass theme, translucent panels over the game. For another, add --theme slate: the command imports that theme’s stylesheet in src/styles.css, right after the UI stylesheet.
The project holds the following files, among others:
| File | What it is |
|---|---|
src/app/app.tsx |
The game: its scenes, its plugins, its save and the wallet’s readout |
src/game.ts |
The plugins every scene runs |
src/scenes/ |
The lobby, the run and the platforms, one file each |
src/components/ |
The entities: the robot, a coin, the ball, the tree, the ring and a platform, and the round’s HUD |
src/behaviours/Roll.tsx |
A behaviour of the game’s own, on the ball |
src/kicks.ts, src/runs.ts |
Two plugins of the game’s own: the kick, and the count of runs won |
src/save.ts, src/items.ts |
What the player’s save keeps, and the one item, a potion |
wiki/ |
The game’s own pages, which the devtools’ Wiki tab shows |
test/ |
Vitest tests that step the scenes headless and build the game |
In your project the paths start at src/. A sample’s title names the file it is copied from in Spawnite’s own repository, here under games/example/, and the docs’ tests fail when the sample and that file differ. A title that ends in (part) shows a piece of the file. The example’s own tests and typecheck cover each of these files.
2. The game and its plugins
Section titled “2. The game and its plugins”Three files start the game, and a fourth, src/game.ts, lists its plugins. To add a scene, an agent adds an import and a <Scene> line to app.tsx, as Add your own scene says, and leaves the rest as it is. The example’s tests pin parts of these files, as What the example’s tests pin lists. index.html loads src/main.tsx and the game’s stylesheet, src/styles.css.
src/main.tsx is the page’s entry, which index.html loads. It renders the default export of app.tsx into the page’s root element, and renders into the same root again when a hot update runs the module a second time:
import { StrictMode } from "react";import * as ReactDOM from "react-dom/client";import App from "./app/app";
// A hot update that reaches this module runs it again, so it renders into// the root it made the first time: a second root on the same element// would fight the first for its children.const root: ReactDOM.Root = import.meta.hot?.data.root ?? ReactDOM.createRoot(document.getElementById("root") as HTMLElement);if (import.meta.hot) import.meta.hot.data.root = root;
root.render( <StrictMode> <App /> </StrictMode>,);src/app/app.tsx is the game. A Game holds the scenes and says which one opens first, and each Scene names one screen of the game and the component that draws it. The example opens on the lobby. The lines to read first are <Game name="example" start="lobby" …>, the three <Scene> lines inside it, and export default App at the end:
import { lazy, Suspense, useState } from "react";import { Game, Hud, Panel, registerMaps, Scene, Slot, Text, useWallet, type RendererFactory, type RoomOptions,} from "@spawnite/engine";import { plugins } from "../game";import { save } from "../save";import { Lobby } from "../scenes/Lobby";import { Platforms } from "../scenes/Platforms";import { Run } from "../scenes/Run";
// The example game: a lobby where the visor robot stands, then a run through// three coins to a goal ring, or a climb up five platforms. One file per// level of the engine's shape.
/** The wallet, at game level, so it reads the same in every scene. */function Readout() { const { coins } = useWallet();
return ( <Hud> <Panel slot={Slot.TopRight}> <Text>Coins {coins}</Text> </Panel> </Hud> );}
// Every map file under src/maps, each by its file name.registerMaps(import.meta.glob("../maps/*.json", { eager: true }));
// Development only: a production build drops the import.const ExampleDevtools = import.meta.env.DEV ? lazy(() => import("./devtools")) : undefined;
/** The room `?room=` names, as `spawnite play start` opens a game created * with `--room`; none, so the game plays alone, where it names none. */function readRoomOptions(search: string): RoomOptions | undefined { const query = new URLSearchParams(search); const url = query.get("room"); if (url === null) return undefined; return { url, playerName: query.get("name") ?? "Player" };}
/** The renderer `?renderer=webgpu` asks for: a `WebGPURenderer`, the * engine's experimental WebGPU mode; none, for the engine's WebGL2 one, * where the URL names none. */function readRenderer(search: string): RendererFactory | undefined { if (new URLSearchParams(search).get("renderer") !== "webgpu") return undefined; return async (defaults) => { // Loaded only on this page, so the WebGL2 one never downloads it. const { WebGPURenderer } = await import("three/webgpu"); const renderer = new WebGPURenderer(defaults); await renderer.init(); return renderer; };}
export function App() { // Game reads its room and its renderer once, when it mounts. const [room] = useState(() => readRoomOptions(window.location.search)); const [renderer] = useState(() => readRenderer(window.location.search));
return ( <Game name="example" start="lobby" room={room} gl={renderer} plugins={plugins} save={save} > <Scene name="lobby" component={Lobby} /> <Scene name="run" component={Run} /> <Scene name="platforms" component={Platforms} /> <Readout /> {ExampleDevtools && ( <Suspense fallback={null}> <ExampleDevtools /> </Suspense> )} </Game> );}
export default App;Besides the scenes, the file does the following:
registerMapsregisters each map file undersrc/mapsby its file name, before Game mounts, so a World names one withmap. The example has none yet;add_mapwrites one there.readRoomOptionsreads?room=from the page’s address, whichspawnite play startpasses for a game created with--room. With none, the game plays alone.readRendererreads?renderer=webgpu, for the engine’s experimental WebGPU renderer.ExampleDevtoolsmounts the devtools in development only, through a lazy import that a production build drops.Readoutdraws the wallet’s count at the game’s level, so it reads the same in every scene.
Keep export default App at the end: main.tsx imports the default export, and a file with only the named App export does not typecheck. Keep the devtools behind import.meta.env.DEV and the lazy import, and keep the registerMaps line. A game with no map files under src/maps still runs with it: the line registers nothing, and the built-in grid and meadow need no file.
src/app/devtools.tsx is the module that lazy import loads. Its default export mounts the engine’s devtools, with the game’s own panels in panels, where spawnite add panel has a panel’s entry pasted. The example’s build test fails if any devtools code reaches a production build, and the playtest tools in step 7 drive the game through the devtools:
import { Devtools, type DevtoolsPanel } from "@spawnite/devtools";
// Loaded only in development, through the lazy import in app.tsx, so a// production build never reaches this module or the devtools behind it.
// Where `spawnite add panel` has a panel's entry pasted.const panels: DevtoolsPanel[] = [];
export default function ExampleDevtools() { return <Devtools panels={panels} />;}plugins lists what the world steps each tick: the engine’s plugins the character and the behaviours need, the round, and the game’s own two. A plugin brings its systems, its inputs and its traits, and Game runs each system in its phase every step. Plugins lists the engine’s.
import { behaviours, characters, cooldowns, inventory, machines, resources, stats, type PluginList,} from "@spawnite/engine/core";import { kicks } from "./kicks";import { rounds } from "@spawnite/engine/rounds";import { runs } from "./runs";
/** The plugins every scene's page and the room run: the character and the * engine's plugins it leans on, the bag for the potion, the round the Run * scene plays, the kick for the ball, and the count of runs won. */export const plugins: PluginList = [ characters(), stats(), cooldowns(), resources(), behaviours(), machines(), inventory(), rounds(), kicks, runs,];3. The lobby
Section titled “3. The lobby”A scene renders a World, and everything in the scene stands inside it. The lobby stands on grid, the flat floor a World mounts when it names no map. The lobby names it outright, under the noon look. The grid loads no texture and no scatter model, so the first screen opens fast. The run and the platforms stand on meadow, the engine’s map of hills and trees.
import { Button, Camera, CameraPreset, Hud, Panel, PanelVariant, Slot, useScenes, World,} from "@spawnite/engine";import { noon } from "@spawnite/engine/looks/noon";import "../items";import { Robot } from "../components/Robot";
// A room and `spawnite simulate` load this file alone, so it exports the// game's plugins and its save.export { plugins } from "../game";export { save } from "../save";
/** Metres the lobby's camera opens behind the robot. */const lobbyCameraDistance = 4;/** Degrees the lobby's camera opens looking down. */const lobbyCameraPitch = 10;
/** The visor robot standing on the engine's plain grid under the noon * look, a camera behind it, and the buttons to the run and the platforms. The * grid loads no texture and no model, so the first screen opens fast. */export function Lobby() { const scenes = useScenes();
return ( <World map="grid" look={noon}> <Robot /> {/* Behind the robot and low, so the whole floor and the sky show; the left button's drag orbits it, and no click-to-walk. */} <Camera preset={CameraPreset.Classic} distance={lobbyCameraDistance} pitch={lobbyCameraPitch} clickToWalk={false} /> <Hud> <Panel slot={Slot.Bottom} variant={PanelVariant.Bare}> <Button onPress={() => scenes.go("run")}>Play</Button> <Button onPress={() => scenes.go("platforms")}> Platforms </Button> </Panel> </Hud> </World> );}In the lobby’s file:
- The two
exportlines at the top are there because a room andspawnite simulateload a scene’s file alone, never the app, so the file hands them the game’s plugins and its save. import "../items"registers the game’s items, for the same reason: a scene names an item only where the scene imports the file that registers it.- The Camera uses the Classic preset: it opens 4 m behind the robot, looking down 10 degrees, low enough to show the whole floor and the sky. A drag with the left button orbits it, and
clickToWalk={false}keeps a click from walking the character. - A Button in a Hud calls
scenes.go("run")to move the game to the run.
The robot is the player’s character: a <CharacterSpawn> with the visor-bot model, which plays its idle, walk, run and jump clips. Every scene mounts the same component, so the character looks the same everywhere:
import { CharacterSpawn, type CharacterSpawnProps } from "@spawnite/engine";import "../models";
/** The player's character, as the visor robot: its rigged model playing its * idle, walk, run and jump. */export function Robot(props: Omit<CharacterSpawnProps, "model">) { return <CharacterSpawn model="visor-bot" {...props} />;}The second button opens the platforms scene, src/scenes/Platforms.tsx, under a SideCamera, with Space to jump. It holds five fixed boxes that climb across the meadow’s clearing, each 0.9 m higher than the last, past the character’s 0.6 m step and under its 1.2 m jump. On a phone, a Joystick and a Tap bound to the input store’s steer and jump stand in for the keys. The scene has no ending: the top platform is the goal.
- Reference: World, CharacterSpawn, Camera and Button.
4. The run and its coins
Section titled “4. The run and its coins”The run puts three coins in a line ahead of the player, a tree beside them, the ball past the last coin and the ring at the end. Its camera follows the player:
import { Camera, CameraPreset, CameraTarget, Hud, Panel, Slot, Text, World, type Position,} from "@spawnite/engine";import { Round, RoundScreen } from "@spawnite/engine/rounds";import "../items";import { Ball } from "../components/Ball";import { Coin } from "../components/Coin";import { Ring } from "../components/Ring";import { RoundClock } from "../components/RoundClock";import { Tree } from "../components/Tree";import { Robot } from "../components/Robot";
// A room and `spawnite simulate` load this file alone, so it exports the// game's plugins and its save.export { plugins } from "../game";export { save } from "../save";
/** Three coins in a line ahead of the player, a tree beside them, then the * ball, and the ring past them. The round is won with every coin. */export const coinPositions: Position[] = [ [0, 0, -3], [0, 0, -5], [0, 0, -7],];/** Resting on the ground: its centre a radius up. */export const ballPosition: Position = [0, 0.5, -8.5];const treePosition: Position = [2, 0, -5];const ringPosition: Position = [0, 0, -10];
export function Run() { return ( <World map="meadow"> <Robot /> {/* The orbit it has always had: the left button's drag, and no click-to-walk. */} <Camera follow={CameraTarget.ViewTarget} preset={CameraPreset.Classic} clickToWalk={false} /> <Round seconds={20} target={coinPositions.length} /> <RoundClock /> <RoundScreen /> {coinPositions.map((position) => ( <Coin key={position.join()} position={position} /> ))} <Ball position={ballPosition} /> <Tree position={treePosition} /> <Ring position={ringPosition} /> <Hud> <Panel slot={Slot.Bottom}> <Text> WASD walks, and F kicks the ball. Collect the three coins before the clock runs out. </Text> </Panel> </Hud> </World> );}A coin is an Entity with a Pickup. The player who walks within its radius takes the reward into their wallet, with the engine’s pickup chime, and the coin goes. Spin and Bob move it so that it reads as a coin. Its body is kinematic, so it moves with the bob and never falls, and the ball bounces off it where it is drawn:
import { Bob, box, Entity, Pickup, type Position, RigidBodyType, sounds, Spin,} from "@spawnite/engine";
/** One coin: it turns, bobs, and goes into the wallet of whoever reaches it, * with a chime. * Its body follows the bob, so the ball bounces off it where it is drawn. */export interface CoinProps { position: Position;}
export function Coin({ position }: CoinProps) { return ( <Entity position={position} body={{ bodyType: RigidBodyType.Kinematic }} collider={{ shape: box({ size: [0.8, 0.1, 0.8] }) }} > <Spin speed={2} /> <Bob height={0.2} /> <Pickup reward={1} sound={sounds.pickup} /> <mesh> <cylinderGeometry args={[0.4, 0.4, 0.1, 24]} /> <meshStandardMaterial color="gold" /> </mesh> </Entity> );}The other entities in the run each show one more part of the engine:
- The tree’s collider is
convexHull(), the hull of its model, so the character stops at its bark and canopy rather than at a box round them. - The ring only spins. It has no collider, and the round ends on the coins, not on reaching the ring.
- The ball has a dynamic body, so it falls to the ground and rolls when the character walks into it or kicks it:
export function Ball({ position }: Required<Pick<EntityProps, "position">>) { return ( <Entity position={position} body={{ bodyType: RigidBodyType.Dynamic }} collider={{ shape: sphere({ radius: 0.5 }) }} > <Roll spot={position} /> <mesh> <sphereGeometry args={[0.5, 24, 16]} /> <meshStandardMaterial color="tomato" /> </mesh> </Entity> );}<Roll> is a behaviour of the game’s own, in src/behaviours/Roll.tsx: a state machine that says whether the ball still rests on its spot. Its when watches the ball’s own transform, so no system has to send it anything:
export const RollMachine = states({ id: "roll", description: "The ball: resting on its spot, then pushed off it.", initial: "resting", context: { spotX: 0, spotZ: 0 }, states: { resting: { when: [[isOffItsSpot, "pushed"]] }, pushed: {}, },});
export const RollBehaviour = defineBehaviour({ name: "roll", trait: RollMachine.trait, runsOn: RunContext.Client, wiki: "/behaviours/roll/", source: "src/behaviours/Roll.tsx", description: "Says whether the ball rests on its spot or was pushed.",});State machines says how states works, and Entity how a behaviour is defined.
The kick, a rule of the game’s own
Section titled “The kick, a rule of the game’s own”F, or the Kick button on a phone, kicks a ball within 1.4 m of the character. No engine plugin does that, so the example declares a plugin of its own: an input bound to the F key and a touch button, and a system in the rules phase that runs after the engine’s cooldowns count down:
export const kicks = definePlugin({ name: "kicks", description: "Kicks a ball within reach on F.", inputs: { kick: buttonInput({ keys: ["KeyF"], touch: { label: "Kick" }, description: "Kick the ball", }), }, systems: { rules: { kick: { system: kickBalls, after: [cooldowns.systems.countDown], description: "Kicks each ball within reach of a character pressing kick.", }, }, },});The system reads each character holding the kick and pushes each dynamic body within reach away from it, with an impulse of 2.4 newton-seconds along the ground and 0.8 up, then waits half a second before the next kick:
function kickBalls(world: World, step: AuthoritativeStep) { readEach(world, characters, ([{ position: feet }], character) => { if (!readInput(character, kicks.inputs.kick)) return; if (isCoolingDown(character, kickCooldown)) return; readEach(world, bodies, ([body, { position }], ball) => { if (body.bodyType !== RigidBodyType.Dynamic) return; away.subVectors(position, feet).setY(0); if (away.length() > kickReach) return; away.normalize().multiplyScalar(kickAlong).setY(kickUp); applyImpulse(step, ball, away); startCooldown(character, { key: kickCooldown, seconds: 0.5 }); }); });}Systems says how a system reads and changes the world, and Plugins how a game declares one.
5. The round
Section titled “5. The round”A Round counts down game time and scores the coins the player picks up while it plays. The run’s <Round seconds={20} target={coinPositions.length} /> lasts 20 seconds and is won with three coins. In a game played alone, it starts when the scene opens. In a room, which opens its scene before anyone plays, the round waits until the first player presses Play and opens the run.
The round is won on the step the third coin lands in the wallet, and lost on the step the clock runs out first. RoundScreen then opens the win or lose dialog, with a Play again button, which pauses the world. A game with a rule that Round does not cover writes its own plugin, as the kick does.
The example counts each run the player wins in a second plugin of its own, src/runs.ts, whose system runs after the round’s. The player’s save keeps that count, and the coins in the wallet, so a run’s coins reach the lobby:
export const save = defineSave({ include: ["entity", "wallet"], version: 1, schema: z.object({ runsWon: z.int().check(z.nonnegative()) }), read: ({ player }) => ({ runsWon: readRunsWon(player) }), restore: ({ player }, saved) => setRunsWon(player, saved.runsWon),});include names the parts of the engine’s save the game wants: entity, the character’s save, which holds where it stands and its health, and the wallet, which is on the player. The runs won are on the player too, since they outlive any character, so read and restore take the player. schema checks the game’s own part when a save loads, and a save that fails it does not load. Saving progress says how a save is written, versioned and tested.
The game’s one item, a potion, is registered in src/items.ts. No scene places one yet: it is there for a game that adds a bag or loot. Inventory lists what an item can say.
registerItem("potion", { displayName: "Potion of healing", maxStack: 10, use: { effects: [{ type: "heal", amount: 30 }], consume: true, cooldownSeconds: 1, },});- Reference: Round and RoundScreen.
6. The HUD
Section titled “6. The HUD”The HUD reads the round with useRound() and draws the seconds left and the coins toward the target, each in a Counter, in a Panel at the top of the screen. A Hud draws in the screen layer, over the canvas. When the round ends, the panel says how:
import { Counter, Hud, Panel, Slot, Text } from "@spawnite/engine";import { RoundState, useRound } from "@spawnite/engine/rounds";
/** The run's round: the seconds left and the coins toward the target, each * a Counter, then how it ended. */export function RoundClock() { const { secondsLeft, score, target, state } = useRound(); const ended = { // The example's round starts as it mounts, so these never show. [RoundState.Ready]: "", [RoundState.Countdown]: "", [RoundState.Playing]: undefined, [RoundState.Won]: `Won with ${Math.ceil(secondsLeft)} s left`, [RoundState.Lost]: `Out of time, ${score} of ${target} coins`, }[state];
return ( <Hud> {ended === undefined ? ( <Panel slot={Slot.Top} className="flex-row gap-6"> <Counter icon="timer" label="Seconds left" value={Math.ceil(secondsLeft)} /> <Counter icon="coins" label="Coins" value={`${score}/${target}`} /> </Panel> ) : ( <Panel slot={Slot.Top}> <Text>{ended}</Text> </Panel> )} </Hud> );}The wallet’s count is drawn at the game’s level, by Readout in app.tsx, so it reads the same in every scene.
7. Playtest and run
Section titled “7. Playtest and run”The spawnite commands run the game with no window and report what happened, so you check the game without playing it. The first command that opens the game in a headless page downloads Chromium, once for the machine. Run each one in your game’s folder; spawnite --help lists every command. Each simulate also prints the plugins and the systems of the step after the hash; they are left out here.
-
Step the run scene in Node for 21 seconds, with no keys down. The player stands still, so the clock runs out:
Terminal window spawnite simulate --scene run --seconds 21 --fields round.state,round.reasonStepped 1260 steps.Hash 1113369361, stepped in 476 ms.{"5": {"round": {"state": "lost","reason": "time"}}} -
Step it again with W held, until no coin is left. The player walks forward through the three coins, and the round is won on step 76, about 1.3 s of game time:
Terminal window spawnite simulate --scene run --keys w --until "count('pickup') === 0" --fields wallet,round.stateThe condition held on step 76; it returned true.Hash 693284281, stepped in 281 ms.{"1": {"wallet": {"coins": 3}},"5": {"round": {"state": "won"}}}The same command gives the same hash on every run of the same code. The hash also changes with the engine release, so yours may differ from the one shown. A hash that changes after an edit means the edit changed the world the run leaves, which a trait added only for display also does.
-
Start a playtest: the dev server and a headless Chromium page. It prints the page’s address and the scene’s tree, and the world holds still until a command steps it:
Terminal window spawnite play startPlaytest running at http://localhost:51135/hud:1 Hudhud:3 Panelscene:lobby lobby2 World #2.1hud:5 Hudhud:7 Panelhud:9 Button: Playhud:11 Button: Platforms8 LookPostProcessing #8.18:_r_d_ AmbientOcclusion8:_r_g_ Bloom8:_r_j_ Vignette5 Robot #5.15:movement Movement9 Camera #99:settings SettingsWith
--scene run, it goes on from the lobby to the run and prints the run’s tree instead. The playtest’s browser skips the engine’s Play menu, so a game whose camera starts locked shows the game itself; Escape still opens the menu. The camera page says why. -
Step the lobby for one second, 60 steps, then take a screenshot of that step. The command prints the path of the PNG it saved, then the line below:
Terminal window spawnite play screenshot --until "steps >= 60"The condition held on step 60; it returned true.The picture shows the robot on the grid, the Play and Platforms buttons, the wallet’s count at the top right, and the devtools’ spawnite button at the top left.
-
Stop the playtest, which closes the page and the dev server:
Terminal window spawnite play stop
The Devtools API covers what the playtest calls, and How your agent works on your game puts these commands together into the loop an agent runs.
pnpm test runs the project’s tests under test/ with Vitest, and pnpm test test/scenes/Run.test.tsx runs one file. The tests run in Node with no browser. They step the run, the ball and the platforms headless, check that the save restores the runs won and the coins, build the game and hold its size to a budget, and check that src/models.json matches the model files in public/assets/. After you copy a model file in by hand, run pnpm models, and commit src/models.json if the project is a git repository, before the tests. The run’s own test is the smallest:
// @vitest-environment nodeimport { expect } from "vitest";import { RoundState, RoundTrait } from "@spawnite/engine/rounds";import { it } from "@spawnite/engine/testing";import { plugins } from "../../src/game";import { Run } from "../../src/scenes/Run";
it("plays the round as Run opens in a game played alone", async ({ createWorld, scene,}) => { const { world } = createWorld(plugins);
await scene(Run, world);
expect(world.queryFirst(RoundTrait)?.get(RoundTrait)?.state).toBe( RoundState.Playing, );});To play the game yourself, run pnpm dev and open the address it prints. Press Play, then walk with WASD and kick with F.
Add your own scene
Section titled “Add your own scene”To add a scene, such as an arena, an agent makes the following edits:
-
Write
src/scenes/Arena.tsx. The MCP’sadd_scene, orspawnite add scene arena, writes it with a world, a character, a camera and a line of HUD, and a page for it atwiki/scene/arena.md. It prints the two lines step 3 pastes:Wrote src/scenes/Arena.tsxWrote wiki/scene/arena.mdRegister it: paste these into src/app/app.tsx:import { Arena } from "../scenes/Arena";<Scene name="arena" component={Arena} /> -
At the top of the scene’s file, keep the
export { plugins } from "../game";the scaffold writes, and addexport { save } from "../save";beside it, as the example’s other scenes have. A room andspawnite simulateload a scene’s file alone, neverapp.tsx, so the file hands them the game’s plugins and its save. Addimport "../items";too when the scene names an item. -
In
src/app/app.tsx, add the import at the top and the<Scene name="arena" component={Arena} />line inside<Game>, beside the other three. Change nothing else in the file. -
Give the player a way in: a Button that calls
scenes.go("arena"), as the lobby’s Play button callsscenes.go("run"). To open the game on the new scene instead, setstart="arena"on Game.
Then check the scene: spawnite simulate --scene arena --seconds 1 mounts it headless, loading src/scenes/Arena.tsx, the scene’s name in PascalCase. spawnite play start --scene arena opens a playtest and goes to the scene the <Scene name="arena"> line registers. In a game with a room, the room runs the one scene that spawnite.room.scene in package.json names, so a new scene plays in the room only when that line names it.
What the example’s tests pin
Section titled “What the example’s tests pin”The example’s tests hold its files to what they do now. pnpm test runs them, and pnpm check type-checks test/ with src/, so a test that no longer matches the game’s code fails the check too. An agent that changes what a test asserts changes the test in the same step:
test/app.test.tsxrenders the whole app and expects the game namedexample, a button named Play, the textCoins 0, no open dialog, and the devtools’ Spawnite button. A second case mounts it as outside development, with?debugin the address, and expects no devtools.test/game.test.tssteps the game’spluginswith no window and expects a won round to count once as a run won. It reads what the step does, not the names of its systems, so a plugin the game adds tosrc/game.tspasses it.test/scenes/Run.test.tsxexpects the round to be playing as the run opens in a game played alone.test/scenes/Lobby.test.tsxexpects the lobby’s ground to be the flat 8 m grid with nothing scattered on it.test/scenes/Platforms.test.tsxexpects the character to climb onto the last platform on jumps alone.test/harness.test.tsexpects the first coin in the wallet within 5 s, the ball to roll away when the character walks into it, and the same seed to give the same hash.test/components/Ball.test.tsxexpects the ball to rest until it is pushed, and F to kick it away within reach.Platform.test.tsxexpects a platform to hold the character on its top.RoundClock.test.tsxexpects the seconds left and the coins, each in a Counter.test/save.test.tsexpects the save to include the character’s save, asentity, and the wallet, to restore the runs won and the coins onto the player, to refuse a negative run count, to count a won round once, and the lobby’s file to export that save.test/build.test.tsbuilds the game for production and expects fewer than 24 code chunks, no devtools, one Draco decoder, one glTF loader, and the loaders a first frame does not need kept out of the first download.test/models.test.tsexpectssrc/models.jsonto match the model files inpublic/assets/.
A new scene needs no change to these tests. Its own test goes under test/scenes/, as Run.test.tsx does for the run.