- name
- mirage-game
- description
- Make a Mirage world playable as a small game with @mirage/game - interactables (pick up, use, open, light), inventory, flags, events, hinged doors and pushable props (Rapier physics, lazy WASM), a character controller, keyboard / mouse / gamepad / touch input, procedural 3D sound, save / load and an accessible HTML overlay (objective, prompt, toasts, menu). Use when the user wants something to do in a world - "add a lever / key / door / quest", "make it a game", "pick up", "inventory", "save game", "sound", "HUD" - or a scenario needs near / prompt / flags.
Game layer
Reference implementation: $MIRAGE/examples/garden-quest (main.js, quest.js); API: packages/game/README.md.
Modules stay pure world (geometry + anchors). The game is one lead-owned file, game.js, that spawns entities
from anchors, so module owners never touch game code and the game never hard-codes a position.
1. Switch main.js to startGame
import { startGame } from '@mirage/game'; // add "@mirage/game": "0.1.0" to package.json
import { setupGame, objective } from './game.js';
// ... createWorldRenderer, buildWorld, camera as before; remove world.prepare() and createPlay (startGame does both)
const g = await startGame(world, {
built, canvas, id: plan.name, version: 1, title: 'Title', subtitle: 'one line: what to do',
spp: +Q.get('spp') || 6, shadowRadius, setup: setupGame, objective,
});
if (Q.has('start')) g.ui.begin(); // skip the start screen (tests: "query": "start=1")
window.__mirage = { world, built, play: g.play, g };
window.__READY = true;
startGame builds Rapier colliders from the modules' solid meshes (same rule as the walk grid, proxies
included), adds a game group (collide = false), runs setup, compiles, places the character at
plan.spawn, and starts play with the action map and the game tick.
2. game.js: entities from anchors
import * as THREE from 'three';
export function setupGame({ game, physics, audio, root, plan, anchors, world }) {
const [a] = anchors.levers || []; // offered by a module; tolerate it missing
if (!a) return;
const lever = new THREE.Mesh(new THREE.BoxGeometry(0.06, 0.5, 0.06), world.mat.std({ color: 0x222222, metalness: 0.8, roughness: 0.4 }));
lever.position.set(...a.pos); root.add(lever);
const light = new THREE.PointLight(0xff9a50, 0, 12, 2); light.position.set(a.pos[0], a.pos[1] + 2, a.pos[2]); root.add(light);
const setLit = (on) => { light.intensity = on ? 3 : 0; game.dirty = true; }; // dirty: re-render the still frame
game.add({
id: 'lever', object: lever,
interact: { label: (g) => (g.flags.lit ? 'Turn the light off' : 'Light the lamp'), reach: 2.4,
use: (g) => { g.set('lit', !g.flags.lit); audio.play('ignite', { at: a.pos }); game.ui.toast(g.flags.lit ? 'Lit' : 'Dark'); } },
save: () => ({ lit: !!game.flags.lit }), load: (s) => setLit(s.lit),
update: (dt) => { /* animate; set game.dirty = true while something moves, or the frame counts as still */ },
});
game.on('flag', ({ key, value }) => { if (key === 'lit') setLit(value); });
audio.loop('wind', { bus: 'ambience', gain: 0.8 });
}
export const objective = (game) => (game.flags.lit ? ['Done', 'The lamp is lit.'] : ['Light the lamp', 'Find the lever.']);