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
- The shape of it
- The build
- The source
- The rules
- A journey
- The page
- Screens and the interface
- Sound and music
- Extending the engine
- Saving
- Balance and the tools
- Performance
- The stylesheet
- Testing
- Packaging
- 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:
- One file. The build writes
dist/<file>.html(thefilein the game’sedition.jsonc). That file is the game. It runs fromfile://with no server. - No network. No CDNs, web fonts, analytics or
fetch. The fonts, pictures and sounds are inside the file. The build refuses to write a page that containshttps://. - No dependencies. Plain HTML, CSS and JavaScript: no packages, frameworks or bundlers. The build needs Python 3 (and Node, to deal the stacks while it checks them); the game needs a browser. The sounds and music are made as they play.
- Old phones. A game is wrapped in an Android app whose WebView can be years old, on a phone with little memory. Avoid what an old WebView lacks and measure on a real phone (chapter 12).
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:
- The edition.
edition.jsoncis read: its name and file, its kinds of amulet (in sets), the music, the sounds and effects, the tutorial and every word (text). - Music. Each stack’s key, scale and instrument are checked against the engine’s (
INSTRUMENTS,SCALES) and the song styles incontent/music/against the instruments and layers the songs know. - 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. - 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()). - A journey’s content. Boons, relics, omens, places, events, the special tiles, the money and the covers. Every
effectmust be one the engine knows and the names are read from the code itself (RUN_EFFECTS,EFFECT_NUMBERS,PLACE_BLOCKSandSPECIAL_TILESinsrc/02-run.js), so adding an effect to the code is enough for the build to accept it. The pair effects are read fromFX.pairinsrc/game/50-effects.jsand the sounds fromRECIPESinsrc/game/20-sound.js. - 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. - The guard (chapter 1).
- Looks, pages and tables. Each stylesheet in the game’s
web/looks/,web/pages/andweb/tables/is scoped to itself ([data-look="ebony"] ...),extrude()is written out as stepped shadows for a tile’s side andurl(images/...)becomes the picture itself. - 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,MUSICand the rest), thensrc/*.js,src/audio/*.jsandsrc/game/*.js, each in name order. - 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 containhttps://. - Writing.
web/shell.htmlis filled in (/*TITLE*/,/*CSS*/,/*CODE*/) and written todist/<file>.html.
| Flag | What it does |
|---|---|
--check | checks everything and writes nothing |
--watch | builds again whenever a file is saved |
--debug | writes dist/<file>-debug.html, which keeps a play log on the device |
--game example | builds 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('<') 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 numbers are the order. Function declarations are hoisted and can be called from anywhere. A
constorletthat is used while the page loads (a table built from the content, say) must be declared in an earlier file than its first use, or the page throws on load. Functions that only run later don’t mind. - Top-level names are one space. Every function and top-level name must be unique across all files; the build refuses a name given twice.
- A new file needs only a name with the right number. Two files may share a number (
65-places.js,65-satchel.js): they go in the order of their names. - Each file starts with a note: what it is for, “What’s here” (its main names, a line each) and “Changes in the save”. Read it first; keep it true when you change the file. The code map follows these notes.
- To find something, search for
functionand its name. A line of dashes (// ---- the satchel ----) marks a part inside a file.
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:
| File | What’s in it |
|---|---|
02-run.js | the 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.js | the map, season by season (makeMap, makeSeason) |
04-run-stacks.js | what the stack at a place is: stackFor, stifferOn, sceneryFor, placeCover |
05-run-play.js | a stack’s play: startStack, afterPair, afterWrong, whenStuck, the help (mercyDue, askGods, undoPair), loseLife, leaveStack, afterClear, hintCost |
06-run-places.js | markets, shrines, the river: shopStock, itemPrice, buy, boonChoice, relicOffer, placeEvent, gain |
07-run-boons.js | boons: BOON_TARGETS, canTarget, useBoon |
A journey is one plain object (newRun), kept in the save as it is:
| Field | What it is |
|---|---|
seed, draws | its own chance: every draw of runRandom counts up draws, so a journey picked up from the save goes on as it would have |
level | the level’s id |
map, at | the rows of places and where the player is ({ row, col }) |
shuffles, coins, lives, gods | what the player has |
boons, relics | the ids carried |
cleared, over | stacks cleared; 'won' or 'lost' once it has ended |
seen | river events met, so none comes twice |
stack | how 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:
- Which layout (
stackFor). The first rows deal only the forgiving layouts ofstart. After them the size climbs along the journey andseasonLayerskeeps each season to so many layers. - How many kinds and which special tiles: the row’s
depth(the last entry whosefromthe row has reached), the level’skindsadded, an omen’s on top. Never more kinds than the set has. - 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.shapesdeals the tall ones more kinds and more buried twins and the wide ones fewer (never belowleastKinds), so the two meet in the middle. - Which deal (
dealChosen, chapter 4). - In play: the level’s
shufflesandlives,undoandwarn;mercy(the last few dealt again) andaskGods(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:
44-paint-pictures.jsmakes every picture once and keeps it: each amulet at the size it is shown, each cover and the tile’s body in the look being worn, photographed from a hidden tile wearing the look’s stylesheet by way of an SVG picture of its computed style (bodyOf).45-paint.jspaints a tile from those (paintTile) and keeps each tile as a picture of its own, so painting the stack is a copy of each.46-paint-specials.jspaints what a journey adds: a veil, rubble, a seal on its cord, a gem.47-paint-moves.jsholds the canvas, paints each frame and moves tiles on it asel.animatewould (moveTile,moveLeaving).
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);
};
| Kind | What it is |
|---|---|
main | the one way forward on a screen: large, full width, with a picture. One per screen. |
way | another way, beside it (a choice on the river). The default |
quiet | small, 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
- Fewer words, symbols with them. A player reads little: show a picture and a number rather than a sentence.
- Buttons are stone (the page style’s colours), chosen is pressed in and a button is no larger than it needs to be.
- Cards (
45-cards.css) for boons, relics, omens and places: a picture first, then a name and one line, on the game’s paper. - Never fade a picture with
opacityorfilter. On some phones a few faded pictures in a scrolling sheet stop the whole page painting, while the page says all is well. Dim with a veil laid over it instead. - No scroll bars where they can be avoided and the page itself never scrolls.
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.
- The audio engine (
src/audio/10-audio.js): the context and the instruments (plucked strings, bells, flutes, hand drums), made once into buffers where they can be. - The calm music (
20-music.js) and the songs (40-songs.js): the songs follow a style from the game’scontent/music/styles/(tempo, rhythms, instruments, sections) and come back to the tunes written by hand. Both play in the stack’s own key and follow how it is going (soundFollows,20-sound.js): tension rises as free pairs grow few. - Effects (
src/game/20-sound.js):sfx(event)plays the edition’s sound for an event, one ofRECIPESby name. Pitched sounds take their notes from the chord playing (runNote), so they sit in the music; a run of pairs climbs through it (climb). - Vibration: a short buzz for what happens (the edition’s
vibrate), through the Android app’s bridge or the browser’s. - Levels, measured without a speaker (
tools/loudness.js): a pair about −30 dB at its loudest, the music some 6 dB under it. Nothing may clip.
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:
| Key | What it keeps |
|---|---|
<game> | the settings (10-settings.js) |
<game>:run | the journey under way, as it is, with the stack in play (saveRun, 61-journey.js) |
<game>:home | what journeys bring home (HOME): coins, gems, relics, stacks found, days, deeds, what was bought, the tips seen, the tutorial played |
<game>:log | the play log, in a debug build only |
Try-out mode (?try) keeps its own under <game>:try and never touches the real one.
- Every field has a default.
HOMEisHOME_DEFAULTSwith the save laid over it, so an old save always loads; a new field goes inHOME_DEFAULTS. - Never rename or repurpose a saved field or an id. If one must change, the edition’s
renamedlists the old names (keysfor a field, values under their field) and they are read under their new ones. A game renamed lists its old files informerlyand its save is carried over once. - Every write is in a
try(storeJSON): the game runs even when storage fails. - Something the player can’t afford to lose is saved at once. A won journey’s relics go into
HOMEbefore their notices show, as the app may be closed while they do.
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).
- One canvas for the stack, painted from pictures made once (chapter 6). As page elements, a hundred and forty tiles each with its amulet took 0.35 s to show a tap; a layer for each ran out of graphics memory and the screen went black. Painted, about 0.12 s.
- Caps on what is kept: pictures made (240) and tiles painted (300) are let go past a ceiling, so a long session can’t keep growing.
- Never fade a picture in a scrolling sheet (chapter 7).
- A tap is timed on the phone with real touches (
adb shell input tap) and the page’s Event Timing, never with synthetic events. On the Huawei the floor is about 0.07 s. - Audio: a phone asks for a larger buffer and “Steadier sound” in Settings asks for a fixed one (80 ms, as in Amulets of Anubis); instruments are made once into buffers.
- Leaks:
tools/phone-soak.jsplays a long session on the phone and watches the heap, elements, listeners, live audio nodes, the caches and Android’s graphics memory. Nothing may keep climbing.
13. The stylesheet
engine/web/css/ is joined in file-name order, then the game’s web/css/, then the looks, pages and tables.
| File | Styles |
|---|---|
00-tokens.css | every 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.css | the page, the bar and the table, the notch and corners |
20-run.css | the scenes: home, the map, the places, the acts |
30-sheets.css | the sheets: the menu, Settings, Customise, How to play, the museum |
39-type.css | the typefaces in use (--display, --body) |
40-tiles.css | what every tile look shares |
41-bar.css | what every page style shares |
42-special.css | the special tiles, over any look |
45-cards.css | boon, 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):
| Tool | What it does |
|---|---|
phone-run.js | a whole journey, step by step, with a photograph of each screen |
phone.js | photographs of every page, table and look |
phone-paint.js | opens every sheet and tab and fails if the phone painted nothing (headless Chromium never shows this fault) |
phone-soak.js | the long session of chapter 12 |
phone-levels.js | the 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.
- The Android app (
platforms/android/,tools/build-android.py): a WebView showing the file from the app’s assets.MainActivity.javawires up storage and the Back button and passes the notch’s insets and the corners’ radius to the page as CSS variables (chapter 7);Vibration.javais the vibration bridge;SaveFile.javasaves a file (the play log) where the player chooses.--debugwraps the debug game instead. ItsBUILD.mdsays what it needs and how it is signed. - The desktop programs (
platforms/desktop/,tools/build-desktop.py): the file in Electron, with the network blocked, for Windows, macOS and Linux. - The website is the game’s own (its
tools/build-website.py).
16. House style
- Nothing of one game in the engine. A name, a word, a number, a picture or a colour that belongs to one game goes in that game’s files and the engine reads it from there; the guard checks it.
- British English in the engine’s comments, messages and docs, with no comma before “and”.
- The engine’s own words for its pieces: stacks, tiles, pairs, places, coins, gems, boons, relics, omens. What a game calls them on screen is its own business, in its
text. - Plain code for a first reader (chapter 3).
- Watch the engine’s size. Prefer a content file or a setting to new code and say how much code a change adds.
- Every file starts with a note (what it is for, what’s here, what it changes in the save); keep it true. Comments say what the code does and why, not who asked for it; the decisions are kept in the game’s
docs/history/. - Small commits that say what changed and why.