EngineExplanationFor authors, contributorsApplies from 4.3.0
Execution model
Cette page en françaisThis page on the GitHub wiki
What the engine does between a tap and the next frame: which reaction answers an action, in which order rules are
tried, what each command changes, when the state is saved, how time is counted, where randomness comes from, and why
the same inputs always land on the same state. Every statement here is read from src/engine/core; the file is named
each time, so you can check it against your version.
Why one model
The same Engine class (engine.ts) runs in the browser, in the node tests, in
the solver and in the replay tool. It knows nothing of the page: everything it shows goes through a Presenter
(ports.ts), everything it keeps goes through a SaveStore. The browser plugs the
real ones in; the solver and the tests plug a silent presenter and a memory store. So the order of effects described
below is not a description of the browser only: it is what the solver proves and what a replay reproduces
(What the solver proves).
The action cycle
- Input. The player picks a verb, then taps something (or an inventory item, then a target). The input layer
(
src/engine/dom/input.ts) turns it into an intention ({ kind: 'act', verb, target, item }); the presenter callsengine.act({ verb, a, b })(since 4.1.11). A double tap picks the verb the player most likely means (default-verb.ts). - Busy check.
Engine.actreturnsnulland does nothing while the engine is busy (an action, a cutscene, a conversation, a script step running). During a tutorial step ({ guide }), only the awaited verb on the awaited target is accepted; any other action makes the hero repeat the instruction and answers'guide'. - Session entry. The action opens an entry of the session (
{ act },session-runtime.ts): one logical step of the run clock. - Approach. When the target is in the scene (not in the inventory), the hero walks to its approach point (from
the layout) and both turn to face each other. A walk the player interrupts marks the entry
aborted: nothing else happens, and a replay of that entry does nothing either. - Resolution.
resolve(interactions.ts) finds the one reaction that answers (next section) and runs its commands. - Commands. The commands run one by one through
exec(command-runtime.ts); each changesengine.stateand calls the presenter (speak, walk, change a prop, play a sound…). - Save. When the outermost run ends (
Engine.run, the busy counter back to zero), the engine saves: the objectives are checked, then the state goes to theSaveStore. In the browser that isIndexedDbSaveStore(save-store.ts), which writes a versioned envelope (save.ts) and reads it back before saying it is saved; the manual slots live in the same database, as envelopes, verified too;localStorageis the compatibility fallback. The whole state is plain JSON, aGameState(types/state.ts): room, inventory, flags, props, counters, what was seen. The file format is on File formats. - Render.
App(app.ts) is the browser's presenter: the room view (room.ts, painted by the DOM or, on demand, by Canvas), speech (speech.ts), the inventory, music (director.ts,audio.ts). See How the engine works.
Conversations. A conversation is one action until "Bye". Since 4.3.0 (#218) it is also saved after each topic,
so what a topic changed survives a page that dies before "Bye". Only when the conversation is the rest of its action:
the talk verb on a character with topics, or a { talk } that is the last command of a rule. A talk followed by more
commands, a talk inside a cutscene or inside a topic is saved at the action's end, never with half a do done. A save
holds no open conversation: loaded, it lands in the room, with the topics seen marked.
Room entry also saves. Entering a room (enter, movement.ts) sets the
room, emits roomEntered, places the hero (at the entry asked, else where it last stood, else the layout's default
entry, else [320, 360]), counts the visit, saves, then shows the room, the inventory and the room's music, and
only then runs the room's onEnter and starts its scripts (when the entry came from a goto or the map).
Between actions. The world's scripts advance one command each (ScriptDef, Engine.advance): NPC strolls,
ambient gags, a character who moveActors to another room when an emitted event wakes its waitEvent. Their
position lives in the state, so a save resumes them. See Events and scripts.
Which reaction answers an action
resolve tries these, in this order, and stops at the first that answers. The returned source ('rule',
'look', 'talk', 'hint', 'kind', 'refuse', 'fallback') is what the solver and the session read.
| Order | What | When it answers |
|---|---|---|
| 1 | A written rule of the room (room.on) | The first rule, in written order, whose verb matches, whose if holds, and whose a / b match the action |
| 2 | A written rule of the game (rules.on) | Same test, after every room rule |
| 3 | Look lines | Verb look with no second object: the room's look[a], else the item's look when it is in the inventory |
| 4 | Talk: the hint item | Verb talk on the game's hintItem while it is in the inventory: the current room's first hint whose until is still false |
| 5 | Talk: a conversation | Verb talk on an actor that has topics in this room (room.talk[a]) |
| 6 | A kind reaction (rules.kinds) | First one aimed at that exact target, else the first one for a kind the target has; an item constraint only matches with a second object |
| 7 | Give to another playable character | Verb give to a playable character other than the active one, the item in the inventory, inventories not shared: the item moves to their inventory (source 'rule') |
| 8 | The character's refusal | Verb give to a character whose sheet has refuse |
| 9 | A fallback line | Always: rules.fallbacks[verb] (use2 for "use A on B"), else the look fallbacks, else … |
How a rule matches (findRule):
- Room before game, then written order. Room rules are all tried before any game rule; inside each list the first match wins. There is no other priority: no specificity score, no weight.
- Exits come after the written rules of their room. A declared exit becomes a hotspot of kind
exitand rules appended at the end ofroom.on(define.tsnormalizeExits): "verb on the exit →goto" when its condition holds, then itslockedline. A written rule on the same exit therefore wins. aandb. A rule withoutbonly answers an action without a second object; a rule withbonly answers one with it.aorbmay be a list of ids. When both objects are inventory items, the order does not matter: "use A on B" also matches a rule written for B on A.if. The rule's condition is evaluated against the state at the moment of the action (after the approach walk).
Only a written rule (or a hint) moves the game forward for the solver; look lines, kinds, refusals and fallbacks are answers the player sees, nothing more. Rules, kinds and fallbacks are written as described on Reactions and Conditions.
How commands run and what they change
One at a time, in order. exec runs a do list command by command, awaiting each one (a line is awaited until it
is said, a walk until it arrives). It stops early in two cases: the engine was destroyed (Engine.destroy, a Studio
preview closed), or the game ended (state.done). A plain string in a list is a line the hero says.
Each command, through one table. step looks the command's key up in
command-handlers.ts. The catalogue of keys is
cmds.ts (CMD_KEYS); a type check refuses a command declared in one place and not
the other. Before a command runs, the run clock is told its logical duration (Time).
What changes the state. CHANGES in cmds.ts lists the commands that change the game's state: prop, show,
hide, gain, lose, used, set, unset, inc, unlock, goto, map, moveActor, emit, switchPlayer,
transfer, custom, startScript, stopScript, minigame, phone, ending, reveal, end. The others only show
something, with these exceptions read in the handlers: walk, place, the four motions (launch, spring, path,
follow), face and pose record where a character stands, faces or poses (state.hero, state.actors), and
camera records the camera.
Effects worth knowing, from the handlers:
| Command | Effect on the state | Journal event |
|---|---|---|
set, unset, inc | Writes or removes a flag; inc adds by (default 1) to a number, starting from 0 | flagChanged, only when the value really changes |
gain | Adds the item once; clears its used mark | itemAcquired when it was not held |
lose | Removes the item, the state first, then the event | itemLost when it was held |
used | Marks one or more items used (greyed, inert unless a rule still targets them) | — |
goto | Enters the room (see "Room entry also saves") and runs its onEnter | roomEntered |
emit | Runs the event (next section) | — |
once, nth, cycle, random | Keep a counter under the block's key in state.counters | — |
minigame | Plays it; a result it reports is written in the reserved flag minigame.<id> (since 4.1.16), then then runs | flagChanged |
custom | Runs the declared effects (commands) always; the run part only in the browser, never in the solver or a skipped cutscene. In dev, a state change by run outside its effects is reported | — |
end, ending, reveal | Set state.done, check the objectives, save | endingReached |
waitEvent | Nothing outside the top level of a script, where it pauses the script | — |
waitUntil | Waits until the condition holds, woken by every state change, bounded by 100,000 rounds | — |
Containers. if runs then or else; once runs its list the first time only; nth runs list n on the
n-th time and stays on the last; cycle wraps around; random draws a branch, never the same one twice in a row
when it has more than one; choice offers the options whose if holds (and, for a once option, not taken yet), the
hero says the chosen text, then its do runs; parallel starts every branch at once and waits for all of them, so
their effects interleave; cutscene runs its list as skippable.
The keys of once, nth, cycle, random. compileGame gives each block a key: its id when you wrote one,
else its position (<room>:on<i>.<j>, game:intro.<i>…; define.ts
assignKeys). The counter is saved under that key. Reorder the content above an unnamed block and its key changes: a
save made before then counts it from zero again. Give an id to a block whose memory matters
(State and saves).
Skipping. skip() during a cutscene makes the remaining commands run fast: their state effects all happen, their
presentation is cut (sfx and shake are not played, walks and waits end at once). The session records after how
many commands the skip happened (skipAt), so a replay skips at the same point.
Events and scripts
An emit (event-runtime.ts) does three things, in this order:
- Every script of the game and of every room (not only the current room's) that is paused on
waitEventfor this event moves past it. - The current room's listeners (
room.events) whoseonis the event and whoseifholds run, in written order. Aoncelistener that already ran is skipped, and is marked seen before it runs. - Then the game's listeners (
events), the same way.
The listeners run inside the action that emitted, so the action is saved once they are done.
Scripts (script-runtime.ts,
scheduler.ts). In the browser, scripts run on their own (autoScripts):
the current room's scripts start on each room entry, the game's on a new session; each loop calls advance, and waits
250 ms whenever nothing ran. In node (tests, the solver) they only move when stepped. One advance:
- does nothing (
blocked) while the engine is busy with an action, a cutscene, a conversation or a minigame, or the game has ended; - with a
whilecondition that is false, rewinds the script to its first command and waits; - on
waitUntil, moves on only when the condition holds; onwaitEvent, waits for theemit; - otherwise runs exactly one command, then saves when the engine is idle;
- at the end, stops (
done), or starts again from the top when the script hasloop.
Changing room ends the room's loops at their next step; a new session (new game, load, checkpoint) and a player switch
end the game's loops too and start them again. A script that throws is stopped and marked off in the state, so a
save does not show it alive. The position of each script (pc, and since v3 saves its named step, which follows
the script when its commands are reordered) is in the state.
The solver plays scripts as actions ("Script <id>", run up to its next wait, so a patrol is seen room by room),
tries every option of a choice (the path reads Talk x: "topic" › "reply"), and keeps the exact value of a counter
that is ever lowered or set to a number. See What the solver proves.
Time and randomness
ADR 0016 and decision D24 (4.1.14). Three clocks coexist, and none of them is an authority for the game's logic.
Engine.clock: wall-clock milliseconds, set by the browser only. It dates the session and each entry'st. The solver and the tests set none, so their sessions are byte-identical from one run to the next. The state'sstartedfield also comes from it (elseDate.now()); it is not part of the state digest.- The run clock (
run-clock.ts,EngineRunClock,Engine.runClock;Engine.clockkeeps its meaning, the run clock is a new name): what a speedrun is timed with. The core tells it of every session entry (one logical step), of every command before it runs, of every line said, of every approach walk that arrived, of every room entered at a point and of every cutscene entered and left. It keeps its own counters (bigintmicroticks, 1 ms = 1,000) and never writes the state; nothing the content does can depend on it.monotonicNow()is real time (RTA), never an authority. - Logical durations (
timing.ts,TIMING_VERSION = 1; a run records the version and a verifier refuses another one):
| What | Logical cost |
|---|---|
A line (a say, a plain string, a look, a fallback, a hint) | 2,200 ms, whatever its text, language or text speed (SAY_LOGICAL_MS) |
A walk (the approach walk, a walk command) | Its distance from the character's last logical anchor, over WALK_SPEED = 150 units per second, rounded to the ms |
wait | Its ms |
anim | Its ms, default 800 |
camera pan | Its ms, default 600 (follow and reset cost nothing) |
launch, spring, path, follow | Their ms, default 900, 1,200, 1,500 and 0 |
| Anything run fast (a skipped cutscene) | Nothing (a walk still moves the anchor) |
| Anything else, a choice included | Nothing: the time a player thinks is real time (RTA) only |
What runs inside a cutscene counts in the in-game time (IGT) but not in the Active IGT. On screen a line lasts
max(2,200, 70 ms per character) / text speed; that presentation time is never counted.
Categories, splits and verification are on Speedrun for authors.
The seeded generator (prng.ts): xoshiro128** over 32-bit integers
(PRNG_VERSION = 1), written with Math.imul and unsigned shifts only, so Node, Chromium, WebKit and Firefox draw the
same numbers from the same seed (tests/fixtures/prng-vectors.json). A seed is a string; derive(seed, stream) gives
independent streams: logic, cosmetic, minigame:<id>, copy-protection. A cosmetic draw never moves the logic's
sequence.
engine.randomdraws from the run'slogicstream (Engine.sessions.seed).- A new game or a checkpoint starts a new run: a new seed (the host's
nextSeedwhen it chose one, else 128 fresh bits from WebCrypto; without WebCrypto, an error, never a guessable draw) and the run clock back to zero. - A load continues the stream and the clock.
- The seed is written in the session (
Session.seed) only when the host chose it (a speedrun category, a verifier); a session nobody seeded stays as it was before 4.1.14. - Every draw is recorded in the entry that made it (
rnd[]): therandomcommand, and the choice of a fallback line (which also avoids repeating the previous one). - Look lines and hints are not random: they cycle in order, with a counter in the state.
- Particles are seeded per emitter on the presentation clock, never with the engine's dice.
rnd[]stays the trace: a verifier replays without feeding it and demands that the seeded draws equal it.
The run's journal. run-tape.ts turns each entry into a link (the entry,
the semantic events from its beginning to the next one's, the run clock after it);
journal-chunks.ts chains the links by SHA-256 into chunks of
SESSION_MAX = 500 entries (also the session's rollover), each written in one transaction
(run-store.ts, IndexedDB web-scumm-runs) and read back checked. The speedrun
itself (categories, splits, the .wsrun, the verifier) is in tools/speedrun/: see
Speedrun for authors and File formats.
Determinism: record, replay, prove
What the session records. Every input is an entry of Engine.session: an action, a map travel, a player switch,
a script step, an outside signal (Reality Bridge). Each entry keeps the answers it met on the way: the choices
(picks), the map picks (maps), the random draws (rnd), the minigames' results (mg, since 4.1.16) and their
transcripts (mgt, since 4.1.17), the skip point (skipAt), what answered (ran: the ids of rules, topics,
listeners, script steps), and in the browser a digest of the state after it. The digest covers the logical state
(logicalState, diff.ts): positions, camera and timestamps are left out. A session holds at most 500 entries
(SESSION_MAX); the next input starts a new one from the current state.
Replay. replay() (src/engine/tools/replay.ts, npm run replay) runs a
session on a fresh silent engine and feeds back the recorded answers instead of asking anyone; the scripts advance
exactly when they did. It lands on the same state, and says at which entry it diverges when it does not. By default the
recorded draws are fed back (and a missing draw is 0); given a seed, it drops the recorded draws and draws again from
the seeded stream. A minigame is never played again on a replay: its recorded result is used, and a recorded transcript
that does not give that result stops the replay.
The solver (src/engine/tools/solve) runs the same engine with its
own random source: for every draw it meets, it tries one representative value per branch of every random block of
the game, so each branch is explored. It leaves out of the state whatever cannot change the outcome (a flag read by
nothing but its own setter, a clock script nobody reads, a walker nobody waits for), so a decorative world costs it
nothing. It records what every action read (Engine.reads) and what answered (SessionEntry.ran): the profile
(--profile) says what the states are made of, and the partial-order reduction (--por,
por.ts) skips orders of actions that commute. Its solution is a session
(SolveResult.steps), replayable like any other. Options are on Solver options.
What makes this hold. The engine's logic reads only the state, the content, the recorded answers and the seeded generator. Wall-clock time only dates entries. Presentation (animation frames, the text speed, particles, the renderer) never feeds back into the state: How the engine works.
Alternatives set aside
- A line's logical cost as a function of its length: refused in ADR 0016. A translation is presentation; a French run and an English run of the same route must have the same in-game time.
- The presenter, or the frame rate, as a source of time: refused by D21 and D24. A duration the player could shorten without an input the session records would make the time unverifiable.
Math.random: the engine drew from it until 4.1.13, withrnd[]as a trace only. The seeded streams of 4.1.14 let a verifier draw the same numbers again instead of trusting the trace.- A timer that changes the state on its own: it would break replay (ADR 0004). Scripts move by steps that the session records, and the engine's clock is recorded, never read by the logic.
Trade-offs
- Written order is simple to read and to reproduce, but an early broad rule silently shadows a later narrow one: put the narrow rule first. The engine has no other priority between rules.
- A save on every room entry and after every action means many small IndexedDB writes; each is read back before it counts.
- Position-based keys for
once,nth,cycleandrandomneed no authoring, but move when the content is reordered; an explicitidcosts one word. - One more observer on the hottest path of the core: a function call per command and per entry for the run clock (ADR 0016).
- The logical clock ignores how long the player really took to read or think; that is what RTA is for, and RTA is the client's word, not a proof.