quest'n'click 4.5.0 · r1

Built for quest'n'click 4.5.0, revision r1. Whether it is published, and which one is current, is recorded at publication.json.

How the engine works

Français · Type: explanation · Audience: author, contributor · Context: engine repository, created project · Status: ready · Applies from: 4.3.0

The engine's parts and why they are cut that way: the game as data, how a page boots a game, the two screen layouts, the scene model and its painters, the scene frame and the semantic journal, walking, the asset graph, the music director, and the cache that lets a game play offline. The order in which an action runs is on Execution model.

The engine

A SCUMM-style point-and-click engine for the browser, built for phones in landscape. It knows nothing about any particular game: a game is a content folder that the engine reads.

See Layout of a project and The working loop.

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, npm run validate), saved, diffed and solved (npm run solve). 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. See Custom code and File formats.

Bootstrap

src/engine/boot.ts bootGame({ game, layouts, manifest, minigames, commands, locales, version, dev, sw }) is what a page does to start a game: the language, the fonts, the verified save store (early errors are kept until the App can show them), the App, window.__game, the dev tools, the title, the service worker. src/main.ts calls it; a game that embeds the engine calls it too (Upgrade notes, v3: a game that embeds the engine). pickLanguage, waitFonts and openStore are exported for a page that needs another order. The full signature is on Public API.

Two layouts

The choice is made automatically (App.layout, dom/shell.ts): the desktop layout needs a fine pointer (pointer: fine) and a content box of at least 720 × 450 once the safe areas are taken out. It is made again whenever the window is resized.

The scene: a model and a painter

dom/room.ts (RoomView) is the scene model of a room: who is where, which image of which pose, depth, walking, mouths and blinks, fades, the camera, and the hit test (the smallest visible target under the finger). It hands each entity to a painter as a finished sprite (SpriteSpec in dom/renderer.ts: image, feet position, size, depth, mirror, rotation, opacity, glow, shadow) and never asks the painter anything back, so two painters cannot disagree on what a tap touches.

