Levels
A game lists its levels in order with defineLevels, and passes the list to Game’s levels. The engine keeps the player’s progress through them: the levels the player has finished, each one’s best, and the levels the player can play. A won round records itself against the level being played. The game writes the list and nothing else, and a reload keeps the progress.
Roblox, Unity, Unreal and Godot leave this to the game. A Roblox game invents its own DataStore keys, Unity’s PlayerPrefs and Unreal’s USaveGame are empty containers that the game fills, and a Godot game writes its own. Level-based games such as Candy Crush, Angry Birds and Mario Kart keep the same three facts for each level: finished, the best result, and unlocked. The engine keeps those three, so no game writes the bookkeeping again.
Listing the levels
Section titled “Listing the levels”defineLevels({ One: "level-1", Two: "level-2" }) names each level and gives its id. The order of the keys is the order of play. The object it returns reads like an enum, Track.Two is "level-2", and each id keeps its literal type. It throws for an empty list, and for an id listed twice.
Unlocking and the best
Section titled “Unlocking and the best”- The first level is always unlocked. Each level after it unlocks once the level before it is finished.
- A won round finishes the level being played. A lost round records nothing.
- A game whose win is no round, such as a puzzle solved, finishes the level itself with
finishLevel(world, { score, seconds })from a system. The round calls the same function as it is won. - A level’s best is the highest score and the fewest seconds of any round that won it, each kept on its own. The score is the coins the round counted while it played, and the seconds are the simulated seconds it played. A race shows the seconds, and a game of collecting shows the score.
Playing a level
Section titled “Playing a level”useLevels() returns a LevelsStatus:
levels: each level in order, as aLevelStatuswith its id, whether it is finished and unlocked, and its best.current: the id of the level being played. It is never empty: the first level until the save says otherwise.next: the id of the level aftercurrent, or null after the last.
The scene builds the level that current names. A game opens on the first level not finished, or on the first level once every one is. useLevels() throws in a game whose Game has no levels.
Choosing a level
Section titled “Choosing a level”The level changes on the step that decides outcomes: playLevel(world, id), from @spawnite/engine/core, makes an unlocked level the current one. It returns false, and changes nothing, for a locked level, and throws for an id the game does not list. A page asks for a level with a command the game declares, and the system that answers it calls playLevel and refuses locked where it returns false. Once the command is accepted, a level select in another scene, such as a lobby, opens the level’s scene with useScenes().go(name), as Scene says, and a button inside the level’s own scene starts it again with useScenes().reload().
The round’s screen leads on with a “Next level” button among its actions, shown once the round is won and while next names a level. It sends the command for next, which a won round has unlocked, and starts the race scene again on it once the command is accepted.
The engine’s tests open the race from the lobby on level 1, win it, and press Next level:
import { useWorld } from "koota/react";import { Button, CommandStatus, defineCommand, defineLevels, definePlugin, Hud, oneOf, Panel, playLevel, readCommands, sendCommand, Slot, Text, useLevels, useScenes, type AuthoritativeStep, type LevelId,} from "@spawnite/engine";import { Round, RoundScreen, RoundState, useRound,} from "@spawnite/engine/rounds";
export const Track = defineLevels({ One: "level-1", Two: "level-2", Three: "level-3",});
/** Plays the level each play command names, where it is unlocked, on the * step that decides outcomes. */function playLevels(step: AuthoritativeStep) { for (const command of readCommands(step, levelSelect.commands.play)) { if (playLevel(step.world, command.payload.level)) command.accept(); else command.refuse("locked"); }}
/** The level switch a page asks for: a command, answered on the step that * decides outcomes, refused `locked` for a level not unlocked yet. */export const levelSelect = definePlugin({ name: "level-select", commands: { play: defineCommand( // The game's level ids: each a `LevelId`, which is any string // until the game registers its levels. { level: oneOf<LevelId>(Object.values(Track)) }, { refusals: ["locked"] }, ), }, systems: { rules: { playLevels: { system: (_world, step) => playLevels(step), answers: ["play"], }, }, },});
/** The lobby scene: a button for each unlocked level, which opens the race * scene on it once the level switch is accepted. */export function Lobby() { const world = useWorld(); const scenes = useScenes(); const { levels } = useLevels(); return ( <Hud> <Panel slot={Slot.Center}> {levels.map(({ id, unlocked }) => unlocked ? ( <Button key={id} onPress={async () => { const { status } = await sendCommand( world, levelSelect.commands.play, { level: id }, ); if (status === CommandStatus.Accepted) scenes.go("race"); }} > {id} </Button> ) : ( <Text key={id}>{`${id}, locked`}</Text> ), )} </Panel> </Hud> );}
/** The race scene: its round, and a Next level button on the round's * screen once it is won, while a level is left, which starts the scene * again on that level. */export function Race() { const world = useWorld(); const scenes = useScenes(); const { next } = useLevels(); const { state } = useRound(); const playNext = async (level: LevelId) => { const { status } = await sendCommand(world, levelSelect.commands.play, { level, }); if (status === CommandStatus.Accepted) scenes.reload(); }; const nextLevel = state === RoundState.Won && next !== null ? ( <Button onPress={() => void playNext(next)}>Next level</Button> ) : null; return ( <> <Round seconds={60} target={8} /> <RoundScreen actions={nextLevel} /> </> );}Register the levels’ type beside defineLevels, as Typing the ids says, so playLevel and the command’s level field take only the game’s ids.
Showing the level
Section titled “Showing the level”<LevelBanner>, from @spawnite/engine/rounds, names the level being played and the count, “Level 2” over “of 4”, in the centre slot while the round is ready. It hides once the round starts, and it draws nothing in a game with no levels. Two short lines fit the centre of a phone, which is about 114 px wide between the other slots. Pass noun to call the levels something else: sled passes "Track". The round screen names the level too, on one line above its title.
Its props are in LevelBannerProps.
A screen of the game’s own reads the same count with useLevelPlace(): { number, count }, with number counted from 1, or null in a game with no levels.
Roblox, Unity, Unreal, Godot and Phaser leave a level card to the game. Mario Kart names the course before the race and shows nothing about it during the race, and the banner does the same: the player reads it on the start line, not while steering.
Typing the ids
Section titled “Typing the ids”A game registers its levels once, beside defineLevels:
declare module "@spawnite/engine" { interface Register { levels: typeof Track; }}The engine exports an empty Register interface, and this line adds the game’s levels to it. From then on current, next, each level’s id and the argument of playLevel take only the game’s ids, as the LevelId type, so playLevel(world, "lvl-2") fails to compile where the game lists "level-2". Without the line, each of them is any string. TanStack Router types its routes the same way, through its own Register. Roblox, Unity, Unreal and Godot name a level by a plain string or an asset reference.
A game that forgets the line still compiles, so it keeps a test that fails without it:
import type { LevelId } from "@spawnite/engine";import { expectTypeOf, it } from "vitest";import type { Track } from "../src/levels";
it("registers its levels, so useLevels types each id", () => { expectTypeOf<LevelId>().toEqualTypeOf<(typeof Track)[keyof typeof Track]>();});The test fails the typecheck, not the test run: without the line, LevelId is string.
The save
Section titled “The save”The engine saves the finished levels only where the game’s save names levels in include, as Saving progress says: by id, each with its best score and seconds, under engine.levels. The save restores them as it loads, and a won round that changes them asks for a write within 2 seconds, so a game writes no code for either. A game that does not include levels starts on the first level at each load. A game with rooms keeps none: its levels are the room’s.
The save finds a level by its id, so a game keeps each id once players hold a save. A level’s name, the key in defineLevels, can change.
Where it runs
Section titled “Where it runs”Client, and headless on the first level. The progress is the player’s own and lives in the browser’s save, so Game spawns the levels in the player’s world, and a room keeps no progress. A room mounts its scene headless, and the mount spawns the levels the last defineLevels call made, so a scene that calls useLevels() mounts in a room too. The headless mount plays the first level whatever the page’s save holds, so a game with levels plays level 1 in a room. The room’s stream carries none of them. A won round records the level in the step that decides it, or in finishRound. Game spawns the levels on an entity of their own before anything mounts, outside every scene, so a scene’s reload keeps the level being played. A system reads them with listLevels(world) and nextLevel(world), and changes the level with playLevel(world, id), all from @spawnite/engine/core. createHeadlessGame takes the same levels as Game, so a headless test reads current as spawnite play does in the browser.