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

Game

<Game name start> is the root of a game. It holds the scene registry, the modal stack, the screen layer and the stores. UI inside Game but outside any scene is global and lives as long as the game. Stores and saves describes the stores and the save. Plugins and Systems describe what each step runs.

Game draws one canvas. The engine’s frame loop and the active scene render inside it; Game’s children render as DOM after it. The canvas fills Game’s parent, so a game’s page gives its root the whole screen, as The stylesheet shows.

The example template’s App is a whole game root: its scenes, its plugins, its save and a coin count every scene shows.

games/example/src/app/app.tsx (part)
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;

room joins a room where the page’s address names one, gl picks the renderer, and ExampleDevtools loads the devtools in development alone. Readout, the coin count, is global UI: it sits inside Game and outside any Scene, so it shows in the lobby and on the run. plugins is the list src/game.ts exports, which Plugins describes, and save is the game’s save, as Stores and saves says.

The engine’s components import no stylesheet of their own. A game’s stylesheet imports the UI kit’s sheet first, then its own rules, as every template’s src/styles.css does:

src/styles.css
@import "@spawnite/ui/styles.css";
#root {
height: 100dvh;
}

Where the game’s own Tailwind classes and the kit’s sheet hold the same utility, such as flex-col, the game’s variants of it, such as sm:flex-row, win, because its rules come after the kit’s.

A game builds with Vite, and its vite.config.mts loads the engine’s plugin, spawnite(), once. A game made from a template already loads it. To add it to a Vite config of your own:

import { defineConfig } from "vite";
import react from "@vitejs/plugin-react";
import { spawnite } from "@spawnite/engine/vite";
export default defineConfig({
plugins: [react(), spawnite()],
});

The plugin does the following for every game, whatever the game uses:

  • Tells the engine whether the game runs in development, so the engine’s dev warnings print in development and not in a build a player opens.
  • Inlines each LYGIA include in a shader fragment when the game builds.
  • Writes each sound as a file of its own, rather than into the first chunk the page loads.
  • Points three’s Draco decoder at the one the engine loads, so the build ships one decoder.
  • Ships the game’s own copy of a model the engine also bundles, where public/assets holds the same bytes, as the frame page describes.
  • Warns when a built stylesheet still holds an @theme block, which the browser drops.
  • Writes the loading screen into the page, so it shows from the first paint while the scripts download. A page that is not a game leaves it out with spawnite({ loadingScreen: false }).
  • Names the game’s build in a built page, which the page sends as it joins a room, so a room of another build logs both and the page shows a note naming them, as Multiplayer says.
  • On the dev server, keeps each playtest as a replay. spawnite({ replay: { … } }) sets its budget and its screenshots, as Settings lists.
  • On the dev server, throttles the errors the page forwards to the terminal: uncaught errors and console.error lines. Vite forwards them when an agent runs the server, or when server.forwardConsole asks for it, and a frame that throws sends sixty a second: printed each time, they keep the server too busy to send the page the edit that fixes the frame. The terminal prints a repeated error once a second and at most ten distinct errors a second, then one line counting the ones it left out. Warnings and every other console level print as Vite forwards them.

The same config builds a published version of the game, its page and its server half, with the engine left for an engine release, as Publishing says. Game, not <CharacterSpawn>, listens to the movement keys for as long as it is mounted, whether or not the loop has loaded its physics yet, so a key held across a scene switch or a remount keeps walking the new character. While its world has a player’s character, Game also keeps the arrow keys and Space from the page, so they walk rather than scroll it; with no character, they stay the page’s.

Each Game keeps its own scene registry, a zustand store among its stores, so two Games on one page each draw their own scene. useScenes() reads it from anywhere under Game.

Game covers the canvas and the HUD with the engine’s loading screen from the page’s first paint until the first playable frame. A game needs no code for it. The screen lifts once, with a half-second fade, and a later scene’s load does not bring it back.

The screen shows a progress bar across its width, with the percentage at the bar’s end. The bar and the percentage show the same value: it never moves back, and it reads 100% only once the first playable frame has drawn, or 10 s after the other stages are in if the shaders never report done. Under the line, the screen names the stage the load waits on.

