A map of the code
This map is written by the build (python3 build.py --docs) from the note at the top of every file, so it always matches the code. To change what it says about a file, change that file’s note.
Every file of the engine starts with a note: what the file is for, the main functions in it (“What’s here”) and what it changes in a player’s save. This page puts all of those notes in one place, in the order the build joins the files. Part 2 of the manual explains how the parts work together. (A game that carries the engine in its engine/ folder may carry fewer of the tools.)
The build
build.py
Builds a whole game into one file: dist/<file>.html, the file named in the game’s edition.jsonc, with the game’s other values of its own.
python build.py (on Windows you may need: py build.py)
python build.py --check only check the content files; write nothing
python build.py --watch build again by itself whenever you save a file
(leave it running; press Ctrl+C to stop)
python build.py --game <folder> build the game in that folder
This is the engine's build. A game that holds the engine as its engine/
folder has a small build.py of its own that runs this one for it; the game
is the folder that holds edition.jsonc (see game_root() below). Everything
said below about content/, images/ and dist/ is in the game's folder and
about src/, web/ and tools/ in the engine's.
It also writes dist/try-it.html: double-click it to open the game in try-out
mode, with a separate save and everything unlocked, straight at the stop or
river event you changed last. (Your real save is never touched.) And, when
rsvg-convert is installed, dist/art-references/: every picture as a PNG.
To start a new thing from a ready-made file: new.sh (or new.bat) in the game.
It needs only Python 3 and the file it makes needs only a web browser.
Everything is put inside that one file: the code, the stylesheet (web/css/,
joined in file-name order, with the game's own web/css/ files replacing or
joining the engine's), the fonts, the content files in content/ and every
picture in images/ (see images/README.md):
images/amulets/<name>.svg an amulet (.svg, .png, .jpg or .webp)
images/backdrops/<stop>.svg the scenery behind a stop, by its id (harbour.svg)
images/boards/<stop>.svg what shows through the gaps of that stop's board
images/floors/<set>/ a floor set: bare, thick and gilded squares
images/specials/, images/badges/ the marks of special amulets and badges
images/icons/ every icon (buttons, boons, relics, menu, map)
images/relics/<relic>.png a picture for a relic, instead of its icon
images/favicon.svg the picture in the browser's tab (or .png)
The content files (stops, relics, river events, trials, shops, looks...) are
checked before anything is written. If one has a mistake, the build stops and
says which file, which line and what to do; the last good game file is left
alone.
For the simulators in tools/: python build.py --content-json prints the
checked content, as the game sees it and nothing else. And
python build.py --docs writes the parts of the engine's docs that follow the
code by themselves and checks the rest (tools/reference.py).
The rules and the start-up (src/)
src/00-open.js
The player’s save: loading it, bringing an older save up to date and writing it back. The first part of the game: web/shell.html wraps all of src/ except 01-core.js in one function.
What's here:
save the player's progress, purse, looks and settings: one
object, kept in localStorage under the edition's
save_key (edition.jsonc).
Read and changed nearly everywhere.
persist() writes save back; call it after changing save
applySaveDefaults() fills in any field an older save lacks. A new save
field needs its default here (13-saves.js runs it on
an imported save too).
remapStops() keeps progress with its stop when stops are added or
reordered in content/stops/
EDITION edition.jsonc: the keys the save is kept under, the
save code's prefix (13-saves.js)
TRY, TRY_ID try-out mode (#try in the address), with its own save
PICTURES every picture from images/, put here by the build
applyColours(), the colour mode and less motion, from the Settings
applyMotion() screen or the phone (reduceMotion is read throughout)
deviceSetting() a setting kept in this browser rather than the save
(WebGL, fewer effects)
TAU a small helper used throughout
Changes in the save: fills in defaults and migrates old fields on load;
colours and motion (the Settings screen, 14-menu.js).
src/01-core.js
The rules of the game, with no browser code at all.
Everything here can be run in plain Node, which is how the simulators in
tools/ test the difficulty of every stop. Keep it that way: no document,
window, canvas or sound here. It is loaded on its own, before the rest.
The game's content (stops, amulets, trials, relics, river events, the
stall, the looks) is NOT written here: it lives in content/, which build.py
checks and pours into CONTENT below. This file holds the rules those files
plug into.
What's here:
CONTENT, T() everything from content/; T(key) gives the
words on screen (content/text.jsonc)
LEVELS, TRIALS ... short names for parts of CONTENT, used everywhere
BOARD_MODES Classic, Grand, Ruins and Omega and how each builds
its floor
DIFFICULTY the four difficulties
helpers the words every rule is written in: where a square
is (rowOf, neighbours, around, rowSquares ...), which
squares hold what (bareStones, plainAmulets,
edgeOfFloor ...), choosing (pickSome, pickOne) and
changing (thicken, coverAmulet, makeSpecial ...)
COVERS what an amulet can be held under (content/covers/)
SPECIAL_POWERS what the special amulets do
BADGE_POWERS what badges do (content/badges/ picks one each)
HARDSHIPS what curses and omens do (content/curses/, omens/)
BOONS the boons from content/boons/
conditionMet() whether a relic is found or a look unlocked
Core one game of one stop: the grid, matching, falling,
specials, covers, scoring; play() is one whole move
stopOptions() everything a stop needs to start; the game and every
simulator build their boards through it
Changes in the save: nothing (it never sees the save; the game passes in
what it needs).
src/02-pictures.js
Loads every picture before the game starts.
The pictures are files in images/ (see images/README.md); the build embeds
them in PICTURES (00-open.js). Nothing here draws: this only turns them into
images the canvas can paint.
What's here:
loadPictures() loads them all; 30-boot.js starts the game when done
AMULET_PICS[name] amulets (images/amulets/), each a canvas with room
round the drawing for its outline and shadow: the
128-unit picture in the middle of AMULET_BOX
drawAmulet() paints one of them so the drawing itself fills the
square given and its outline and shadow reach past it
amuletURL() one as a picture for the page, cut to its 128 square
SPECIAL[name] the marks of special amulets (images/specials/)
BADGE[kind] badges (images/badges/)
SET_PICS[set][name] an amulet set's own pictures (images/amulet-sets/),
with the same room round them
COVER_PICS[name] covers, drawn over an amulet (images/covers/) and
coverPicture(tile), the one for a tile's cover and
the layers it has left
FLOOR_PICS[id] {bare, thick, gilded:[...]} per floor set
(images/floors/)
PIC_SIZE, makeCanvas() the 128-pixel size and a helper that makes a canvas
of it (used by 04-boards.js)
Backdrops, board backings and relic pictures are used straight from
PICTURES by the code that shows them.
Changes in the save: nothing.
src/03-themes.js
Each stop’s theme, taken from its file in content/stops/.
What's here:
THEMES one entry per stop, in the order of LEVELS:
floor base colour of the bare stone
set which amulets are in play
audio {root, scale, inst}: the home note in Hz, the
scale as steps and the instrument
Used by the sound, the music, the board and How to
play.
The scenery behind a stop is a picture, images/backdrops/<stop id>.svg,
shown by setBackdrop() in src/game/05-state.js.
Changes in the save: nothing.
src/04-boards.js
What the board is made of: the colours of its frame, the amulet sets (looks) and the floor squares.
What's here:
BOARDS one per stop, in the order of LEVELS: the [face,
shade, edge] colours of the carved frame. What shows
through the gaps of the floor is a picture,
images/boards/<stop id>.
TILE_SPRITES the amulets in play, drawn in the chosen amulet set
skinned() an amulet set's pictures: the same drawings through a
canvas filter (content/amulet-sets/)
FLOOR_SETS the floor sets (content/floor-sets/, images/floors/)
buildFloors() makes FLOOR_STONE, FLOOR_THICK and FLOOR_GOLD, the
squares for the stop being played (05-state.js,
river events, the Treasury)
floorTextures() the same, for a preview, without touching the board
Changes in the save: nothing.
The running game (src/game/)
src/game/01-caches.js
The drawing caches that keep the game smooth on a phone.
The floor, its shadows and the amulet pictures don't change between moves,
so they are drawn once into offscreen canvases and copied onto the board
each frame. Anything that changes the floor (core.floor) must set
bgDirty = true, or the old floor stays on screen.
What's here:
buildBg(), bgLayer the floor and its shadows, drawn once (19-draw.js)
bgDirty, bgCells "draw the whole floor again" and "draw these squares
again"
scaledTiles, tileAt() the amulet pictures, scaled to the square size
haloImg() the glow round a chosen amulet
RIVER_CHANNEL the colours of the water down an Omega board
(content/settings.jsonc, "river_channel")
lowFx fewer effects: chosen in Settings, or turned on for a
slow phone (fewerEffects(), 19-draw.js)
Changes in the save: nothing.
src/game/02-board-pen.js
The board drawn with WebGL, on the graphics chip.
A trial (September 2026): on a phone, drawing the board with the canvas's
own calls cost more than the game's rules. GLPen understands the few
canvas calls the board uses (19-draw.js) and turns them into WebGL: every
picture goes into one big texture (an atlas), so a frame of amulets, glows
and sparks reaches the graphics chip in one or two batches instead of
hundreds of calls. Gradients, rings and text are drawn by a second small
shader. The drawing code itself doesn't change.
It is used wherever WebGL works on the graphics chip, unless the player
turned it off (Menu, Settings, "This device": the device setting "renderer"
= "2d", 00-open.js). Where WebGL is missing, fails to compile its two small programs,
or would run only in software, the board is a plain 2D canvas, as before.
Measured on three phones on 25 September 2026: about a quarter less work
for the page's main thread on the two newer ones.
What's here:
makeBoardPen(canvas) the board's pen (02-screen.js): GLPen or 2D
boardPen which it is: 'gl', '2d' (turned off) or 'none'
(WebGL can't be used here); the Sound screen shows it
class GLPen the canvas calls the board uses, in WebGL
glColour() a CSS colour as premultiplied numbers
A picture drawn on the board that changes afterwards (the floor layer,
bgLayer) must be marked: picture.glDirty = true (01-caches.js does).
Changes in the save: nothing (the choice is in localStorage, not the save).
src/game/02-screen.js
The page and the board’s size on it.
What's here:
$(id) document.getElementById, used everywhere
html`...` HTML laid out over several lines for reading; the
line breaks are dropped before it reaches the page
Tplain(), fillPlain() words from content/ as plain text, for titles and
labels (T() in 01-core.js gives them with bold and
italics)
canvas, ctx the board's canvas and its drawing context
cs, dpr the size of a square in pixels and the screen's
pixel density
fit() sizes the board to the window; runs on every resize
Changes in the save: nothing.
src/game/03-sound.js
The sound effects, made at run time with Web Audio (or a game’s own recordings, from sounds/, played instead): plucked strings, bells, gongs, flutes, crinkling paper, a rattle.
What's here:
sfx(name, n) plays an effect ('match', 'gild', 'unroll', ...);
called from everywhere. When the music plays, pitched
effects take their notes from its chord.
getCtx(), audioCtx the audio engine, made on the player's first tap
restartAudio() starts it again, for the "Steadier sound" setting
soundDelay() how late this device plays sound, in milliseconds
bell(), playInst(), note(), theme()
the instruments and notes, shared with 04-music.js
prewarmKS(), prewarmBells()
make a stop's notes ahead of time, so a match never
waits for them (05-state.js, at the start of a stop)
The effects play about 6 dB above the music; keep it so (the manual,
part 2, "Sound and music").
Changes in the save: nothing (the sound settings are changed in
14-menu.js).
src/game/04-music.js
The music: a score made as it plays, in each stop’s scale and instrument (THEMES, 03-themes.js).
Layers (a choir pad, a drone, plucks, an arpeggio, a pulse, a lead, bells)
come and go with how the stop is going: calm at first, busier as the moves
run out.
What's here:
startMusic(), stopMusic() on and off (the Menu's sound settings)
musicVolume() follows the volume setting
musicStopBegins(), musicFollow(), musicCascade(), musicResolve()
how the game talks to the music: a stop starts, the
moves run down, a cascade, a win or a loss
music the music's state; 03-sound.js reads its chord so
effects play in key
Changes in the save: nothing.
src/game/05-state.js
The stop being played: its state, starting it and the display around the board.
What's here:
core the game of the stop now on the board (a Core,
01-core.js)
busy, selected, trial, armed, particles, popups ...
what is going on: an animation running, the chosen
amulet, the trial, a boon ready to use, the effects
startLevel(i) starts stop i: builds its board with stopOptions(),
sets the scenery, the music and the trial. Called
from the map, a win, a loss, a new journey, the boot.
setBoard(), paintBoardFrame(), setBackdrop()
the board's size, its frame, the scenery behind it
rowsForScreen(), omegaSize()
how many rows fit (a taller board on a phone)
updateHUD() the top bar, the gilding tube and the column beside
the board; call it after anything they show changes
fitNote() the stop's note beside the board, cut to whole lines
with "Read on"
settle(), delay() wait for the board to stop moving, or for a time
Changes in the save: current (the stop being played), curse (used up),
omenPick.
src/game/06-icons.js
The icons: SVG files in images/icons/<group>/<name>.svg, put into ICONS by the build.
What's here:
iconSvg(group, name) a whole icon, ready for the page
iconArt(group, name) an icon's inside only, for code that wraps it in its
own <svg>
--ico-gold, --ico-lapis, --ico-relic their pictures, as CSS variables for
the .g-ico marks (web/css/03-hud.css)
BOON_ICON[id] each boon's icon (a boon's file may name another's)
sharedDefs() the gradients the icons share, put once in the page.
That is why a gradient id must be unique across all
the icon files.
In web/shell.html, {{svg:group/name}} puts an icon in the page.
Changes in the save: nothing.
src/game/07-trials-boons.js
Trials, curses and boons.
A trial is the extra challenge offered at the start of a stop
(content/trials/); failing one leaves a curse for the next stop
(content/curses/). Boons are one-use powers the player holds
(content/boons/); each boon's file names one of the effects in
BOON_EFFECTS.
What's here:
offerTrial(), makeTrial() the trial card at the start of a stop
trialProgress(), completeTrial(), failTrial()
a trial as it goes (21-moves.js, 24-win-lose.js)
curseFor(), carryCurse(), curseWords()
picking a curse, keeping it for the next stop and
saying what it does
renderTrial(), renderBoons(), renderCurse()
the trial, boons and curse in the column beside the
board
BOON_EFFECTS what each kind of boon does. A new kind of boon is a
new entry here (the manual, part 2, "A new boon
effect").
useBoon() uses a boon, on a square if it needs one
(22-input.js)
Changes in the save: curse, lapis (trial rewards), trialsDone, boons
(used up).
src/game/08-relics.js
Relics: their pictures and what happens when one is found.
Relics are content files (content/relics/), each saying in "found_when"
what finds it; 09-unlocks.js checks that after every stop.
What's here:
relicIcon(id) a relic's picture: its own from images/relics/, else
the drawing its file names (images/icons/relics/),
else a plain gold disc. Undiscovered relics show it
dimmed (CSS).
grantRelic(r) the relic is found: pays its reward, shows a banner
and puts a "new" dot on the Museum
Changes in the save: relics and gold, lapis or boons from a relic's
reward.
src/game/09-unlocks.js
What unlocks looks and finds relics and the banners that announce it.
Every look (amulet set, floor, frame, sparkle) and every relic has
conditions in its content file ("need", "found_when"), checked by
conditionMet() in 01-core.js.
What's here:
checkRelics() finds whatever relics are now earned, then runs
checkLooks() for the looks (called after a win, a
loss, a move, a river event)
conditionText(), lookNeedText(), needProgress()
what a locked look asks for, in words and how far
along it is (the Customise screen)
CONDITION_NAMES, CONDITION_TEXT
the words for each kind of condition. A new condition
needs its words here and in content/text.jsonc.
announce() a banner at the top of the screen, one at a time
(new relics, looks, seals, stages)
checkLooks() also runs when Customise opens, for anything earned where no
check ran (a restored save).
Changes in the save: skins, floors, frames, sparkles (unlocked looks).
src/game/10-river-events.js
River events and the small boards they share with tombs, temples and oases.
Between stops the boat may meet something on the river
(content/river-events/): a choice, or a small puzzle board with a goal.
What's here:
goNext(from) sail on from a stop: maybe a river event first, then
the next stop (after a win, or leaving a chamber)
startEvent(ev) shows an event: its choice, or its puzzle
eventChoice() a choice as cards, each saying what it costs and gives
setupSmallBoard() the board of a puzzle or a chamber, 8 wide and as tall
as its floor plan
(11-chambers.js uses it too)
eventAfterMove() after each move on a puzzle: goal met, or out of moves
giveReward(), rewardText(), getLine()
paying a reward and saying it in words ("You get:")
eventState the event under way, or null
Changes in the save: gold, lapis, boons, life, lastEvent; chambers and
chamberWins for chambers.
src/game/11-chambers.js
Tombs, temples and oases: the small, dim boards beside a stop, often with amulets under covers (content/chambers/, content/covers/).
A chamber's doorway opens once its stop is gilded. Tombs and temples wake
when the player goes back in (cursed badges, rising rewards); oases stay
calm. The board itself is set up by setupSmallBoard() in
10-river-events.js.
What's here:
chamberAt(i) the chamber beside stop i, if any
chamberOpen() whether its doorway is open
startChamber() goes in; chamberFromWin() and chamberFromCard() go in
from the win scroll or the stop card
visitReward(), returnBadges()
what a visit pays and the badges a return wakes
leaveChamber() comes out, to the next stop or back to the one before
placeKey(), placeIcon()
the words and picture for a tomb, temple or oasis
(TOMB_WORDS)
coversNote() the note on a chamber's card about the covers on its
floor (content/covers/, "chamber_note")
midSentence(), withArticle()
a name as it reads inside a sentence ("the Great
Hall"), by the article in content/text.jsonc, "names"
(also for relics, in 09-unlocks.js)
Changes in the save: nothing directly (10-river-events.js records the
visits and pays the rewards).
src/game/12-stall.js
The stall, where boons and moves are bought during a stop and the refunds that the stall and the Treasury share.
What's here:
openStall() the stall's scroll (the stall's button in the dock, the
Menu); what it sells is content/stall/
stallPrice() what an item costs now: it rises each time the same item
is bought at this stop (STALL_RISE, content/settings.jsonc)
logBuy(), undoBuy(), undoButton(), purseLine()
refunds: anything bought can be taken back until the
scroll closes. 16-treasury.js uses these too and
closeOverlays() (23-scrolls.js) empties the list.
Changes in the save: gold, lapis, boons, charges.
src/game/13-saves.js
Saving to a code and back and starting a new journey.
What's here:
openSaves() the Saves scroll (the Menu, the title screen): copy
the save out as a code, paste one in, or reset
everything
saveCode(), readCode()
the save as text (the edition's export_prefix,
"AMULETS1:" and a code) and back;
an imported save goes through applySaveDefaults()
(00-open.js)
openNewJourney() starts the river again from the first stop. Relics
and earned looks stay; everything that buys power is
reset.
Changes in the save: all of it (importing, resetting, a new journey).
src/game/14-menu.js
The Menu and the settings it opens.
What's here:
openMenu() the Menu: eight tiles in two rows (the map, How to
play, the Treasury, Customise, the Museum, sound,
difficulty and the stall or Learning pace) and the
ways out of the game small at the foot
openSettings() Settings: sound and music, vibration, colours,
motion and effects, this device; five tiles, each
opening its part (settingsSound() and the rest)
openDifficulty() difficulty, board shape and "fill the screen"; a
change restarts the stop
MENU_ICONS the Menu's icons (images/icons/menu/)
Changes in the save: the settings: sound, music, musicVol, sfxVol,
harmonise, steadySound, vibrate, vibrateChosen, colours, motion,
difficulty, board, fill.
src/game/15-title.js
The title screen.
Its own layer (#ovTitle) over the scenery of the stop the player is at,
with the rest of the game hidden (body.at-title). Shown on every launch
and from the Menu.
What's here:
openTitle() the title screen: Continue and the smaller ways in
(How to play, sound, saves, a new journey)
Changes in the save: nothing.
src/game/16-treasury.js
The Treasury and putting on a look.
What's here:
openTreasury() the Treasury: lasting upgrades bought with gold and
lapis (content/treasury/); purchases can be taken
back until it closes (logBuy(), 12-stall.js)
levelSquares() an upgrade's level as gold squares
applyLook() puts on an amulet set, floor, frame or sparkle and
redraws the board. Always go through this: it also
empties the amulet picture cache.
dockAmulet() the Amulets button's picture: an amulet in the
amulet set in use (applyLook() and 30-boot.js)
skinPreview(), floorPreview(), cachedPreview()
the small pictures of each look, drawn once and kept
(the Customise screen uses them)
Changes in the save: upg (upgrades bought), gold or lapis.
src/game/17-museum.js
The Museum: the relics found so far and the ones still to find, with what each was and what finds it.
What's here:
openMuseum() the Museum scroll (the Menu, the relics beside the
board)
museumSquares() a bare and a gilded square of the floor in use, as
pictures (the Museum and the relic strip beside the
board, 05-state.js)
Changes in the save: nothing.
src/game/18-customise.js
Customise: where the player dresses the game. One tab each for amulet sets, floors, the frame round the board and the sparkles.
What's here:
openCustomise() the Customise scroll (the dock, the Menu); locked
looks show what unlocks them (09-unlocks.js), and a
look for sale its price: a tap buys it, and it can
be taken back until the scroll closes (logBuy(),
12-stall.js)
framePreview(), sparklePreview()
the small pictures of frames and sparkles
warmPreviews() draws the previews in idle moments, so the screen
opens quickly (05-state.js starts it)
Choosing a look calls applyLook() (16-treasury.js).
Changes in the save: skin, floor, frame, sparkle (the looks being worn);
skins, floors, frames, sparkles, gold and lapis when a look is bought.
src/game/19-draw.js
The game loop: moving things and drawing the board every frame.
What's here:
frame() runs every frame (30-boot.js starts it): update(),
then draw(). Frames slow down when nothing moves
(idleWanted()), to save the battery.
update() moves falling and swapping amulets and the effects
draw() paints the board: the cached floor (01-caches.js),
the amulets, badges, effects and popups
drawTile() one amulet, with the mark of a special (How to play
uses it too)
fewerEffects(), fewer effects: the Settings choice and the careful
setEffects(), automatic that watches cascades on a slow phone
watchEffects()
Don't make gradients or shadows here per square per frame: cache them
(the manual, part 2, "Performance").
Changes in the save: nothing (fewer effects is kept in this browser).
src/game/20-effects.js
Small effects: sparks and vibration.
What's here:
burst(x, y, n) a burst of sparks at a square, in the chosen sparkle
colour (fewer with reduced motion or on a slow device)
vibrate(ms) a short buzz on a phone, if the player turned it on
Changes in the save: nothing.
src/game/21-moves.js
Playing a move: the swap, the matches, the cascade that follows and what they earn.
What's here:
attemptSwap(a, b) swaps two amulets (22-input.js); if they match, runs
cascade(), else swaps them back
cascade() clear, score, fall and refill, again and again until
the board is still; then checks the trial, the river
event, the win and the loss
showClear() what one clearing earns: gold, lapis, sparks and
sounds (the numbers are EARN, content/settings.jsonc)
BADGE_SHOWS how each badge power shows on the board when it fires
(the powers are BADGE_POWERS, 01-core.js)
Changes in the save: gold, lapis (and what they count towards: goldEarned,
lapisEarned, goldBits, lapisBits), suns, thickCracked, bestCascade.
src/game/22-input.js
The player’s hands on the board: tapping, dragging and the keyboard.
What's here:
cellAt(e) the square under the finger or the mouse
the pointer events tap two amulets, or drag one onto its neighbour,
to swap them (attemptSwap(), 21-moves.js); with a
boon ready, a tap uses it (useBoon(),
07-trials-boons.js)
the keyboard arrows move a cursor, Enter or Space picks, Escape
lets go
Changes in the save: nothing.
src/game/23-scrolls.js
Scrolls: every screen that opens over the board (the overlays in web/shell.html).
What's here:
showMsg(body, actions, opts)
the general scroll: words and buttons for what to
do next. Each action is [label, fn, {kind: 'go' |
'card' | 'quiet' | 'exit', sub, icon, dark, oasis,
short}]: one big "go" button for the usual next step
(the manual, part 2, "Scrolls"). opts.noClose hides
the ×; opts.onClose says what closing means.
exitButton() the full-width way out, with its ×
openOverlay(id), closeOverlays()
open one screen, or close them all. Closing also
ends the chance to take back purchases (shopLog).
anyOverlayOpen() whether a screen is open over the board
androidBack() Back, in the app and the browser (popstate): closes
the open scroll; on the board it asks before leaving
body.no-keys until a key is pressed (and after a tap), no focus
rings
Changes in the save: nothing.
src/game/24-win-lose.js
Winning and losing a stop.
What's here:
levelWon() the stop is gilded: stars, rewards, seals, the trial,
unlocks and the win scroll with the way on (the next
stop, a doorway, the map)
levelLost() out of moves: the lose scroll; when the floor was
nearly gilded, more moves to buy (the stall's "Three
more breaths")
showBoardEnd() the plaque on a won or lost board, when its scroll
was left by the map; from closeOverlays()
Both are called from cascade() (21-moves.js) when the board is still.
Changes in the save: stars, unlocked (the furthest stop), wins, streak,
fails, lastWin, life, met, omens, gold, lapis, goldEarned, lapisEarned,
boons, charges.
src/game/25-codex.js
How to play, a book in chapters.
What's here:
openHelp(chapter) opens it: on a wide screen the chapters run down the
margin, on a narrow one (CODEX_NARROW) it opens on a
contents page and turns like pages
CODEX_GROUPS the chapters, in two groups (on the board, on the
journey), each with a picture in
images/icons/codex/. The website reads this list too.
CODEX_STAGE the stage a chapter waits for on a first journey
(26-stages.js)
The chapters' words are in content/text.jsonc ("codex"); the amulets'
names and meanings come from content/amulets/.
Changes in the save: seenHelp.
src/game/26-stages.js
Staging: on a first journey the game’s parts arrive one at a time and “new” dots.
What's here:
STAGES each part (trials, boons, the stall, badges, curses,
chambers, ...): the stop it arrives at is in
content/settings.jsonc ("staging"), its banner in
content/text.jsonc
stageOn(name) whether a part is on yet; check it before showing
anything that belongs to one
meetStages() banners for anything newly on (at the start of every
stop)
markNew(), clearNew(), isNew()
"new" dots on the dock and the Menu, until that
screen is opened
openPace() Menu → Learning pace: everything on at once, or back
to one at a time
Changes in the save: staged, met, fresh.
src/game/27-seals.js
Seals: three challenges at each stop (content/stops/, “seals”), each stamped the first time a win meets it and worth lapis.
What's here:
stampSeals() after a win, stamps any seals newly met
(24-win-lose.js)
sealsOf(i), sealsLine()
a stop's seals and how they show on the win scroll
mark(), marks(), doorMark(), gildBar(), journeyRiver()
seals, omens and a doorway as small pictures (the
stop card's tiles)
SEAL_LAPIS what a seal is worth
Changes in the save: seals, lapis.
src/game/28-map.js
The map of the journey, the stop card and the Amulets scroll.
What's here:
openMap() the map: the river with every stop and a list under
it with each stop's stars and what else is there
MAP_SIZE the map's width and height: its picture's viewBox
(images/icons/map/background.svg)
openStop(i) the stop card: tiles for its seals, omens and
doorway, one open at a time; the big button follows
the open tile (onto the stop, or into the doorway)
showAmulets(), amuletList()
the Amulets scroll: the amulets on the board and what
each meant
Changes in the save: omenPick (the omens chosen for the next go at a
stop).
src/game/29-buttons.js
What the buttons around the board do: the dock (map, hints, restart, the shops, How to play, the Menu) and the note beside the board. Each only opens the screen that another file makes.
What's here:
the click handlers $('btnMap').onclick and the rest
syncInfoOpen() the note beside the board starts open on a wide
screen
Changes in the save: nothing.
src/game/30-boot.js
Starting the game, last of all.
Loads the pictures (02-pictures.js), starts the stop the player was at
(startLevel(), 05-state.js) and the game loop (frame(), 19-draw.js), then
shows the title screen. In try-out mode (#try=<id>) it goes straight to the
stop, river event or chamber named.
Changes in the save: nothing.
The stylesheet (web/css/)
web/css/00-colours.css
Every colour and texture of the interface, by name and the four colour modes.
This is the engine's own look: plain paper, grey stone and a little gold,
with no texture, so a game that brings no look of its own still reads
well. A game gives itself its own look with a 00-colours.css of its own in
its web/css/, which takes the place of this file (the manual, part 2, "The
stylesheet"); it must set every name set here.
The rest of the stylesheet names these colours (var(--ink), var(--bevel-hi))
and never writes one out, except for what looks the same in every mode:
gold and sand (metal is metal), shadows, water, the title screen (always
by night) and anything drawn on the board. So a mode is one short list
here. The Settings screen chooses the mode (applyColours(),
src/00-open.js), as data-colours on <html>: none for the game's own look,
'dark', 'hcl' (high contrast, light) or 'hcd' (high contrast, dark).
web/css/01-base.css
Fonts and the page itself: the fonts’ names, the reset, the body background and the scenery layer behind everything. (The page’s colours, --page and the rest, are in 00-colours.css.)
web/css/02-layout.css
The app shell: a fixed grid that never scrolls and the carved stone strips.
web/css/03-hud.css
The top bar: title, gauges (moves, gold, lapis, relics, score, stop).
web/css/04-board.css
The stage and the board: layer promotion, the Omega layout, the side tablet, the board frame and canvas and the gilding tube.
web/css/05-dock.css
The buttons along the bottom (map, hint, restart, shops, help, menu).
web/css/06-scroll-and-buttons.css
Paper scrolls, the stats tablet and the stone buttons.
web/css/07-side-panel.css
The side panel beside the board: trial and boon rows, place notes, relic strip.
web/css/08-shops-and-looks.css
Board size picker, toggles, the Treasury and stall rows and the Customise cards.
web/css/09-codex-and-sound.css
How to play (its index, contents and chapters), the tabs of Customise and the sound and music screen.
web/css/10-screens.css
The close button, New journey, the menu, the title screen and difficulty.
web/css/11-journey.css
Seals and omens, Learning pace and “The game grows as you travel”.
web/css/12-notices.css
Telling the player about new things: unlock banners, staging (hidden until met), “new” dots and the try-out badge.
web/css/13-overlays.css
Overlays (every scroll screen), help lists, the Basics legend and the map.
web/css/20-small-screens.css
Phones and short windows. Kept last on purpose: these rules override the ones above for narrow and short screens.
The tools (tools/)
tools/art-export.py
Exports the game’s pictures for editing in other programs, into dist/art-export/:
python3 tools/art-export.py
pdf/ every SVG as a vector PDF (Affinity, Illustrator, Pixelmator,
Preview): the shapes stay shapes, the gradients gradients.
pixel/ the small pictures (amulets, specials, badges, icons) as pixel
art at 32 and 64 pixels: few colours, hard edges, no blur. A
starting point for a pixel-art look, not a finished one.
aseprite/ the same pixel art as Aseprite files (.aseprite), one per
folder, each picture a frame, named by a tag; LibreSprite and
Pixelorama open them too.
sheets/ each folder of small pictures as one sprite sheet (PNG, 128 px a
picture) with a JSON map in Aseprite's own format, which game
engines (Godot, Unity, Phaser) and TexturePacker read.
The originals stay the SVGs in images/: those are what the game uses and
what Inkscape, Affinity and Illustrator edit best. The larger PNGs are in
dist/art-references/ (tools/art-references.py, run by the build).
Needs rsvg-convert (brew install librsvg) and ImageMagick (brew install
imagemagick). Nothing here is used by the game or the build.
tools/art-references.py
Turns every picture in images/ into a PNG in dist/art-references/, in the same folders (amulets/, backdrops/, icons/boons/, floors/<set>/, ...), for showing the game’s art to people and tools that can’t read SVG.
python3 tools/art-references.py [the game's folder]
build.py runs it after every build. It needs rsvg-convert (librsvg:
`brew install librsvg`, or `apt install librsvg2-bin`); without it, it says
so and does nothing and the game builds as before. Only pictures changed
since their PNG was made are drawn again, so it is quick after the first time.
The prints (JPEG) are converted with ImageMagick or macOS's sips, whichever
is there.
The size is the longest side, per folder: small things large enough to look
at closely, scenery large enough to see its detail.
tools/econ-sim.js
Economy sweep: plays whole journeys with the greedy bot and tracks what a player earns and unlocks, with the game’s own formulas. The bot never spends, never replays for stars or seals and never braves omens, so it shows the pace of a straight playthrough. Usage: node tools/econ-sim.js <cols> <rows> <journeys> <difficulty 0-3> node tools/econ-sim.js 8 13 4 1
tools/events-sim.js
Can a greedy bot finish each puzzle event? (people should do better)
tools/journey-sim.js
A first journey, as a new player meets it: the parts of the game arrive at the stops settings.jsonc says (badges, cursed badges...), a lost stop is tried again with the persistence moves and every board is built by stopOptions() in 01-core.js, exactly as the game builds it. It reports, for each stop, how often the first try wins and how many tries it takes, on the board sizes players see: 8 x 8 (a desktop window) and 8 x 13 (a phone).
node tools/journey-sim.js Normal, 40 journeys per board size
node tools/journey-sim.js 1 80 difficulty (0-3), journeys
node tools/journey-sim.js 1 40 later a later journey: everything on
The bot is greedy: it takes the move that gilds most, never plans and never
spends a boon, so a person should do somewhat better. Aim, on Normal, for a
first try that wins about 70-80% of the time early on and 60-70% late.
tools/load-core.js
Loads src/01-core.js into Node for the simulators, with the game’s content (the files in content/) filled in, exactly as the build does it. Usage: const K = require(‘./load-core’)([‘LEVELS’,’Core’, ...]);
tools/reference.py
The docs that follow the code by themselves. The build runs this with
python3 build.py --docs
and it does three things:
1. It rewrites the appendix of docs/content-reference.md ("every name the
build knows") from the names in the code and the build, so that list is
never out of date.
2. It checks the rest of the reference, which is written by hand: every
setting of every kind of content file has a row in its table, no row
names a setting that doesn't exist, "yes" and "no" in the Needed column
agree with what the build really insists on and the tables of boon
effects, badge powers, hardships and conditions list exactly what the
code knows.
3. It writes docs/code-map.md, a map of the code, from the note at the top
of every file.
It prints what doesn't agree and exits with 1 if anything doesn't, so the
reference can't quietly fall behind. An ordinary build of the example game
runs the checks too (without writing anything) and lists what it finds under
"Things to check".
The docs are the engine's (docs/ beside build.py), not the game's.
tools/rules-test.js
The rules’ own test: every helper, every kind of cover, every badge power and every hardship in src/01-core.js, each tried on a small board of its own and checked. Run it after changing the rules:
node tools/rules-test.js with the game the build would build
node tools/rules-test.js --game <folder> with another game
It needs a game with at least one cover (the example has five). It also
checks that every badge power has a way to show itself in the game
(BADGE_SHOWS in src/game/21-moves.js). Nothing here is random in a way
that matters: where a rule picks at random, the test checks what any pick
must satisfy.
tools/screenshots/check-looks.mjs
Checks that every amulet set stays easy to tell apart in every look.
python3 build.py # not needed: this reads the content itself
node check-looks.mjs # a report and dist/looks-check.png
For every look (content/amulet-sets/) it draws each amulet the way the game
does (skinned() in 04-boards.js: the same canvas filter, glow and tint) and
takes its average colour in Lab, the colour space in which distances match
what the eye sees. Then, for every set of every stop, tomb, temple and oasis,
it measures how far apart each pair of amulets is (ΔE). Two amulets whose
colours come closer than CLOSE can only be told apart by their shape.
The report lists, per look, how many pairs are too close and the worst ones;
looks-check.png shows each look's worst set so they can be judged by eye.
tools/screenshots/edge.mjs
Edge cases: plays the built game down the awkward paths a player finds by accident (losing and leaving the scroll another way, winning and coming back, tapping during an animation, turning the phone, a broken save) and reports every dead end: no scroll open and a board that can’t be played, or a scroll you can’t get out of. Also any page error.
node tools/screenshots/edge.mjs every case
node tools/screenshots/edge.mjs lose win just those (names below)
It plays a copy of the game (dist/edge/game.html) with a small window onto
the board added (window.__edge), so it can run the moves out or gild the
floor at once. The game itself is never changed. Build first.
tools/screenshots/pdf.mjs
Prints an HTML file to a PDF with Chromium, for the manual:
node tools/screenshots/pdf.mjs dist/website/manual-print.html dist/website/manual.pdf [title] [A4|A5]
A4 (or A5), backgrounds printed, the page number at the foot of every page. The
page's own stylesheet sets the margins (@page). Development
only, like the screenshots: the game never needs it.
tools/screenshots/smoke.mjs
A quick play-through of the built game, to catch a page error before a player does. Run it after any change to the code:
python3 build.py
node tools/screenshots/smoke.mjs
It starts a new player and a player part-way down the river, on a desktop
and on a phone. On each it makes moves with the keyboard, taps the hint and
opens every screen from the dock and the menu. It prints what it did and
fails (exit code 1) if the page logs a single error. It does not judge how
anything looks: take.mjs and the pictures in docs/images/ do that.
On a Mac the browser uses the real graphics chip (ANGLE on Metal), so the
board is drawn with WebGL as on a phone (src/game/02-board-pen.js); one
more run uses a browser without it, for the plain canvas the game falls
back to.
tools/sim.js
Balance harness: plays every stop the way the game does (random shape from the stop’s pool), on a given board size, with everything on (as on a second journey) and reports win rates. The board is built by stopOptions() in 01-core.js, the same function the game uses. For a first journey, as a new player meets it, see journey-sim.js.
tools/where.mjs
Where the engine is, where the game it builds is and that game’s values from edition.jsonc, for the browser tools. The rule is the one in tools/where.py and build.py: the game is the folder that holds edition.jsonc (TESSERA_GAME if set; else the folder above the engine when it sits inside a game as engine/; else the engine’s own folder; else its example/ game).
tools/where.py
Where the engine is and where the game it builds is, for the Python tools (build.py has the same rule written into it).
The game is the folder that holds edition.jsonc:
- the folder named in TESSERA_GAME, if set;
- else the folder above the engine, when the engine sits inside a game
as its engine/ folder;
- else the engine's own folder, if it holds an edition.jsonc;
- else the engine's example/ game, when the engine stands alone.