Sema-tawy: the docs

Part 2: The engine

Part 2 of the manual, for developers: how Plinth works on the inside and how to change it. For changing a game without code, see part 1. Every file is listed in the code map; Reading the code is the gentle way in.

This part is for people who’d like to change the engine itself: add a new kind of effect, a new screen or a new tool. We assume you’ve read chapter 3 of part 1 (“How the game is put together”) and know a little JavaScript and Python.

All the paths below are written as they look inside a game, with the engine in its engine/ folder. In Plinth’s own folder (~/Code/plinth), leave off engine/.

Contents

  1. The shape of it
  2. The build
  3. The source
  4. The rules
  5. A journey
  6. The page
  7. Screens and the interface
  8. Sound and music
  9. Extending the engine
  10. Saving
  11. Balance and the tools
  12. Performance
  13. The stylesheet
  14. Testing
  15. Packaging
  16. House style

1. The shape of it

A game made with Plinth is one HTML file meant to keep working, unchanged, for decades. That is the most important thing about the engine. Four rules follow from it and every change has to keep all of them:

The organising idea is that content and code are separate. Everything a player sees or reads that isn’t a rule is content, one file per thing in the game’s content/, with the words in its edition.jsonc. The rules, the drawing, the sound and the screens are code, in engine/src/. engine/build.py is the seam between the two: it checks the content and then puts it into the code.

