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.

bash
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.

FieldMeaning
world: World | nullThe world the screen draws.
camera: CameraPassed 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.
MyGameGame.ts
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 useEngineGame
onEvent: (_e: GameEvent) => {}, // assigned by useEngineGame
hudFeedback: {} as HudFeedback
};
world.scoreboard.reset(0, TIME_LIMIT);
world.scoreboard.handlers = { onTimeUp: () => g.phase === 'playing' && end(g, true) }; // rules only
return 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 entities
g.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 fieldWhen it runs
gameIdGAME_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 / onOverFeedback hooks: fx(sound, haptic).
resultsDelay?(status)Milliseconds before the results card.
ReturnsValue
gameThe object from create.
theme / phase / statusCurrent GameTheme, GamePhase and result.
boardLive mirror of world.scoreboard: { score, seconds, lives }.
fx(sound, haptic)Feedback that respects settings and stays silent in the modal.
screenProps 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.

PropPurpose
{...screen}Source, camera, playing, input, lifecycle and share/leaderboard/banner props.
columnsHudColumn[]: scoreColumn, timerColumn(seconds, theme), livesColumn(n, max), textColumn.
subtext?Line under Start/Stop while playing.
gameOvergameOver({ 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.
childrenReact views over the scene, hidden under the results card.
GameView propslight, background (theme default), paused and the rest.
MyGame.tsx
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.

HelperUse
effects (engine)confetti, sparkle, shatter, splash, sparks, explosion, dust, flash, pop.
shared/EngineBlastspopAndBurst for cleared pieces, popBurst for pieces that stay.
shared/EngineBlocksspawnRaisedBlock and raisedBlockScale(size) for glossy board pieces; light with frontLight.
shared/EngineMeshescreateGameMesh, meshCache('<folder>/<File>:'), cacheGameAsset; keys need the folder prefix.
shared/EngineBackdrop, EngineTiltThemed backdrop; tilted 2.5D view (aimTilted, project).
shared/EngineUtilstoColor and frontLight (from the engine), gameWidthFor.
shared/useLiveStateuseState that updates while the frame loop keeps JS busy.
shared/EngineTouchSurfaceRaw multi-touch inside getSceneInputInsets(offset).
world.transformsAllocation-free per-frame moves; flush() only before queries.
quatx, y, z, axisAngle, fromEuler, multiply, slerp (optional out).
world.scoreboardreset, startCountdown, startClock, add, addLives, stop, resume; handlers for rules, listen for observers.
world.after / cancelTimersGame-clock delays that pause with the view.
world.isDisposedGuard only spawn, update, scoreboard, audio and React callbacks.
MyGameScene.ts
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 key
const 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 tuple
export function moveBlocks(world: World, blocks: { entity: Entity; x: number; y: number; angle: number }[]) {
const t = world.transforms; // allocation-free, applied when the world steps
for (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.

tsx
// services/UtilsService.ts
export 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.ts
MY_GAME: { START: { sfx: '...' }, WIN: { sfx: '...' } /* ... */ },
// src/index.tsx
export * from './games/my-game/MyGame';
bash
bun run games:test
bun check -p packages/games/tsconfig.build.json
bun run games:build