Build an engine game
Library games pair plain TypeScript rules on a @nayan-ui/engine world with a small screen on the shared HUD. These modules are internal, so use this pattern in the library or a fork; for your own app, follow the engine game guide and the lifecycle rules below.
1. Files
One folder per game under src/games/<game-id>/; Stack Tower is the smallest example.
src/games/stack-tower/├── StackTower.tsx # screen: engineGame + useEngineGame + EngineGameScreen├── StackTowerConstants.ts # tuning, timings, colours, layout, light├── StackTowerGame.ts # rules, no React: createGame / start / reset / tick / tap└── StackTowerScene.ts # cached meshes, spawning, animation, theme repaint# Larger games add <Name>Rules.ts (pure, testable) and <Name>Meshes.ts (cached models)
2. The game module
createGame returns an EngineGameModel; one world serves the whole visit and is reset in place.
| Field | Meaning |
|---|---|
| world: World | null | The world the screen draws. |
| camera: Camera | Passed to GameView. |
| phase | 'ready' | 'playing' | 'over' (GamePhase). |
| onPhase(phase) | Assigned by the hook; call on every change. 'over' reports the result. |
| score? | Reported unless spec.reportScore is set. |
| won? | Default outcome at 'over'. |
| onEvent?(event) | Assigned when the spec has events; plays that event's sound and haptic. |
| hudFeedback? | { message?, flash? }, read by the HUD each frame without a render. |
import { type Camera, World } from '@nayan-ui/engine';import type { GameTheme } from '../../services/ThemeService';import type { HudFeedback } from '../shared/EngineHud';import type { GamePhase } from '../shared/useEngineGame';import { POINTS, TIME_LIMIT } from './MyGameConstants';import { buildScene, setSceneTheme, type Scene } from './MyGameScene';export type GameEvent = 'hit' | 'miss';export function createGame(theme: GameTheme) {const world = new World(400);const camera: Camera = { eye: [0, 0, 240], target: [0, 0, 0], fov: 1, ortho: 1 };const g = {world, camera, theme,scene: null as Scene | null,phase: 'ready' as GamePhase,won: false,get score() { return world.scoreboard.score; },onPhase: (_p: GamePhase) => {}, // assigned by useEngineGameonEvent: (_e: GameEvent) => {}, // assigned by useEngineGamehudFeedback: {} as HudFeedback};world.scoreboard.reset(0, TIME_LIMIT);world.scoreboard.handlers = { onTimeUp: () => g.phase === 'playing' && end(g, true) }; // rules onlyreturn g;}export type Game = ReturnType<typeof createGame>;export const setLayout = (g: Game, width: number, height: number) => { g.scene = buildScene(g.world, width, height, g.theme); };export function setTheme(g: Game, theme: GameTheme) {g.theme = theme;if (g.scene) setSceneTheme(g.world, g.scene, theme); // repaint, keep progress}export function start(g: Game) {if (g.phase !== 'ready') return;g.phase = 'playing';g.world.scoreboard.reset();g.world.scoreboard.startCountdown(TIME_LIMIT);g.onPhase('playing');}export function reset(g: Game) {g.world.cancelTimers();// ...despawn this run's entitiesg.world.scoreboard.reset(0, TIME_LIMIT);g.won = false;g.phase = 'ready';g.hudFeedback.message = undefined;g.onPhase('ready');}export function tick(g: Game, dt: number) {// ...this frame's movement; the world steps after}export function tap(g: Game, x: number, y: number) {if (g.phase !== 'playing') return;g.world.scoreboard.add(POINTS);g.onEvent('hit');}function end(g: Game, won: boolean) {g.world.scoreboard.stop();g.won = won;g.phase = 'over';g.onPhase('over'); // the hook reports win / lose once}
3. The screen
engineGame(id, Component) returns the exported game: memoised, with default settings, theme and the engine loading screen.
useEngineGame(props, spec)
Creates the game, steps and disposes its world, and reports each run once. Import GamePhase from shared/useEngineGame rather than redeclaring it.
| Spec field | When it runs |
|---|---|
| gameId | GAME_IDS value; preloads its GAME_SOUNDS. |
| create(theme) | Once per mount: world, camera, state. |
| start(g) | Start pressed: 'ready' → 'playing'. |
| reset(g) | Stop (after cancel is reported) and, by default, Play Again. |
| replay?(g) | Play Again, when it differs from reset. |
| dispose?(g) | Unmount: non-world resources only; the hook disposes the world. |
| tick?(g, dt) | The game's own frame work; the world steps after. Never runs after disposal. |
| setTheme?(g, theme) | Repaint in place, keep progress. |
| setActive?(g, active) | false in background, settings or pause; clear held input. |
| resize?(g, w, h, offset) | Lay out for the view; may return the game width. |
| tap?(g, x, y) / drag?(g, e) | Touch in view points; e.phase is 'start' | 'move' | 'end'. |
| hud?(g, hud) | Every frame, for games that drive EngineHud directly. |
| outcome?(g) | 'win' | 'lose' | 'draw' (default g.won); a draw reports cancel. |
| reportScore? / reportData? | Score and additionalData for the result. |
| events? | { hit: { sound, haptic } } for each g.onEvent(event). |
| wire?(g, fx) | Once, for callbacks events and board don't cover; may return a cleanup. |
| sounds? | SoundConfig map preloaded before the first sound. |
| onStart / onStop / onPlayAgain / onOver | Feedback hooks: fx(sound, haptic). |
| resultsDelay?(status) | Milliseconds before the results card. |
| Returns | Value |
|---|---|
| game | The object from create. |
| theme / phase / status | Current GameTheme, GamePhase and result. |
| board | Live mirror of world.scoreboard: { score, seconds, lives }. |
| fx(sound, haptic) | Feedback that respects settings and stays silent in the modal. |
| screen | Props to spread on EngineGameScreen. |
| gameOver(card) | The results card, or null until it is due. |
EngineGameScreen
Renders GameView with the HUD, results card, banner and settings modal, and handles pause, mute and the back gesture while playing.
| Prop | Purpose |
|---|---|
| {...screen} | Source, camera, playing, input, lifecycle and share/leaderboard/banner props. |
| columns | HudColumn[]: scoreColumn, timerColumn(seconds, theme), livesColumn(n, max), textColumn. |
| subtext? | Line under Start/Stop while playing. |
| gameOver | gameOver({ title, message?, scoreLabel, score, formatter?, buttonText }). |
| choices? | HudChoice buttons: id, label, selected?, onPress. |
| hudCapacity? | Extra overlay entities. |
| onAudioChange? | Suspend or resume a game-owned audio loop. |
| children | React views over the scene, hidden under the results card. |
| GameView props | light, background (theme default), paused and the rest. |
import { HapticType } from '../../services/HapticsService';import { GAME_SOUNDS } from '../../services/SoundsService';import { GAME_IDS } from '../../services/UtilsService';import { engineGame, EngineGameScreen } from '../shared/EngineGameScreen';import { scoreColumn, timerColumn } from '../shared/EngineHud';import { useEngineGame } from '../shared/useEngineGame';import { LIGHT } from './MyGameConstants'; // e.g. frontLight([-0.45, 0.6, 1], 0.5)import { createGame, reset, setLayout, setTheme, start, tap, tick } from './MyGameGame';const SOUNDS = GAME_SOUNDS.MY_GAME;export const MyGame = engineGame(GAME_IDS.MY_GAME, (props) => {const { board, theme, status, screen, gameOver } = useEngineGame(props, {gameId: GAME_IDS.MY_GAME,create: createGame,start, reset, tick, setTheme,resize: (g, width, height) => setLayout(g, width, height),tap: (g, x, y) => tap(g, x, y),sounds: SOUNDS,events: {hit: { sound: SOUNDS.HIT, haptic: HapticType.LIGHT },miss: { sound: SOUNDS.MISS, haptic: HapticType.ERROR }},onStart: (fx) => fx(SOUNDS.START, HapticType.SUCCESS),onOver: (fx, s) => fx(s === 'win' ? SOUNDS.WIN : SOUNDS.GAME_OVER, s === 'win' ? HapticType.SUCCESS : HapticType.ERROR)});return (<EngineGameScreen{...screen}light={LIGHT}columns={[scoreColumn(board.score), timerColumn(board.seconds, theme)]}subtext="Tap to play!"gameOver={gameOver({title: status === 'win' ? 'AMAZING!' : 'GAME OVER',scoreLabel: 'Final Score',score: board.score,buttonText: 'Play Again!'})}/>);});
4. The scene and helpers
Build models in code (lathe, tube, extrude, merge, loft, block, ellipsoid) once per app; mesh ids are never freed. Tint a white body per entity and keep coloured parts separate.
| Helper | Use |
|---|---|
| effects (engine) | confetti, sparkle, shatter, splash, sparks, explosion, dust, flash, pop. |
| shared/EngineBlasts | popAndBurst for cleared pieces, popBurst for pieces that stay. |
| shared/EngineBlocks | spawnRaisedBlock and raisedBlockScale(size) for glossy board pieces; light with frontLight. |
| shared/EngineMeshes | createGameMesh, meshCache('<folder>/<File>:'), cacheGameAsset; keys need the folder prefix. |
| shared/EngineBackdrop, EngineTilt | Themed backdrop; tilted 2.5D view (aimTilted, project). |
| shared/EngineUtils | toColor and frontLight (from the engine), gameWidthFor. |
| shared/useLiveState | useState that updates while the frame loop keeps JS busy. |
| shared/EngineTouchSurface | Raw multi-touch inside getSceneInputInsets(offset). |
| world.transforms | Allocation-free per-frame moves; flush() only before queries. |
| quat | x, y, z, axisAngle, fromEuler, multiply, slerp (optional out). |
| world.scoreboard | reset, startCountdown, startClock, add, addLives, stop, resume; handlers for rules, listen for observers. |
| world.after / cancelTimers | Game-clock delays that pause with the view. |
| world.isDisposed | Guard only spawn, update, scoreboard, audio and React callbacks. |
import { effects, extrude, merge, Path, quat, type Entity, type World } from '@nayan-ui/engine';import { createGameMesh, meshCache } from '../shared/EngineMeshes';const cached = meshCache('my-game/MyGameScene:'); // folder-prefixed keyconst block = () =>cached('block', () =>createGameMesh(merge([{ geometry: extrude(new Path().addRoundedRect(-20, -10, 40, 20, 4), { depth: 16 }) }]), { shine: 0.4 }));export const spawnBlock = (world: World, x: number, y: number, color: string): Entity =>world.spawn({ mesh: block(), position: [x, y, 0], color }); // CSS string or 0..1 tupleexport function moveBlocks(world: World, blocks: { entity: Entity; x: number; y: number; angle: number }[]) {const t = world.transforms; // allocation-free, applied when the world stepsfor (const b of blocks) {const [qx, qy, qz, qw] = quat.z(b.angle);t.transform(b.entity, b.x, b.y, 0, qx, qy, qz, qw);}}export const celebrate = (world: World, x: number, y: number) =>effects.confetti(world, { position: [x, y, 10], colors: ['#FFD43B', '#FF6BD6'], size: 4, speed: 160, count: 40, lifetime: 1.2, gravity: -300 });export async function clearPiece(world: World, entity: Entity, color: string) {await effects.pop(world, entity, { scale: 1, color });world.despawn(entity); // safe no-op after disposal}
5. Lifecycle rules
- ready → playing → over, always through g.onPhase; start only from 'ready'.
- End a run by stopping the scoreboard, setting won and calling onPhase('over'); never call onEndGame.
- reset clears timers, entities, scoreboard, hudFeedback and input, then onPhase('ready').
- Drive frames from tick, world.after and engine animations, never setInterval or requestAnimationFrame.
- Show score, lives and time from board; keep only rule handlers in scoreboard.handlers.
- setTheme repaints without resetting; settings.theme === 'dark' picks night scenes.
- Clear held input in setActive(false).
- Use g.onEvent with spec events, or fx, for sounds and haptics.
- Cache meshes, textures and fonts at module scope; spawn new entities per run.
6. Register and verify
Add the game to each registry, and to tests/lifecycle.test.ts; expensive models can add a stage in services/GameAssets.ts.
// services/UtilsService.tsexport enum GAME_IDS { /* ... */ MY_GAME = 'my-game' }// services/GamesMetadata.ts[GAME_IDS.MY_GAME]: { id: GAME_IDS.MY_GAME, title: 'My Game', description: 'One sentence.', instructions: ['Tap to drop.'], categories: ['arcade'] },// services/GamesService.ts[GAME_IDS.MY_GAME]: { ...GAMES_METADATA[GAME_IDS.MY_GAME], component: MyGame },// services/GameLoaders.ts[GAME_IDS.MY_GAME]: () => require('../games/my-game/MyGame').MyGame,// services/SoundsService.tsMY_GAME: { START: { sfx: '...' }, WIN: { sfx: '...' } /* ... */ },// src/index.tsxexport * from './games/my-game/MyGame';
bun run games:testbun check -p packages/games/tsconfig.build.jsonbun run games:build