build.py                  (the game's) runs engine/build.py for this game
edition.jsonc             the game's name, file, kinds of amulet, music, sounds, every word
content/                  the content, one file per thing and settings.jsonc
images/                   every picture
web/                      the game's own look: its skin, tile looks, page styles, tables
engine/build.py           checks the game, joins the code and the styles, writes dist/
engine/src/01-...07-*.js  the rules, with no browser code (the tools run them)
engine/src/audio/         the audio engine, the instruments, the music and the songs
engine/src/game/          the page, one file per part
engine/sounds/            the few sounds kept as files (a button, a stone, a choice, a page)
engine/web/shell.html     the page, with holes the build fills
engine/web/css/           the stylesheet, in a plain look of grey tokens
engine/tools/             simulators, the tests, the phone tools, the app builds, new.py
engine/platforms/         the Android app and the desktop wrapper
example/                  Button Box, the example game (in Plinth's own folder only)

The engine and the game. Everything in engine/ is the engine and makes any game from a folder of content. Nothing in it names a place, a God or an amulet: the guard in the build refuses the game’s own words (its name, its seasons, its boons’ and relics’ names and the edition’s guard list) anywhere in the engine’s code and any colour written outside the token file. The engine’s own look is plain grey; a game brings its colours, typefaces and textures (chapter 13).

Plinth and a game. Plinth has a repository of its own. A game keeps a copy in engine/, brought in by the game’s tools/update-plinth.py:

python3 tools/update-plinth.py                      # says what would change
python3 tools/update-plinth.py --copy               # copies Plinth's last commit in
python3 tools/update-plinth.py --copy --working     # copies Plinth as it is now, uncommitted work too

It writes the commit it copied into engine/FROM. Change Plinth in its own folder, never in a game’s copy: the next copy would undo it. A new file is copied only once Plinth’s git knows it (git add it first). A change to the engine goes in a commit of Plinth; the game’s copy of it and any content that uses it go in the game’s next commit.

The example. Button Box (buttons of a sewing box, in birch and felt) is Plinth’s own small game in example/. It proves the engine needs no game of its own: python3 build.py --game example builds it and PLINTH_GAME=example points any tool at it.

2. The build

engine/build.py reads everything, checks everything and only then writes. It writes nothing if any check fails, so a broken content file never ships a broken game. Each check adds a plain sentence to errors, naming the file (and the line, where it can); at the end they are all printed together.

In order:

  1. The edition. edition.jsonc is read: its name and file, its kinds of amulet (in sets), the music, the sounds and effects, the tutorial and every word (text).
  2. Music. Each stack’s key, scale and instrument are checked against the engine’s (INSTRUMENTS, SCALES) and the song styles in content/music/ against the instruments and layers the songs know.
  3. Layouts. Each stack’s shapes (layers, tall, phone) are read into places and dealt with the rules themselves, through Node. A shape that can’t be cleared or has an odd number of tiles is refused.
  4. Picture sets. Every picture becomes an SVG <symbol> with its inner ids given a prefix of its own (symbol()), so two pictures never borrow each other’s gradients. Blurred drop shadows, costly for a phone to draw, are made cheap (flat_shadows()).
  5. A journey’s content. Boons, relics, omens, places, events, the special tiles, the money and the covers. Every effect must be one the engine knows and the names are read from the code itself (RUN_EFFECTS, EFFECT_NUMBERS, PLACE_BLOCKS and SPECIAL_TILES in src/02-run.js), so adding an effect to the code is enough for the build to accept it. The pair effects are read from FX.pair in src/game/50-effects.js and the sounds from RECIPES in src/game/20-sound.js.
  6. Words. Every key the engine asks for must be in the edition’s text; every kind of amulet needs a name (a screen reader says it); every deed says when it is done.
  7. The guard (chapter 1).
  8. Looks, pages and tables. Each stylesheet in the game’s web/looks/, web/pages/ and web/tables/ is scoped to itself ([data-look="ebony"] ...), extrude() is written out as stepped shadows for a tile’s side and url(images/...) becomes the picture itself.
  9. Joining. The engine’s stylesheets and then the game’s (web/css/) in name order, then the looks. The code: the checked content as constants (EDITION, LAYOUTS, SETS, LOOKS, RUN_DATA, CARD_PICTURES, SPECIALS, MUSIC and the rest), then src/*.js, src/audio/*.js and src/game/*.js, each in name order.
  10. The last checks. Two top-level names alike are refused (in one script the later would quietly replace the earlier); the script must parse (node --check); the page must not contain https://.
  11. Writing. web/shell.html is filled in (/*TITLE*/, /*CSS*/, /*CODE*/) and written to dist/<file>.html.
FlagWhat it does
--checkchecks everything and writes nothing
--watchbuilds again whenever a file is saved
--debugwrites dist/<file>-debug.html, which keeps a play log on the device
--game examplebuilds Plinth’s example (in Plinth’s own folder)

One trap. The build escapes </ as <\/ inside the script, so the page’s </script> can’t end early. That breaks a regular expression such as /</g: write .split('<').join('&lt;') instead.

The manual as a website

python3 engine/tools/docs/build.py

makes the manual into a website in dist/docs/, with a search over every page and a PDF beside it. Which pages there are and their order are in the game’s docs/site.json.

3. The source

The build joins every file into one script that runs top to bottom, in file order: src/*.js, then src/audio/*.js, then src/game/*.js. Each file is complete JavaScript on its own, so an editor can read it. So:

The rules have no browser code. src/01-core.js to src/07-run-boons.js never touch document, window, a canvas or audio. That is what lets the tools run the real rules in Node: tools/engine.js loads those files the way the page does and hands every top-level name to a tool:

Object.assign(global, require('./engine.js'));
const stack = deal(readLayout(layout.layers), kinds, 7, {});

The style. The code is written plainly, for a reader new to it: words for names (stack, place, level), small helpers that say what they do, plain for loops and if/else rather than chains of ?:, reduce or clever spreads, one statement per line, function declarations, tabs. A short arrow that reads like a sentence is fine (kinds.map(k => nameOf(k))). HTML is built as template strings; a list of things becomes HTML through htmlOf(list, makeOne) and every word from the content goes through esc().

4. The rules

engine/src/01-core.js: a stack and its rules.

A layout is read into places { x, y, z } in tile units (readLayout): a tile is one unit wide and one tall and a layer may be shifted by half a unit so it sits between the tiles below. neighbours works out once what lies above, below, left and right of every place.

A stack (makeStack) is the places plus lists, one entry per place: kinds, gone and, for a journey, rich, gem, veiled, seal, rubble, wild and cover, with groups (kinds that all match each other) and history (for putting a pair back). A tile is its number in these lists.

Free (isFree): not gone, not rubble, not sealed, not covered, nothing above and nothing on the left or nothing on the right (isOpen). freeOnes and freePairs list them.

Taking (take) checks the pair matches, then remove marks both gone, opens seals of their kind, brings rubble a pair nearer to crumbling and lifts covers to their left and right. It returns what else changed ({ opened, crumbled, uncovered }) for the page to show. undo puts the last pair back from history.

The deal (fill) is why every stack can be cleared. It plays the empty stack forwards as a player could: take two free places, give them the next kind and so on, with rubble crumbling and seals and covers opening as they would in play. If it paints itself into a corner it starts again (up to 300 times), so the order it found is a way through. deal adds the special tiles, turns whole pairs into a group, rich or gem pairs and puts a veil only on a tile hidden by where it lies, so the way through still holds. A tricky deal (0 to 1) now and then gives a pair the kind of a tile lying on one of them, a buried twin, which makes the order matter more; no kind gets more than its fair share and a pair. Every order of chance comes from the seed through shuffled (Fisher and Yates), never from a sort whose comparison answers at random: a browser may take such a sort its own way and the stack of the day must be the same for everyone.

A chosen deal (dealChosen) deals so many candidates and has a careless bot play each a few times (trapRate), keeping the one whose share of stuck plays is nearest the row’s trap. Every candidate can be cleared; what differs is how much the order matters.

The shuffle (shuffle) deals what is left again the same way, keeping the same amulets. When no deal of what is left can be cleared (two tiles left, one on the other), it lifts the most buried tile away with one of its kind and tries again.

The bot (BOT.choose, carefulPair) takes the pair that frees the most tiles (freedBy). It doesn’t plan ahead, which makes it a fair stand-in for a person. canClear asks whether a stack can still be cleared, by the bot playing on, carefully and less so; lostAt walks back over a lost stack’s pairs to the one after which it could not.

The helpers near the top of the file (highestLayer, copyTiles and restoreTiles, allGone, countOf, firstOfGroup, aPair) are what the rest is written in. A new rule goes beside its kind; node tools/rules-test.js checks them all.

5. A journey

engine/src/02-run.js to 07-run-boons.js, also without browser code, a file for each part:

FileWhat’s in it
02-run.jsthe basics: RUN_EFFECTS (every effect a boon, relic or omen can have) and EFFECT_NUMBERS (the numbers each takes), PLACE_BLOCKS, SPECIAL_TILES, the journey’s own chance (runRandom, runPick, runShuffled), the relics carried (hasRelic, relicsWith, sumOf, productOf), newRun, nextPlaces, goTo, leavePlace, endRun
03-run-map.jsthe map, season by season (makeMap, makeSeason)
04-run-stacks.jswhat the stack at a place is: stackFor, stifferOn, sceneryFor, placeCover
05-run-play.jsa stack’s play: startStack, afterPair, afterWrong, whenStuck, the help (mercyDue, askGods, undoPair), loseLife, leaveStack, afterClear, hintCost
06-run-places.jsmarkets, shrines, the river: shopStock, itemPrice, buy, boonChoice, relicOffer, placeEvent, gain
07-run-boons.jsboons: BOON_TARGETS, canTarget, useBoon

A journey is one plain object (newRun), kept in the save as it is:

FieldWhat it is
seed, drawsits own chance: every draw of runRandom counts up draws, so a journey picked up from the save goes on as it would have
levelthe level’s id
map, atthe rows of places and where the player is ({ row, col })
shuffles, coins, lives, godswhat the player has
boons, relicsthe ids carried
cleared, overstacks cleared; 'won' or 'lost' once it has ended
seenriver events met, so none comes twice
stackhow the stack in play is going (startStack)

The map is rows of places joined to the row above without crossing. With the settings’ map.pattern, each row is what the pattern says (a place, or a group drawn by weight). Each season is a map of its own ending in its great stack, which leads to every place of the next season’s first row.

A place is put together from building blocks in its content file (PLACE_BLOCKS): a stack, a shop, a choose, a gift, an event, a reward. The engine knows no particular place.

How hard a stack is, in the order it’s decided:

  1. Which layout (stackFor). The first rows deal only the forgiving layouts of start. After them the size climbs along the journey and seasonLayers keeps each season to so many layers.
  2. How many kinds and which special tiles: the row’s depth (the last entry whose from the row has reached), the level’s kinds added, an omen’s on top. Never more kinds than the set has.
  3. The screen’s shape (stifferOn). A phone’s tall shapes play much easier than a computer’s wide ones, as short rows split a stack into parts that come apart by themselves. shapes deals the tall ones more kinds and more buried twins and the wide ones fewer (never below leastKinds), so the two meet in the middle.
  4. Which deal (dealChosen, chapter 4).
  5. In play: the level’s shuffles and lives, undo and warn; mercy (the last few dealt again) and askGods (the stack dealt again once a journey); the boons and relics that rescue a stuck stack.

Measured, the shuffles, lives and askGods move the wins most, the same on every screen. Most of steps 1 to 4 act differently on a phone’s shapes and a computer’s, or not at all. A game keeps its own measurements (in its docs/difficulty.md).

The engine knows boons, relics and omens only by their effect. The game gives each a name, a line, a picture and numbers in content/ and RUN_CONTENT holds them by id (useRunContent).

6. The page

engine/src/game/, one script with the rules. What each file holds is in the code map; these are the ideas that run through them.

Modes. GAME.mode (60-play.js) is home, map, free (a stack in free play or a daily challenge), run (a stack on a journey), tutorial or, while a stack is shown again, replay (the play log) and lookback (where a stack was lost). The screens (#home, #map, #scene) lie over the table; with none showing, a stack is played.

A tap (setUpBoardTaps, 70-menu.js) becomes tap(i) (60-play.js): a tile that isn’t free is nudged and told why (notFree); the first of a pair is chosen; the second is taken with take and pairTaken notes it, draws, plays the effect and the sound and, on a journey, calls runPair (65-journey-stack.js). A tap takes effect as the finger touches (pointerdown), not when it lifts.

Drawing. The stack is painted on one canvas, as Amulets of Anubis paints its board:

Over each tile, drawStack (40-board.js) places a button that draws nothing: it takes the tap, names the tile for a screen reader and carries the chosen tile’s ring and the hint’s glint.

Effects never hold the rules up (50-effects.js). The stack is drawn as it now is at once; a tile leaving is copied onto a canvas of its own above (ghost) and only the copy moves. Each effect is a function (fxMeet, fxDust, fxGather) and FX lists them by kind and name. With less motion, a short fade instead.

After every drawing (redraw), afterDraw (74-help.js) looks at the stack: the last few to deal again, a dead end to warn of, a tip to point out with the game’s label (pointAt), a journey stuck (runStuck).

Words. Every word comes from the edition’s text (TEXT, in 01-helpers.js): nothing English is written into the code. words(key, n) fills in a number; esc() makes any word from the content safe in HTML.

7. Screens and the interface

The interface has a small set of parts. Use them and a new screen looks like the rest without new CSS.

Scenes: scene() and act()

The home screen, the map and every place between stacks are scenes (62-screens.js): scene(html, place) shows the HTML over the table, on the place’s own scenery if it has one and returns the element for the buttons to be wired up. A way on is an act:

const el = scene(
	`<h2>${esc(title)}</h2>${runStrip()}` +
		`<div class="acts">${act('on', { kind: 'main', pic: goOnPicture(), name: TEXT.goOn })}</div>`,
	place,
);
el.querySelector('[data-do=on]').onclick = function () {
	goOn(place);
};
KindWhat it is
mainthe one way forward on a screen: large, full width, with a picture. One per screen.
wayanother way, beside it (a choice on the river). The default
quietsmall, at the foot, for going back or on without doing anything (in acts-small)

way (a place’s colour, the game’s web/css/62-ways.css) colours a place’s way in; line puts a small line under the name.

Sheets: openSheet()

The menu and everything it opens (Settings, Customise, How to play, the museum, the stacks) are sheets (68-sheets.js): a panel over the dimmed table, with “‹” and “×” in one row at the top and its first heading as its title.

openSheet(`<h2>${esc(MENU_TEXT.settings)}</h2>${tiles}`, { back: openMenu });

back is where “‹” goes; wide makes a wider sheet; onClose says what happens however it is closed. A sheet’s choices are tiles (menuTile), each with a picture and a word of state.

The visual language

The bar

One small bar of controls (70-menu.js, 10-page.css): the jar that fills as the stack empties, the stack’s name and mode, on a journey the coins and the satchel, then shuffle, undo, hint and the menu. At the top on a wide screen, at the foot on a phone. runBar (65-satchel.js) fills in a journey’s part.

Notches and rounded corners

The page keeps clear of a phone’s notch and rounded corners through CSS variables (10-page.css): --edge-top and the rest come from the Android app’s --cut-top (and --round-top for the corners), falling back to the browser’s safe area. Anything placed against an edge uses them, as the bar and the stack’s name do:

top: calc(var(--edge-top) + max(2px, var(--corner-top)));

How to play and the tutorial

How to play (69-how.js) is read entirely from the edition’s text.help: chapters of blocks, some of which the game fills in from the content (levels, places, coins, bar, specials, cards), so they stay true. Special amulets are painted as they look in play (lonePainted). The tutorial (74-help.js) is a small stack of the game’s own whose steps wait for their moments.

Accessibility

Keep keyboard play and aria-labels on icon buttons; every tile’s button names its amulet (the edition’s text.kinds). Respect less motion (SETTINGS.lessMotion): the player’s choice, starting from the phone’s own. The controls come in three sizes (UI_SIZES) and there is a high contrast mode.

8. Sound and music

Everything is made as it plays, with Web Audio, apart from four short sounds kept as files (sounds/: a button, a stone, a choice, a page). More is in Sound and music.

9. Extending the engine

Most additions are content (part 1). These need code.

A boon that does something new

Say we’d like a boon that lifts every cover at once.

1. Name the effect. In engine/src/02-run.js, add it to the boons in RUN_EFFECTS:

boons: ['show-pair', 'deal-again', ..., 'swap', 'lift-covers'],

The build now accepts "effect": "lift-covers" in a boon’s file.

2. Say what it does. In useBoon (engine/src/07-run-boons.js), add an else if after the last effect. It changes the stack and sets what it did (or leaves out as null if it can’t be used and the boon is kept):

} else if (boon.effect === 'lift-covers') {
	const lifted = [];
	for (let i = 0; i < stack.places.length; i++) {
		if (stack.cover[i] && !stack.gone[i]) {
			stack.cover[i] = null;
			lifted.push(i);
		}
	}
	if (lifted.length) out = { lifted: lifted };

It needs no tiles chosen, so BOON_TARGETS stays as it is. A boon that does need them says which ('free' or 'any') there and canTarget checks each tap.

3. Show it. applyBoon in 65-satchel.js redraws the stack after any boon, so this one shows at once. For an effect of its own, add it there (as out.pair shows a hint).

4. Content. python3 engine/tools/new.py boon "Breath of the North Wind", then set "effect": "lift-covers", a picture and a price.

5. Test. Add a check to tools/rules-test.js if it touches the rules; the smoke test’s “every boon” step uses it on a journey by itself. Part 1, chapter 9, walks through this one line by line for a first reader.

A relic effect

Add the name to RUN_EFFECTS.relics (and, if it works with a number, to EFFECT_NUMBERS, so the build insists on it in the relic’s file). Then act on it where it belongs in 05-run-play.js: afterPair (for each pair), whenStuck (stuck with no way on), afterClear (a stack cleared) or startStack (a new stack); or in the places’ gain and itemPrice (06-run-places.js). Find the relics of that effect with relicsWith(run, 'my-effect') and return their ids in atWork, so the page shows them rising from the satchel.

An omen effect

RUN_EFFECTS.omens, then where it acts: on the deal in stackFor (04-run-stacks.js, as more-kinds and seal-ring do), in play (as no-hints in hintCost) or as a look: applyOmen sets data-omen on the page and the game’s stylesheet does the rest (as night and blind).

A special tile

Its name in SPECIAL_TILES (02-run.js); how it is dealt in deal (01-core.js), so the way through still holds; what it does to isFree or remove; how it is painted in 46-paint-specials.js; its card and colours in the game’s content/specials/. Add a check to the rules test.

A place block

Its name in PLACE_BLOCKS (02-run.js), what it does in 06-run-places.js and its screen in 65-places.js (a scene with its acts). Then any place file may use it.

A setting

A field in the game’s content/settings.jsonc, read by the code as RUN_CONFIG with a default where it is used. Tuning numbers never go in the code.

A word

A key in the edition’s text, read as TEXT.myKey. The build checks that every key the engine asks for is there, so add it to the example’s edition too.

A saved field

See chapter 10: it needs a default.

10. Saving

A game’s save is kept in localStorage under the game’s name (its file without .html), in a few keys:

KeyWhat it keeps
<game>the settings (10-settings.js)
<game>:runthe journey under way, as it is, with the stack in play (saveRun, 61-journey.js)
<game>:homewhat journeys bring home (HOME): coins, gems, relics, stacks found, days, deeds, what was bought, the tips seen, the tutorial played
<game>:logthe play log, in a debug build only

Try-out mode (?try) keeps its own under <game>:try and never touches the real one.

11. Balance and the tools

The rules run in Node (tools/engine.js), so the bot can play them thousands of times.

node engine/tools/sim.js 200                       # every stack, alone and with a shuffle
node engine/tools/run-sim.js 200 0.6 0.25          # whole journeys: how careless, how many pairs overlooked
SHAPES=phone node engine/tools/difficulty.js 200   # every level, careful and careless, against its aim
node engine/tools/shapes.js                        # each stack's wide shape against its tall one
node engine/tools/play-log.js <log.json>           # a real player's play log, read
node engine/tools/fit-player.js <log.json> ...     # a pretend player fitted to real play logs
PLAYER=player.json node engine/tools/run-sim.js 200  # whole journeys played as that person

fit-player.js lays every pair from the logs again beside the others free at the time and finds what weighs with that person (the tiles a pair frees, how high it lies, the pairs it opens, whether it is safe: PAIR_LOOKS and personPair in 01-core.js). Weighing the pair in front of you is not enough to play like a person, who also sees a trap coming, so it also finds how often the pretend player must look ahead to clear the logged stacks as often as the person did. It keeps their share of wrong pairs, the places they go to on the map, what they buy and their pace, so the run simulator can say how long a journey would take them.

The bot is greedy and never plans, so people do a little better; it never uses undo or hints. Each level’s aim is in the settings (wins: careful and careless, in a hundred journeys). At 200 journeys a result wobbles by about 5 between runs, so judge anything smaller as chance. SHAPES=phone or wide plays one kind of screen: the levels are tuned on a phone’s.

A game may keep tools of its own beside these: one that changes a single setting at a time on a copy of the content and measures each, for instance. What was measured and what each setting was found to do belong in the game’s docs/difficulty.md.

12. Performance

The target is an old Android phone (the owner’s Huawei).

13. The stylesheet

engine/web/css/ is joined in file-name order, then the game’s web/css/, then the looks, pages and tables.

FileStyles
00-tokens.cssevery colour the engine uses, by what it is for, in a plain grey look: the only place a colour is written in the engine
10-page.cssthe page, the bar and the table, the notch and corners
20-run.cssthe scenes: home, the map, the places, the acts
30-sheets.cssthe sheets: the menu, Settings, Customise, How to play, the museum
39-type.cssthe typefaces in use (--display, --body)
40-tiles.csswhat every tile look shares
41-bar.csswhat every page style shares
42-special.cssthe special tiles, over any look
45-cards.cssboon, relic, omen and place cards

A game’s own look. Its web/css/00-skin.css sets the tokens again in its own colours (and may add its own), its 05-fonts.css embeds its typefaces and other files add what is its own (the scenery, the ways into places). A page style (web/pages/) sets the tokens once more for its page, a tile look (web/looks/) draws the tile and a table (web/tables/) what the stack lies on; the build scopes each to itself. The canvas reads its colours from the tokens too (--paint-*), so a look changes the painted stack as well.

Write var(--ink) and never a colour. If no token fits, add one to 00-tokens.css with a comment saying what it is for.

14. Testing

After any change to the engine:

python3 build.py
node engine/tools/rules-test.js      # the rules: 101 checks on small stacks of their own
node engine/tools/smoke.js           # the built game at four sizes, in headless Chromium

The smoke test plays the game from a wide desktop to a 360 px phone: the tutorial, free play, every sheet and chapter of How to play, the daily challenges, then a journey step by step (run-checks.js: every place, every boon aimed where it needs tiles, a stack lost and won). It fails on any page error. In Plinth’s own folder, run both on the example too: python3 build.py --game example and PLINTH_GAME=example node tools/smoke.js.

When stacks or numbers change, run sim.js and run-sim.js; after a change to difficulty, difficulty.js (chapter 11); after a change to sound, loudness.js.

On a real phone over USB (phone-lib.js says what the tools need):

ToolWhat it does
phone-run.jsa whole journey, step by step, with a photograph of each screen
phone.jsphotographs of every page, table and look
phone-paint.jsopens every sheet and tab and fails if the phone painted nothing (headless Chromium never shows this fault)
phone-soak.jsthe long session of chapter 12
phone-levels.jsthe music’s loudness, live

Changing code without changing the game. When a change should alter nothing in play (making code easier to read, say), record before and after: the pretend players on fixed seeds must play the same journeys to the last coin and screenshots of every screen must match.

The browser tests need Playwright; they borrow Amulets of Anubis’ (NODE_PATH=~/Code/amulets-of-anubis/engine/tools/screenshots/node_modules).

15. Packaging

The engine builds the web game: one file that plays in any browser. That file is the thing to keep; everything else wraps it.

16. House style