The DOM painter (dom/render-dom.ts) is the reference (D10): an <img> per sprite, the z-index for depth, a CSS translation for the camera. RoomView.still() stops every animation on a fixed picture (first frames, mouths closed, the camera at rest): npm run e2e:visual compares each room of the sample game, still, with its reference in tests/visual/demo/ (a CI gate; --update rewrites the references after a wanted change). The DOM painter draws layers, parallax and polygon occluders (the backdrop clipped at the occluder's depth).

The Canvas 2D painter (dom/render-canvas.ts): one <canvas> the size of the viewport, held in place while the room under it moves with the camera, repainted on the next frame after a change.

The model evaluates every stage condition and gives the stage again when one changes; a room's transition fades or wipes the painter's surface; reduced motion (the setting or the system's) turns off parallax, particles and transitions. A room chooses Canvas with renderer: 'canvas' (or the game, for every room); the URL parameter ?renderer=canvas|dom forces one painter for every room. The UI, the dialogue and every accessible target stay in the DOM, above the canvas.

Measure · canvas-parity The sample game's rooms painted by Canvas within 0.31% of the DOM references; the whole game played to the end with it; 60 frames per second with the CPU slowed 4× (npm run e2e:perf), all in the CI's chromium / canvas row.

The scene frame, the renderer and the journal

ADR 0011 and decision D21 (4.1.11): rendering is not a source of state. Three contracts carry it.

The scene frame. scene/frame.ts sceneFrame(state, layouts, anim) is a pure function that returns an immutable SceneFrame in logical units: the camera; the layers (the backdrop, the stage layers, the occluders, the props); the characters (where they stand, which way they face, whether they speak or walk, their sprite); the targets with their hit polygons precomputed (the layout's polygon, else the box of what is drawn, rotation included; the smallest wins); the effects (lights, particles, the transition); and a hash of all of it. RoomView resolves every sprite, makes the frame and paints it when a room is built; between builds, an entity that changes paints its own sprite (its part of the frame). The hit test (hitTest) and the accessible buttons read the frame, so the semantic DOM layer (buttons, focus, keyboard, screen reader) follows whatever paints the room. tests/dom/scene-frame.test.ts holds the DOM of every room of the sample game and of the reference chapter, in three states, to what it was before the frame existed.

The renderer. Renderer (mount, render(frame), onIntent, unmount) is what draws a frame. The DOM and Canvas painters are wrapped as one (dom/frame-renderer.ts): an unchanged frame is skipped by its hash, a new room is painted whole, a changed frame by its differences. scene/null-renderer.ts draws nothing, for tests and headless runs. The render loop, the screen-to-scene conversion and the presentation animations (walk cycles, mouths, particles) belong to the renderer side; the engine knows no frame. A painter never imports the engine (tests/boundaries.test.ts).

Intentions. An Intent is the only thing that goes back: act (a verb on a target, an item on it), walk (a point of the floor), pick (a choice), skip, open (the map, the bag, the menu). The input layer (dom/input.ts) makes them from taps, keys and clicks and gives them to the presenter (dom/presenter.ts intent()), which applies the engine's (scene/intent.ts) and answers its own. App composes the engine, the presenter and the room view's renderer. The same clicks on the DOM and on the Canvas painter record the same session and the same journal, at device pixel ratios 1, 2 and 3, on a phone and a desktop screen, with and without reduced motion (tests/dom/intent-equivalence.test.ts).

Guarantee · same-clicks-same-session · referenced · executed on aff6caab7514 · scope self-checked by the author (AI), not reviewed The same clicks on the DOM painter and on the Canvas painter record the same session and the same journal.

The semantic journal. Engine.journal (core/journal.ts) numbers what happened, in ids:

EventWhen
sessionStartedA new game, a load, a checkpoint
roomEnteredA room is entered (with from when it comes from another room)
itemAcquired, itemLostAn item enters or leaves the inventory (an item handed between players is both, each with its player)
flagChangedA flag's value changes (only then; null for a flag removed)
playerSwitchedThe player takes another character
objectiveCompletedAn objective holds for the first time in the session (4.1.12, ADR 0014)
endingReachedend, or the sealed ending
loadMadeA save is loaded (journalled before its sessionStarted)
saveMadeThe autosave that follows something semantic

The command handlers, a room's entry and the engine's lifecycle emit it; nothing in the DOM does, and a kind it does not know is refused (it is not the interface's bus). since(seq) gives the events after a sequence number, subscribe hears them as they come. The window keeps the last 10,000 events: a longer session is exported with journalTruncated, and npm run replay then says it compared no journal. Replaying a session yields the same journal (tests/journal.test.ts: the sample game, the reference chapter, 200 generated games); a session file carries it, npm run replay prints it and fails when the replay's differs, and the dev panel lists the latest events.

core/objectives.ts checks every objective's done at each save and just before an ending, and emits objectiveCompleted the first time one holds, never again in the session, after the flag that completed it and before the autosave's saveMade. A slot on saveMade and loadMade waits for a save that is part of the session. See Chapters and objectives.

Walking and motions

dom/walk.ts walks a character over its room's walk zones (walkZones: one polygon with holes each, its own depth scale and camera zoom) and the links between them (walkLinks: walk, stairs, ladder, jump, teleport, with a duration, an animation and a facing): the fewest links first, then the shortest path inside each zone.

How to draw them: Geometry; when something is visible but cannot be reached: Something is visible but unreachable.

Assets: one graph

src/engine/core/asset-graph.ts says which files each part of the game needs, from the content:

The renderer preloads a room's subset for who is actually there; the background warm-up reads the current and neighbouring rooms' scopes; the full offline plan is the offline scope; provenance covers it; npm run weight budgets it. A file one of them knows cannot escape the others. A room's scope over-approximates one visit (every variant, every character who could be there), never the other way: npm run e2e:weight fails on a request outside the prediction.

Fonts. The default fonts go through the bundler, with hashed names, so the service worker's precache takes them from the HTTP cache instead of downloading them again. The interface font, Galmuri11 (since 4.3.0), is a proportional pixel font close to the LucasArts dialogue fonts, shipped as a Latin subset (125 KB, tools/subset-font.py) with a size-adjust that gives its capitals the height DotGothic16's had. DotGothic16, the default until 4.2.5, stays behind every interface family (fontStack in dom/fonts.ts): its own Latin subset (118 KB) answers the few symbols Galmuri11 lacks, and the full font (2 MB, mostly Japanese) is a face whose unicode-range covers only the rest, so a browser fetches it, and caches it, only for a character outside the subsets. See Skin and fonts.

Music: a director and its stems

dom/director.ts plays a score (audio.scores, since 3.5) on Web Audio from decoded buffers: every stem starts at the same instant of the audio clock and loops over the same window, so they stay sample-locked; a change of the game's state moves stem gains on the next bar (or beat), crossfaded, scheduled ahead on the audio thread. core/score.ts decides what and when, without a sound card: the mix per state, the grid through the loop, the ramps.

Stems or the single mix. dom/audio.ts hands a scored track to the director where it fits: Web Audio, no Save-Data, more than 2 GB of memory and 2 cores; on a browser that does not tell its memory (Safari), only when the scores decode to 128 MB at most (pcmBytes). Elsewhere it plays the single mix with Howler. The URL parameter ?music=mix|stems forces one. Voices and one-off tracks duck the score like the mix.

What the director guarantees.

Measure · music-e2e npm run e2e:music renders thirty minutes offline with 0 samples of drift, plays 100 changes of mix with no click (a hard switch is caught), and measures the real-time jitter in Chromium and WebKit at 0.02 ms, all in CI. Since 3.6 it also checks transitions and the saved phase to the sample.

How to write a score: Music in stems.

Cache and responsiveness

The service worker (vite-plugin-pwa / Workbox, configured in vite.config.ts) keeps the app shell cached from the first visit. Images, sounds, videos and the full fonts are kept on first use (cache first), then served without the network; music is served in chunks (range requests) from the cache. The sealed ending's file (data/) is always requested from the network first. A new version never activates on its own: the game asks the player, after a durable save. What this means for a deployment (a sub-path, an update that does not show) is on Sub-path, cache and service worker.

The warm-up. From the title screen and after each room change, the engine warms in the background the current room, the directly reachable rooms and their audio, in batches sized by GameDef.assetBudgets. Then, once per page, the rest of the game (GameDef.offline, default full): every image, effect, voice, music track and video, batch by batch during idle time, paused while the page is hidden. Nothing is fetched on a "save data" or 2G connection; music and video wait for better than 3G. offline: 'nearby' keeps only the room-scoped warm-up.

Cache busting. Every image or sound address carries ?v=<hash>: the hash changes as soon as a file in public/assets changes, which bypasses the old cache.

Offline, and saying so. After the first visit the whole game plays offline, and the game says whether that is true: the warm-up returns a status (App.offlineStatus, offlineReady):

StatusMeaning
completeEvery file of the plan is in the cache
partialWith its reason: network (and the failed files), save-data, slow, quota (the browser reports less than 64 MB free before starting)
skippedNothing was tried: worker when the service worker never took control of the page in time (fetching would have cached nothing)
offoffline: 'nearby'

The pause menu shows it (ui.offlineStatus: "312/400", "whole game cached", "312/400 ⚠ tap to retry"); a tap retries a partial warm-up, and a file already in the Cache API is never fetched twice, so the warm-up resumes across reloads with no state of its own. npm run e2e:pwa requires complete, then checks every file of the plan against the cache offline and renders a room never visited (Chromium; WebKit cannot navigate offline under automation and reports "skipped", exit 3, accepted only by the CI's --allow-skip).

Limit · nearby-needs-network With offline: 'nearby', a room never visited or warmed may still need the network.

Why these choices

Trade-offs

Decisions and ADRs


Edit the source of this page · Report an error