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.

What the solver proves

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

The solver plays your game on the real engine and tells you whether it can be finished. It gives three different answers depending on how you run it: a winning path, an exhaustive proof, or an exhaustive proof with abstractions. They do not promise the same thing. This page says what each one lets you conclude, and where each one stops.

The solver runs the real engine

solve (src/engine/tools/solve/) explores the game from "New game" with the same Engine the player uses and a silent presenter (FakePresenter). There is no second model of the content: whatever the solver finds, a player can do, and its paths replay as sessions (npm run replay in the engine repository). The path from solve() to a verdict is described in src/engine/BOUNDARIES.md, "Inside the solver". The commands and their options are in Solver options; how to fit the solver into your checks is in Checking your game.

An ending found, a softlock ruled out: what each answer lets you conclude

You ranIt answersYou may concludeYou may not conclude
npm run solve (a winning path, or witness)solved and one path from "New game" to the endingThe ending can be reached: at least one sequence of actions, which a player could play, gets there.That every player can finish. A game where one hero chosen in the intro can finish and another cannot still prints solved. The search stops at the first ending it finds, so the states it did not reach, other answers of a choice included, are not checked. No softlock is ruled out.
npm run solve -- --prove (an exhaustive proof)solvedFrom every state a player can reach, every answer of every choice and every random draw included, the ending stays reachable: there is no softlock, within what the solver models (below).That the game is fun, readable or clear: the solver does not look at the screen. That a minigame can be won: it assumes each one is (the assumptions line names them).
npm run solve -- --provesoftlocksEach listed state is reachable and the ending is lost from it. Causes are grouped by the step that lost it, and each cause carries its session entries, so the way into it replays (since 4.1.13).That the path shown is the shortest one there is: it is the shortest found.
eithertruncatedNothing. A budget (states, time or memory) stopped the search.Anything at all. A truncated search never reads solved, and its exit code is 2.

The demo game shows the difference: npm run solve prints "The game can be finished" and a path from "(tutorial) Look at pantry" to "Use key with pantry"; npm run solve -- --prove explores every reachable state and prints Proof solved: the ending stays reachable from every one of the … reachable states. Only a solved from --prove is a proof. The witness's own sentence says it: "only --prove checks every state, every answer of a choice included".

Guarantee · witness-not-proof · referenced · executed on aff6caab7514 · scope self-checked by the author (AI), not reviewed A winning path is not a proof: on a game whose last door opens only for one of the heroes the intro offers, the witness search says solved and --prove says softlocks, and only a solved proof says "stays reachable from every one".

Guarantee · truncated-never-solved · referenced · executed on aff6caab7514 · scope self-checked by the author (AI), not reviewed A proof (--prove) stopped by its budget of states, of time or of memory reports truncated, never solved.

A proof with abstractions. By default --prove folds states that differ only in what cannot change the outcome (see Abstractions). Its solved then means: the same verdict as the explicit search would give, on the condition that the engine reports everything a run reads. That condition is checked, not assumed:

The audit compares verdicts (status, ending reached, invariants broken, whether there are softlocks, the live flags, rooms and map places reached), not the number of states or the paths: those are what the abstractions fold.

Limit · liveness-not-audited Every search, the explicit one of the audit included, keys a state only on what is live (what the puzzle graph says can still change the outcome). The audit therefore checks the folds listed above, not the liveness analysis itself. A proof is a proof of the game as the puzzle graph reads it.

Limit · minigames-assumed-won The solver assumes every minigame is won. Its output lists each one as an assumption (assumptions: minigame:pipes:success). Whether a person can win it, and whether its Skip works, is for a playtest and the e2e run.

The one status

A solver run and a proof by chapters carry one status, with its exit code and its sentence (src/engine/tools/status.ts). npm run solve prints the sentence and exits with the code; --json carries status, exit and headline; the Studio's Check tab shows the sentence; the solve MCP tool returns all three. None of them words its own verdict.