Stage The load waits on
page, Loading the game The page, its scripts and its stylesheet, until Game mounts.
assets, Loading models and textures The start scene’s files and the engine’s own, the physics and navmesh wasm and the scatter shapes table, which load side by side; every scene boundary drawn; and the start scene mounted.
room, Joining the room The room’s welcome, which brings her character. A game played alone has no such stage.
world, Building the world Her own character and the World’s ground drawn, each only where the scene has one.
shaders, Preparing shaders The scene’s shaders compiled, and then one whole frame drawn that compiled no new program.

A stage passes once its own wait is over and every stage before it has passed. Each stage takes the share of the line that its time took on the last load of the page on this device, and a first visit uses the times of a cold load of Holdfast. Within a stage, the line fills by what it can count, the files and the engine’s chunks loaded, and otherwise by its time against the time expected, and it never reaches the stage’s end before the stage passes. Games generally weigh their stages this way, by the time each took rather than by their count, so the line moves at an even pace.

The engine’s Vite plugin writes the screen into the page itself, so it shows while the scripts download, before any script runs. It carries no script, as the store’s pages run none inline, so a CSS animation moves its line toward a fifth. If the scripts have not arrived after 20 s, it says the game is taking longer than usual, with a link that reloads the page. Game takes it over where its line stands.

Nothing shows over the screen, and nothing plays under it:

  • The screen layer, with every Hud and slot Panel, stays hidden until the screen lifts, and shows as it fades.
  • The frame counter, the Performance stats row, waits for the lift.
  • The devtools draw nothing until the lift. Their store is on the page from their mount, so spawnite play stats, eval and dump read a game that is still loading.
  • The game’s sound holds: a running audio context suspends, and the player’s input starts it only once the screen has lifted.
  • The menu waits too, and Escape opens nothing.

A load that has passed no stage, landed no chunk and loaded no file for 20 s says it is taking longer than usual, with a Reload button, and a development page names what it waits on. The load goes on underneath, and the notice goes as soon as it moves. A room that closes before its welcome, or turns her away as full, says she could not join the game and why, with the same button, and the screen stays up. In the player app’s frame, a room game’s page loads while the app holds its room for her seat, and the screen waits at its room stage, without reading the held room as a failed join, until the app’s init hands the seat over. A game that throws shows the same screen with what went wrong, in place of the game; a development page rethrows, so the dev server’s overlay shows the error. A page the site published also sends the error to the platform’s error tracker, with the game, the version, the engine release, the scene, the tick, the system that threw and React’s component stack, and from the engine’s first load sends any error nothing caught, at most 10 a page; a page the site did not serve, such as a dev server’s or spawnite play’s, sends none, as the trust boundary says. In the player app’s frame, Reload loads the page a second time, which the app answers by removing the frame and saying the game stopped, as the trust boundary says.

Three compiles a material’s shader in the frame that first draws it, which can hold that frame for a tenth of a second or more. Game compiles the shaders of everything in the scene, drawn or hidden, in view or not, before the screen lifts, so an object the camera turns to later draws without a stall. Under the screen it starts a compile on every draw, so the shaders of what the screen hides build side by side rather than one after another. Once the other stages are in, it compiles the whole scene and uses each program once, then watches the frames: the first whole frame that compiles no new program is the first playable frame. A frame that does compile one, from something that mounted late, starts the compile again. The screen lifts after 10 s with a warning in the console if the compile never finishes. After the lift, Game compiles again after each later load and whenever post-processing is switched on or off. A light the game adds after the stages are in, with no file loading, changes the key of every lit shader, so each compiles again in the frame that next draws it: add a scene’s lights before its first draw. Unity and Unreal warm their shader variants at load for the same reason, and Godot 4 compiles its pipelines while the scene loads.

