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.

The sealed ending

Français · Type: how-to · Audience: author · Context: engine repository, created project · Status: ready · Applies from: 4.1.2

The sealed ending is an optional module: the last texts of your game (a scratch ticket, a headline, a final card with photos) are encrypted into one file with a password, so they are never in the game's code. A player who opens the built game's files reads nothing; the ending shows only when the game reaches it. This page sets it up, seals one outcome and checks that nothing leaked.

What you get

A public/data/dossier.bin file holding one chosen outcome, encrypted; a game whose ending module plays the scratch ticket, the confetti and the jingle, then the final card; and a build in which npm run check:spoilers finds no ending text in clear.

Context and versions

Limit · engine-repo-only A created project's quest-n-click command has no seal and no check:spoilers. Its game can play a sealed ending, but the file is sealed from the engine repository.

Prerequisites

Where to run the commands

In the engine repository, at its root, with your game selected. The private configuration lives in games/<GAME>/private/, which .gitignore keeps out of the repository.

The ending module

GameDef.ending describes it:

// games/<GAME>/game.ts
ending: {
  file: 'data/dossier.bin',                         // produced by npm run seal
  password: { given: '…' },                         // or { typed: true, prompt: 'Password?' }
  guess: { flag: 'guess', labels: { a: '…', b: '…' }, right: '… {guess} …', wrong: '…', none: '…' },   // a prediction, judged on the card
  scratch: { ticket: 'items/r4c1', sfx: 'scratch' },   // parameters of the scratch minigame
  card: { accent: '#d4145a' },                       // the final card's frame and scratched-text colour
},
FieldMeaning
filethe sealed file, fetched under the game's base path. npm run seal writes public/data/dossier.bin, so write data/dossier.bin
password{ typed: true, prompt }: the player types it, and it is nowhere in the bundle. { given }: the engine has it, so it is in the bundle: an obfuscation, not a secret
guessoptional: the player's prediction, asked at the start (a flag a script sets) and judged on the final card. labels gives the word for each flag value; {guess} is replaced in right, wrong and none
scratchthe scratch minigame's parameters: ticket (the image) required, sfx, color…; the hidden text comes from the sealed file
cardaccent: the colour of the frame and of the scratched text (default #d4145a)

The command { ending: true, after: […] } plays, in order: the scratch ticket (its text comes from the encrypted file), the confetti (skin.icons.confetti, skin.sounds.confetti) and the jingle (skin.sounds.jingle), then after, then the final card (music skin.sounds.end). It ends the game as end does (state.done, the event endingReached, a save). { reveal: true } is the old name. Right before it, the script plays whatever it wants (the family gathered on the sofa, the lock melting away…).

The ending texts are never in the game content: they live in games/<GAME>/private/ending.config.ts. If the file cannot be read or decrypted, the game shows "…" instead of the ending.

Steps

  1. Write the private configuration from the committed example, then fill in the texts:

    # in the engine repository
    mkdir -p games/<GAME>/private && cp games/<GAME>/ending.config.example.ts games/<GAME>/private/ending.config.ts
    

    Fix the import path in the copy: the example imports ../../scripts/seal-types; from private/ it is ../../../scripts/seal-types. Its password must be the same as ending.password in games/<GAME>/game.ts (or what the player will type). The demo game's example is games/demo/ending.config.example.ts.

  1. Seal one outcome. Only the chosen outcome is encrypted:

    # in the engine repository
    npm run seal -- --outcome=<key>
    

    <key> is one of the outcomes keys. It prints ✔ Sealed file (<key>) → public/data/dossier.bin (<size> KB); the password is never printed.

  1. Build and check for leaks.

    # in the engine repository
    npm run build && npm run check:spoilers
    

    npm run build already runs check:spoilers at its end (build:game); running it again after a build is cheap.

The configuration and the two commands

The private configuration (EndingConfig, scripts/seal-types.ts):

FieldMeaning
passwordthe same as the game's ending.password (given), or what the player types (typed)
outcomesfree-form keys, each { ticket, headline, lines? }: the text under the scratch area (simple HTML), the announcement on the card, extra lines. The sealed key is compared with the guess flag (ending.guess.flag)
messagea short note common to all outcomes
photosthe card's photos, paths relative to games/<GAME>/private/ (jpg, png, webp, gif). seal warns above 800 000 bytes per photo
linescommon lines, added after the outcome's own
judgeGuessfalse: the sealed file does not carry the outcome, and the guess is not judged (a test version)

npm run seal -- --outcome=<key> (scripts/seal.ts) reads games/<GAME>/private/ending.config.ts; without it, it uses the committed example and warns ("using the EXAMPLE configuration"). A missing or unknown --outcome stops it with the list of keys. It encrypts { ticket, headline, message, photos, lines, outcome } with AES-GCM (256-bit key derived from the password by PBKDF2, SHA-256, 120 000 iterations; the password is compared without accents, case, spaces or punctuation) and writes public/data/dossier.bin.

npm run check:spoilers (scripts/check-spoilers.ts) needs dist/ (it stops with "dist/ not found: run npm run build first."). It looks in every .js, .html, .css, .json and .txt file of dist/ for every outcome's headline, ticket text (when longer than two words) and lines, and the first line of the message; it refuses a private file in dist/ (a private folder, a *ending.config* or *reveal.config* file) and a dossier.bin holding ticket or headline in clear. Exit 1 with the count of leaks. A game without a sealed ending has only the private-file check.

Limit · format-frozen The sealed file's fields (ticket, headline, message, photos, lines, outcome) never change: a dossier.bin already sealed stays valid.

Expected result, and how to check it

check:spoilers prints "✔ No sealed-ending text in clear text in dist/." Play the game to its ending (npm run dev): the ticket scratches to the sealed outcome, then the card shows its headline, the message, the photos and the lines, with the verdict on the player's guess.

Common errors

SymptomCauseFix
The game shows "…" instead of the endingthe file is missing at ending.file, or the password differswrite file: 'data/dossier.bin'; make the configuration's password the game's
⚠ … not found: using the EXAMPLE configurationno games/<GAME>/private/ending.config.tsstep 1
Usage: npm run seal -- --outcome=<key> (keys: …)--outcome missing or not a key of outcomespass one of the listed keys
✘ <file> contains in clear text: "…"an ending text is also in the game's contentremove it from the content: it belongs in the private configuration only
✘ private file in dist: …a private file was copied into public/remove it from public/
⚠ dist/data/dossier.bin missing (npm run seal not run?)no sealed file in the buildstep 2, then build again

Files or data changed

Limit · one-dossier-per-repository seal writes public/data/dossier.bin at the root of the engine repository, whatever the game selected: sealing another game replaces the demo's committed file. Restore it with git checkout public/data/dossier.bin before you commit, unless you mean to change it.

Recovery or going back

Sealing again with another --outcome replaces the file; the previous one is not kept. git checkout public/data/dossier.bin brings back the committed file.

References


Edit the source of this page · Report an error