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.
Game draws one canvas. The engine’s frame loop and the active scene render inside it; Game’s children render as DOM after it. Game, not the Player, 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 hero. The scene registry is a zustand store under the engine’s stores/, and useScenes() reads it from anywhere under Game.
Loading screen
Section titled “Loading screen”Game covers the canvas and the Hud with the platform’s loading screen from the moment it mounts until the world can step and draw. A game needs no code for it. The screen lifts once, and a later scene’s load does not bring it back.
The bar counts five steps: the physics wasm, the navmesh wasm, the scatter shapes table, the start scene mounting, and the scene’s first models drawn. Those models are the player’s own hero and the World’s ground, each only when the scene has one, so a scene with no Player or no World does not wait for it. In a room, the screen also waits for the welcome that brings her hero, or for the room to close. It lifts once the last step is in and the loop’s last frame found three’s loaders idle.
To replace the screen, pass a component as loadingScreen. Game hands it value, from 0 to 1, as GameLoadingScreenProps. Give it role="progressbar", as the platform’s screen has, so game play profile waits for it to go. To draw no screen, pass loadingScreen={false}.
import { Game, Scene, type GameLoadingScreenProps,} from "@daniel-zarinski/engine";import { Lobby } from "./scenes/Lobby";
function Countdown({ value }: GameLoadingScreenProps) { const percent = Math.round(value * 100); return ( <p role="progressbar" aria-valuenow={percent}> Loading {percent}% </p> );}
export default function App() { return ( <Game name="sled" start="lobby" loadingScreen={Countdown}> <Scene name="lobby" component={Lobby} /> </Game> );}Stores and hooks scope: game
Section titled “Stores and hooks scope: game ”useWallet, useScenes, useInput, useTime and useLoading read stores the engine owns. usePlayer queries the world for the player’s character instead, and useRound queries it for the round. A game’s own state comes from the engine’s createStore, and a game reads it the same way.
The stores’ shapes are WalletState, InputState, TimeState and LoadingState; usePlayer returns a PlayerState and useRound a RoundStatus.
Game builds the save store and the quality store, and keeps them in the browser’s storage under its name: sled-save and sled-quality for the sample below. Two games on one origin therefore keep separate saves. Anything under Game reads them with useSaveStore() and useQualityStore(), which return the stores for useStore from zustand. A scene mounted headless reads stores of its own that start empty and write to no storage. A game that renames itself loses its players’ saves, so a game keeps its name once players hold one.
A save holds the parts a game has, each optional: the hero, the camera, the player’s wallet of coins, and the game’s own fields. writeSave stores one, and the store’s save holds what it wrote last, so a HUD reads a banked total from it.
Systems
Section titled “Systems”Each fixed step runs the world’s systems in order. A system is a function of the world and the step, (world, { deltaSeconds, input }) => void. Without the systems prop, Game runs the engine’s own: baseSystems, a list for each place in the step (input, move, behaviours and resolve), in that order.
A game adds a system by passing the whole list, built from baseSystems, with its own system at the place it belongs. The harness takes the same list, so a system runs in the browser and in a headless test alike:
import { baseSystems, HealthTrait, type System,} from "@daniel-zarinski/engine/core";
/** Heals everything with health a point a second, up to its maximum. */const regenerateHealth: System = (world, { deltaSeconds }) => { world.query(HealthTrait).updateEach(([health]) => { health.current = Math.min( health.maximum, health.current + deltaSeconds, ); });};
export const systems: System[] = [ ...baseSystems.input, ...baseSystems.move, ...baseSystems.behaviours, regenerateHealth, ...baseSystems.resolve,];<Game name="sled" start="lobby" systems={systems}>const game = await createHeadlessGame({ scene: spawnCoins, systems });Game reads systems once, when it mounts. A system runs inside the step, so it creates no three.js object and no array on each call.
The step’s profile names a system for its function, regenerateHealth above; nameSystem("behaviours.regenerate", regenerateHealth) gives it another name. The engine’s systems carry their place and function, such as resolve.moveActorsThroughPhysics. A headless game made with systems times them once its world has the StepProfile trait, and readStepProfile(game.world) reads the times. game simulate --profile mounts the scene on the engine’s systems alone.
Sample
Section titled “Sample”import { Game, Scene, Hud, Panel, Slot, Text, useWallet,} from "@daniel-zarinski/engine";import { Lobby } from "./scenes/Lobby";import { Run } from "./scenes/Run";
export default function App() { const wallet = useWallet();
return ( <Game name="sled" start="lobby"> <Scene name="lobby" component={Lobby} /> <Scene name="run" component={Run} /> <Hud> <Panel slot={Slot.TopRight}> <Text>Coins {wallet.coins}</Text> </Panel> </Hud> </Game> );}The coin count is global UI: it sits inside Game and outside any Scene, so it shows in the lobby and on the run.