Three reads a program’s link status, uniforms and attributes in the first draw that uses it, and each read waits on the GPU process: a burst’s glow compiled ahead but never drawn held Holdfast’s first spawn for 71 to 79 ms on a production build, so Game makes those reads under the screen. Three also uploads a texture in the first draw that uses it, so Game uploads every texture a material in the scene holds, drawn or hidden, before the screen lifts: in about half of Holdfast’s runs, what the screen had not drawn uploaded 285 to 581 ms of textures in the first second of play. Something the game spawns later, with materials the scene does not hold yet, is compiled too when the game keeps one hidden in the scene. The hidden one also keeps those shaders when the last one drawn goes, as three drops a shader no material uses. The compile leaves out the shadow pass’s shaders, so a caster spawned later compiles its shadow’s shader in the frame that first draws it, unless the game draws the hidden one once, too small to see, after useLoading’s settled turns true, which is under the screen, or after its shadersCompiled does, in a game with no screen. Holdfast keeps a rig of each monster model there and draws each once.

Chrome builds a GPU program the first time each kind of effect in the page’s HTML and CSS draws, which holds the game’s next frames on a player’s first visit. Game draws the HUD and one of each of the engine’s own effects out of sight while the screen covers the game, and a game puts a copy of what its HUD shows later in a <WarmHud>, as the Hud page says. The engine’s effects go first, then each WarmHud in the order it mounted, for at most warmUpMilliseconds on Game, 6000 by default; 0 turns the warm-up off.

To dress the engine’s screen, pass loadingScreen its options. Each is optional:

  • title: the game’s name, large, over the line.
  • line: one line under the name.
  • tips: lines shown one at a time over the line, each for seven seconds.
  • art: the address of an image drawn behind the screen, filling it, such as a file in the game’s public folder. The screen darkens it toward the line, so the words stay readable.

The colors are the --color-loading-* variables, set on :root in the game’s stylesheet as the rest of the palette is. The page’s own screen reads them too, from its first paint. Its field and text colors are written into the page itself, with the house palette as the fallback, so the first paint is the dark field even before the stylesheet lands, as on the dev server, where it arrives with the scripts; the words show once it has:

Variable Colors
--color-loading-background The field, and the shade over the art.
--color-loading-bar The line’s fill.
--color-loading-track The line behind the fill.
--color-loading-text The name, the line and the percent.
--color-loading-label The stage, the tips and the notice.

Holdfast dresses its screen this way, shown here on the lobby:

src/AppWithDressedScreen.tsx
import {
Game,
Scene,
World,
type LoadingScreenOptions,
} from "@spawnite/engine";
function Lobby() {
return <World map="grid" />;
}
const loadingScreen: LoadingScreenOptions = {
title: "Holdfast",
art: `${import.meta.env.BASE_URL}loading.jpg`,
line: "Hold the circle till dawn",
tips: ["Two elements that meet on one monster set off a reaction."],
};
export function App() {
return (
<Game name="holdfast" start="lobby" loadingScreen={loadingScreen}>
<Scene name="lobby" component={Lobby} />
</Game>
);
}
src/styles.css
:root {
--color-loading-background: #020617;
--color-loading-bar: #fcd34d;
--color-loading-text: #fef3c7;
}

To replace the screen, pass a component as loadingScreen. Game hands it GameLoadingScreenProps: value, from 0 to 1, the stage and its label, a problem to show or null, and retry, which reloads the page. The Made with Spawnite notice stays at the foot of the page over it. Give it role="progressbar", as the engine’s screen has, so spawnite play profile waits for it to go. Your screen owns its exit: Game unmounts it inside Motion’s AnimatePresence the moment the game is ready, so to fade it, draw its root as a motion element with an exit, as the engine’s screen fades over half a second. To build on the engine’s own, draw LoadingScreen inside yours with the props it takes. To draw no screen, pass loadingScreen={false}: the page’s own screen then goes as Game mounts.

src/AppWithLoadingScreen.tsx
import {
Game,
Scene,
World,
type GameLoadingScreenProps,
} from "@spawnite/engine";
function Lobby() {
return <World map="grid" />;
}
function Countdown({ value, label }: GameLoadingScreenProps) {
const percent = Math.floor(value * 100);
return (
<p role="progressbar" aria-valuenow={percent}>
{label} {percent}%
</p>
);
}
export default function App() {
return (
<Game name="sled" start="lobby" loadingScreen={Countdown}>
<Scene name="lobby" component={Lobby} />
</Game>
);
}

