ContributeExplanationFor contributorsApplies from 4.1.15
Architecture
Cette page en françaisThis page on the GitHub wiki
How quest'n'click is put together, for someone reading its code for the first time. The Code tour
is the same map walked in order, half an hour with the code open; docs/dev/adr/
explains the decisions that look surprising without their context (Decisions and ADRs).
Overview
The engine is one code path that a phone plays, the solver explores, the replay re-runs and the Studio edits. A game is data the engine reads; everything that comes from outside is checked before it is trusted; every effect on screen goes through one port, so the same engine runs silent in Node. The sections below say where each of these lives. The description holds for the code of 4.4.0; the architecture dates from 4.1.0 "Clarity" (the engine split into modules behind facades) and 4.1.1 (signals from outside).
The layers
games/<id>/ content: data only (rooms, rules, lines, layouts, assets)
│ defineGame / compileGame
▼
src/engine/core/ the engine: state, rules, commands, scripts, sessions, saves — no DOM, runs anywhere
▲ ▲
│ └──── src/engine/tools/ validator, solver, replay, lint, weight, provenance (Node, workers, tests)
src/engine/dom/ the player: App, the Presenter, the room view, audio, input, menus, offline
src/studio/, tools/ the Studio (browser UI, its server and MCP), the command lineWhat each folder may import is written in src/engine/BOUNDARIES.md and checked by
tests/boundaries.test.ts: the core imports neither the DOM nor the tools; the player never imports the solver, the
validator or the Studio statically; the tools never import the DOM; no cycle of static imports anywhere. The full table
is Import boundaries.
Layout of the code
src/engine/
api/ the public entries: content, player, minigames, testing, reality
core/ pure logic (no DOM): state, conditions, reactions, commands, scripts, conversations, hints, saves
types.ts the content format, commented (the reference); split by subject in core/types/
engine.ts the Engine facade: player actions, resolution, lifecycle
ports.ts the Presenter interface (what the core asks of the display) and FakePresenter for Node
cmds.ts the catalogue of commands; command-handlers.ts runs each one
cond.ts condition evaluation
define.ts defineGame / defineRoom, compileGame, stable keys for once/nth/cycle/random blocks
scene/ the scene frame (pure): SceneFrame, the hit test, the Renderer and Intent contracts (4.1.11)
dom/ the browser display
app.ts landscape layout, verbs, inventory, menus, title screen; composes the engine, the presenter, the renderer
presenter.ts the Presenter the engine talks to (lines, overlays, map, minigames, sealed ending) and intent()
fonts.ts default fonts (Galmuri11, DotGothic16 behind it, Press Start 2P), overridden by skin.fonts
room.ts the scene: background, props, characters, depth sorting, walking, poses
walk.ts walkable area (earcut triangulation and navmesh paths), scale by depth
audio.ts music (fade, stack, one-off track) and sound effects, through Howler
assets.ts images and sounds prepared by npm run assets
minigames/ minigames (DOM plugins): pipes, stroke, pick, hide, runner, scratch, cables, code-wheel; their images come from params
ending/ the sealed ending (optional): seal.ts (AES-GCM), card.ts (the final card), index.ts (decryption, ticket, confetti)
reality/ signals from outside (4.1.1): protocol, client, HTTP port
tools/ validator, solver, replay, lint, i18n and the other tools (Node)
dev/ debug overlay and placement editor (development only)
boot.ts bootGame: wires a game module, the engine and the playerThe engine names no game. src/engine/ never imports from games/ and never names a single image, sound or
interface text:
- texts come from
GameDef.ui; - icons, sounds, fonts and default heights come from
GameDef.skin(Skin and fonts); - minigame images come from their
params; - the sealed ending comes from
GameDef.ending.
A game is a folder games/<id>/ whose index.ts exports game, layouts and manifest, and optionally
minigames, extraImages, commands (custom commands) and locales (the GameModule of tools/game.ts).
src/main.ts imports @game, an alias for games/<GAME>/index.ts, and knows nothing else about the game. The
current game is GAME=<id>, else the .cache/game link, else package.json "config": { "game" }, else demo
(Developer setup).
Geometry constants. The logical scene measures 640 × 400; the floor goes no lower than Layout.floor (default 395,
FLOOR in core/define.ts); an approach point recorded more than 150 units from its target (NEAR) is considered
stale and recomputed.
Verbs. Their ids are free (the game's verbs). Four have a meaning for the engine: look (Look texts), talk
(conversations, hints), give and use (two terms: an inventory item then a target; tapping an inventory item with no
verb selected picks use).
A game is data
A game is a GameDef (src/engine/core/types.ts, split by subject in
src/engine/core/types/): rooms, verbs, items, characters, rules ({ verb, a, b, if, do }), scripts, listeners,
dialogues, a map, an ending. There is no function in it, so it can be validated (src/engine/tools/validate.ts),
saved, diffed and solved. Code a game needs (a custom command, a minigame) is trusted code declared beside the content
and reached by name (core/custom.ts, src/engine/minigames/). compileGame (src/engine/core/define.ts) freezes
the content and gives every rule, choice, topic, listener and script step a stable id: saves, sessions and the solver
speak in those ids. How the engine runs that data is How the engine works.
The life of an action
- Input. A tap on the scene or the inventory (
src/engine/dom/input.ts) becomes anAction{ verb, a, b }; a double tap picks the verb a player means (src/engine/core/default-verb.ts). - Engine.
Engine.act(src/engine/core/engine.ts) walks the hero there, opens a session entry (core/session-runtime.ts) and asksresolve(core/interactions.ts) for the answer: a written rule, else a kind, else a refusal or a fallback line. - Commands. The rule's
dolist runs throughexec(core/command-runtime.ts), oneCmdat a time, each by its handler (core/command-handlers.ts): set a flag, give an item, say a line, walk, play a sound, emit an event (core/event-runtime.ts), start a script (core/script-runtime.ts). Every effect on screen is a call to thePresenter(src/engine/core/ports.ts). - State. Commands only change
engine.state, aGameState(core/types/state.ts) of plain JSON: room, inventory, flags, props, counters, what was seen. - Autosave. When the engine is idle again,
Engine.savehands the state to theSaveStore: in the browserIndexedDbSaveStore(src/engine/dom/save-store.ts), which writes a versioned envelope (core/save.ts) and reads it back before saying it is saved. - Render.
App(src/engine/dom/app.ts) composes the engine, thePresenter(dom/presenter.ts, since 4.1.11) and the room view's renderer: the room view (dom/room.ts, painted by the DOM or, on demand, by Canvas), speech (dom/speech.ts), the inventory, music (dom/director.ts,dom/audio.ts).
The order in which reactions are evaluated, and what each command does to time and randomness, is Execution model.
Trusted content, untrusted input
Content and engine code are trusted; everything that comes from outside is not, and starts as unknown: a save
(parseSave, a zod schema), a session file (parseSessionFile), a storyboard (storyboardProblems), a layout import,
an assistant provider's response, a Studio request (tools/studio/security.ts). The same invalid input gives the same
diagnostic in the command line, the Studio and the MCP (tests/diagnostics-parity.test.ts).
Storage, sessions and replay
Saves are envelopes { format, schema: 4, gameId, gameSaveVersion, variant, state } (4.1.15: variant is the world
the game was played in; a schema 3 envelope is read as the story world). An old save is migrated by the game's
migrations (core/migrate.ts), and one save per release since 3.0 is loaded by tests/save-v3.test.ts. Every input
is also an entry of the engine's session, with the answers given while it ran (choices, random draws) and the
digest of the state after it: replay (src/engine/tools/replay.ts) plays a session again and names the first entry
whose state differs. A bug report is a session file. The formats are in File formats.
The solver runs the real engine
solve (src/engine/tools/solve/) explores the game from "New game" with the same Engine and a silent presenter
(FakePresenter): whatever it finds, a player can do. A witness search finds one path to the ending; a proof explores
every reachable state and names the softlocks. The search keys a state only on what can still matter
(solve/abstractions.ts); each abstraction is audited against the explicit search (--audit-abstractions, and the
corpus of npm run audit:corpus). The path from solve() to a verdict is in src/engine/BOUNDARIES.md, "Inside the
solver" (Import boundaries). What a result lets an author conclude is
What the solver proves.
Signals from outside (4.1.1)
A game may declare signals (reality; Reality Bridge). A separate Reality Bridge
(bridge/src/) authorises connectors with Biscuit and signs each accepted fact; the player verifies the signature
before reading anything (src/engine/reality/protocol.ts), and the engine applies the signal at most once, as a session
entry, saved before it is acknowledged. The engine never touches the network: the transport is a port
(WorldSignalPort), and a game without reality loads none of this code.
Public and internal
Public: quest-n-click/content (the types and defineGame), quest-n-click/player (boot), quest-n-click/minigames,
quest-n-click/testing (Engine, the fakes, the save envelope, solve), quest-n-click/reality (since 4.1.1), the
content format, the save envelope and the MCP tools, held by tests/api-surface.json and the support policy
(Stability and support, Public API). Internal: the modules behind the Engine
and App facades (their members marked @internal), the solver's modules, the tools' code. Internal code may move
between minor versions.