StatusExitMeaning
solved0The ending (or a chapter's goal) is reached; with --prove, it stays reachable from every reachable state.
softlocks1Reachable states from which it can no longer be reached (softlockCount, softlockCauses).
unsolved1It is not reached from any state explored.
truncated2A budget stopped the search: --max states, --time or --mem. Nothing is proved; the sentence says which budget to raise.
broken1An invariant (GameDef.invariants) is true on a reachable state, printed with its path. Before 3.3 this was solved with exit 1.
error1The engine failed while exploring.
checkpoint_mismatch1Chapters only: every chapter proved, but a hand-written checkpoint is none of its reachable boundary states.

When several apply, the worst wins, in this order: error, broken, truncated, unsolved, softlocks, solved.

The three warnings worth reading

Even when the status is solved, npm run solve prints what it noticed on the way:

OutputMeaningFix
Dead ends (n): - garden, inventory [shell_phone, pipe] after: A › B › CA state where no action changes anything.An item was consumed with no way to get another, a flag closed the only way forward, or a topic's if hides the clue. Give the item back, or loosen the condition.
Items obtained but never used in a ruleA red herring, or a forgotten puzzle.Use it, or remove it. A visible useless item costs the player minutes.
Items never obtainedDeclared in items, but nothing gives it.Add the gain, or delete the item.

npm run solve -- --from=market starts from the checkpoint market instead of "New game": handy while you work on one room.

How a state is stored

A state is what the content can still tell apart: the live flags, items, props, counters, scripts and positions (the dimensions, solve/abstractions.ts). Since 4.1.13 (ADR 0015) the search stores each state it has seen as an index into flat columns: its parent, its path length, its last step, its room and bag, each string interned once. Its key is its interned (dimension, value) pairs, sorted: two states share a key exactly when their dimensions are equal, so no hash collision can merge them. The engine state is kept only while the node waits to be expanded.

On the open instances of the proof matrix this divides the heap by 16 to 20 (1.6 KB a state on o21 instead of 31.6, measured for 4.1.13 on 7 October 2026, Benchmarks); the time per state is the engine's and does not change. --representation=objects keeps the 4.1.8 storage: the reference the differential tests compare with (tests/solver-oracle.test.ts: the same verdicts, paths and reachable sets).

What folds states: the abstractions

A proof folds states that differ only in what cannot matter. Each fold is audited against the explicit search (npm run solve -- --audit-abstractions; npm run audit:corpus every night):

Dominance (a state with no more progress than one already seen) prunes witnesses only, with --dominance. In a proof it would change verdicts on generated games (tests/dominance.test.ts), so it stays off there and the profile says why. A witness search that finds nothing with dominance is run again without it, so unsolved is never its doing.

The partial-order reduction (--por=sleep|stubborn) has no proof of equivalence for softlocks: --prove ignores it and says so.

What the solver does with a big game

The state space of an adventure game is the product of everything that can vary independently. The solver first asks the puzzle graph what can still change the outcome (liveness in src/engine/tools/puzzle.ts): rooms, map places, players, the ending and the chapter goals are live; an action is live when one of its effects reaches something live; a thing (item, flag, prop, character position, event) is live when a live action reads it. The rest is left out of the state and out of the actions tried: a flag only its own setter reads (a "looked at" marker), a trinket no gate needs, a clock nobody reads, a walker nobody waits for, a topic chain that ends in a flag nothing uses.

This arrived in v2.1. Measured then (Benchmarks): the ten-room version of the benchmark game hit the 20 000-state limit after 54 seconds without finishing before v2.1, and took 102 states after; the forty-room one 624 states; the hundred-room one 2 901.

What still multiplies states, by design, is what matters: independent live things. Ten optional items that each open something, in any order, are 2^10 states. The honest answer for a long game is:

  1. Chapters (checkpoints with goals, npm run solve -- --chapters): each chapter is searched from the previous checkpoint, with only its own things live. With --prove --chapters, each chapter is proved from every reachable boundary state of the previous one, and a hand-written checkpoint that matches none of them is checkpoint_mismatch. In the v2.1 measure, the last chapter cost 3 290 states and the whole game 2 901, because the global search drops what earlier chapters consumed.
  2. Invariants (GameDef.invariants) for the "never again" bugs, checked on every explored state.
  3. The puzzle graph (npm run page:puzzles, the Studio's graph), to see before solving what a thing depends on and what it unlocks.

A global solve of a 100-room, 5-player generated game in seconds does not promise that every 15-hour game will be proven in one go. It says the engine's tools scale with the content, and that the solver spends its budget on the puzzles. When a proof gets slow, the profile says which dimension multiplies the states.

A game with the outside world: prove it in every world

A proof never assumes the outside cooperates. For a game that declares reality.signals, npm run solve:reality (engine repository; part of prove:game) and npx quest-n-click solve-reality (created project; part of quest-n-click release) prove the game in three kinds of world:

  1. closed: no signal at all. The game must be finishable through its fallbacks.
  2. each scenario of games/<id>/reality/scenarios/*.json ({ "signals": ["mail.answer.wrong", "mail.answer.correct"] }): those signals, in that order, each able to arrive at any point after the one before. A required signal needs a scenario that sends it.
  3. adversarial: any declared signal, at any point, again and again.

Each must be solved with no softlock and never truncated. The solver hands the engine the signal itself: no service is contacted. In the Studio's MCP, solve takes reality to try one world. The report names the world its verdict holds in.

validate checks that a required signal's fallback names a rule; the content lint (npm run lint, in verify:game) checks that the closed witness plays it. A game that finishes without the outside world by another route gets fallback-unplayed, a warning: the fallback is declared on a rule nobody needs, often one that cannot run. Declare the action the witness plays, or make the declared one the way through. The Bridge side is in Bridge guarantees.

The proof matrix

docs/dev/PROOF-MATRIX.md is the class of games the solver is measured on since 4.1.13: twelve generated games of 20 to 40 rooms and three playable characters, six constrained (walls between the characters' zones) and six open, with transferable items, crossed puzzles, dialogues, scripts, events and destructive actions. npm run prove:matrix (engine repository) proves each one in its own process under the published budgets (10 000 000 states, 10 minutes on the maintainer's Mac, 20 on the CI runner, 4 GB of heap) and fails on a false verdict. It runs every night, never on a pull request. An instance that does not finish is unknown, never proved: the matrix's gap report says which budget stopped it and which dimension grew. docs/dev/PROOF-PROFILE.md is the profile of each instance.

Why

A model of the game written for the solver would drift from the engine, and a proof would then prove the model, not the game (ADR 0003). Running the real engine means every path the solver prints is a path a player can play, and replays as a session.

Alternatives set aside

Trade-offs

Proofs are slower than on a hand-written model: the engine runs for every try. Large games prove by chapter, on workers, and with checkpoints (Solver options). Workers speed an exploration up; they do not answer an explosion of states.

Decisions and ADRs


Edit the source of this page · Report an error