While the player’s character waits to respawn, Game draws the engine’s RespawnScreen where the loading screen stands: the game dimmed under “Back in 3” and a line that empties as the wait runs out, in the loading screen’s colours. It shows only her own wait, never another player’s, and goes the step she comes back. A game whose respawn is off never shows it.

To replace it, pass a component as respawnScreen. Game hands it RespawnScreenProps: seconds, the whole wait, and secondsLeft, counting down to 0. To build on the engine’s own, draw RespawnScreen inside yours. To draw none, such as a game whose HUD shows the wait itself, pass respawnScreen={false}.

src/AppWithRespawnScreen.tsx
import { Game, Scene, World, type RespawnScreenProps } from "@spawnite/engine";
function Lobby() {
return <World map="grid" />;
}
function Knockout({ secondsLeft }: RespawnScreenProps) {
return <p role="timer">Knocked out: {Math.ceil(secondsLeft)}</p>;
}
export default function App() {
return (
<Game name="sled" start="lobby" respawnScreen={Knockout}>
<Scene name="lobby" component={Lobby} />
</Game>
);
}

The volume sliders in Settings start at 100, the game’s own mix, except music, which starts at 60, 20 dB under the effects. To start them elsewhere, pass defaultVolumes, such as defaultVolumes={{ musicVolume: 100 }} for a game whose music file is mixed under its effects already. Reset audio returns to these volumes, and a volume the player saved on this device keeps its value. Settings lists the volumes and where the top games start them.

A game that joins a room has voice, and the engine draws its microphone button in the top-left corner. Every player’s microphone starts off, and she holds V to talk; defaultVoiceMode={VoiceMode.OpenMic} makes her key turn the microphone on and off instead, and a mode the player saved keeps its value. defineRoom({ voice: false }) turns voice off for the game: no microphone, no voice rows and no button. setVoice(step, { enabled: false }) turns it off for a phase, such as a round. Voice covers the rest. A game that joins a room also has text chat, filtered for each player by the room; defineRoom({ textChat: false }) turns it off, and defineTextChat, exported beside room from the scene’s file, declares its channels and who receives a message.

Game draws with three’s WebGLRenderer, made as React Three Fiber makes it, with percentage-filtered shadows. Its gl prop takes the settings the renderer is made with, such as { stencil: true }, or a factory that makes the renderer; shadows picks the shadow filter; and onCreated runs once the canvas has made its renderer. Game reads the three once, when it mounts. Your renderer and your own three.js describes them, and WebGPU (experimental) mounts a game on three’s WebGPURenderer.

A page can mount the engine to draw something that is not a game, such as the model viewer on the tools app. Four settings leave out what a game’s page has and such a page does not want:

  • menu={false} on Game mounts no Play menu. Escape and leaving fullscreen then open nothing, and Escape reaches the page’s own handlers.
  • loadingScreen={false} on Game draws no loading screen over the canvas.
  • spawnite({ loadingScreen: false }) in the page’s Vite config writes no loading screen into the page.
  • A Game with no player’s character in its world leaves the arrow keys and Space to the page, so a page that scrolls still scrolls.

<ModelFile url> draws a GLB or VRM file with no entity and no World around it: stood on its lowest point with its origin across the ground, a VRM 0 avatar turned to face +z as a VRM 1 avatar faces, casting and receiving shadows, and a VRM’s springs updated each frame. animation names the clip it plays, from the file’s own clips or from clips, such as clips made from VRMA files for an avatar; loop={false} plays the clip once and holds its last frame. onPlay hands over the playing action, for a page that pauses or scrubs it, and onLoad hands over the model’s bounds as it stands, for a camera that frames it. It draws the loaded file itself rather than a copy, since a VRM’s springs belong to the file’s own bones, so two on one page take two URLs. The props are ModelFileProps.

src/ModelPreview.tsx
import { Game, ModelFile, Scene } from "@spawnite/engine";
function Stage() {
return <ModelFile url="/models/duck.glb" animation="walk" />;
}
export function ModelPreview() {
return (
<div style={{ height: 400 }}>
<Game
name="preview"
start="stage"
menu={false}
loadingScreen={false}
>
<Scene name="stage" component={Stage} />
</Game>
</div>
);
}