A mobile-first block-building-meets-roguelike hybrid. You play both halves of the board at once: steer falling shapes to build the dungeon floor, while moving your hero across the tiles you just laid down to fight, grow stronger, and descend.
- Story based on Celtic Mythology (Tuatha Dé Danann cycle)
- Turn-based combat
- Classic block-building game-play
- Character progression via random boons (Geasa) and Ogham Brands
- Central nexus (Sidhe Mound) with stores and transient vendors, npcs and friends.
- Tons of NPCs, random interactions, omens that affect levels, and game-play variations means no run is ever the same!
Not in anyway associated with or endorsed by Blue Planet Software / The Tetris Company. This is a free game that pays homage to RPGs and that falling-block game that we all know and love :)
Built with TypeScript + Vite as an installable PWA. Rendering is a single <canvas>; every modal (start screen, class/modifier pick, shop, altar, character sheet, codex, game-over, …) is a native Web Component with no framework and no Shadow DOM — see Tech stack.
- Causeway to Ériu
The loop. Blocks fall on a gravity timer (and every hero/block action also advances a turn). When a block locks, its cells become dungeon floor — and some cells carry riders: monsters, altars, the tattoo artist, stairs, and traps. Your hero can only walk on floor you've built. So you're constantly deciding where to drop pieces to shape a path, reach altars, and corner enemies.
Combat is dice-based and turn-based (see below). Progression is XP → player level → bigger combat dice, layered with boons (Geasa), brands (Ogham Marks), curses, a starting class, and — for the Draoi class — a patron pact with one of three deities.
Winning. There is one true win condition: let the shape stack reach the ceiling. Instead of dying, the Rift stops producing blocks and summons Bres the Beautiful, a colossal fixed-stat boss who descends slowly from the top of the board as he nears completing his bridge to Ériu. Defeat him and you win. This is a deliberate choice — you gather strength across floors, then commit when ready. Once he's summoned there's no retreat: the causeway is finished, so every remaining stairs tile on the board vanishes (beaming away like any other departing NPC/altar) — you either finish the fight or you don't.
Losing. Your HP hits zero.
Between runs, a lore codex (accessible from the sidebar) tracks every boss, NPC, biome, and patron you've discovered across all past runs, persisted in localStorage — undiscovered entries show as "???" until you meet them. Death/victory screens also carry a short generated recap of the run's notable beats (close calls, bosses summoned, pacts sworn) alongside the stats grid, share text, and full run log.
| System | Where | Summary |
|---|---|---|
| Dice combat | src/systems/combat.ts |
Attacker & defender each roll a die sized by combat level (L1→D4 … L6→D20). Outcome by margin: miss / weak (0.5×) / normal (1×) / power (1.5×) / crit (2×, natural-max roll). Miss-pity: the 3rd consecutive whiff is upgraded to a hit. Graze floor: a miss still chips ~25% ATK, so no swing is wasted. |
| Turns & gravity | src/game.ts |
tickMsForLevel = max(400, 3000 − (floor−1)·100) × (1 + slow%/100). Floor 1 ≈ 3s/tick; deepens toward the 400ms floor. Every hero move / block action also advances monster turns — except rotation, which is free (it cycles in place; charging a turn for it just punished lining up a drop). |
| Boons (Geasa in-game) | altars & level-ups | Stackable passives (ATK, HP, dodge, regen, line-clear damage, …), grouped into tiers I–III. |
| Brands (Ogham Marks in-game) | Occult Tattoo Artist tiles | Permanent, body-slotted marks; collecting a set of the same mark grants a powerful set bonus (e.g. War ×3 → +10 ATK, Life ×3 → free revive). |
| Curses (modifiers) | chosen at run start | Run-long trade-offs (Glass Cannon, Overclock, Cursed, Berserker, …). |
| Classes | chosen at run start | Chronomancer (time/tempo control), The Architect (shape-layer synergy, tankier), An Draoi (blood-priced magic, gains a patron pact). Fully data-driven from classes.json — see Data files. |
| Patron pacts | An Draoi only, src/data/patrons.json, src/pact.ts |
A deity emissary waits in the sídhe-mound waystation from floor 2 until An Draoi swears a pact with one of 2 offered deities (of the Morrígan, Manannán mac Lir, Tethra), each granting its own 3-spell kit as the run's ranged ability — at a permanent ATK toll. |
| Offers | src/dataLoader.ts |
Every 3-choice offer (boons/brands) guarantees ≥2 distinct roles and nudges toward what you already own; gold can reroll. |
| Biomes & terrain | src/dataLoader.ts, src/game.ts |
Depth-scaled monster HP and gravity, biome-specific bosses, and a terrain type (swamp/sacred/ice) that certain piece shapes lay down on lock. Both the kind and the density of terrain are biome traits: each biome's terrainShapes set decides which shapes freeze into ground, so the Sídhe Caverns ice up more of the board (S/L/J/O/I) than the shallow Cairn Halls (S/L/J) — the play-feel shifts as you descend, not just the palette. |
| Boss floors | src/game.ts |
Every 5th floor is boss-eligible — entering one toasts "You sense dark forces lie in ambush!" — but the boss doesn't spawn until the built floor covers at least half the field overall (not just one tall column), so reaching the stairs early just carries the fight into the next floor instead of skipping it. |
| Floor events | src/data/floor-events.json |
A narrative choice every few descents (skip boss floors) — standing stones, Fomorian plunder, etc., each with data-driven option handlers. Rolled on the descent but embodied as a sheltering stranger in the waystation, held (across floors if need be) until you visit and meet them. |
| Lore codex | src/codex.ts, src/storage.ts, <codex-modal> |
Cross-run discovery log for bosses/NPCs/biomes/patrons, persisted in localStorage, with a completion bar (9 / 23 discovered) and an unlock ladder: passing 25 / 50 / 75 / 100% grants a small permanent perk at the start of every future run — starting gold, a tier-I Geis, a Deathward charge, a tier-II Geis. Tuned in balance.json's codex.unlocks; ids that no longer match live content are ignored rather than inflating the count. |
| Next goal | src/views/nextGoal.ts |
The death/victory screen names the single nearest unfinished thing — "One floor short of your deepest (9)", "2 codex entries from complete", "the Spear of Lugh needs 1 more" — so a loss ends with something to aim at instead of a dead end. Ordered by how close a goal is, not how big. |
| Lugh's Spear questline | src/data/smiths.json, src/smithQuest.ts |
Every 3rd floor (skipping boss floors) toasts "You hear the clang of an anvil..." on entry, "The sound of anvils is getting stronger!" at 10 shapes placed, and embeds one of three legendary smiths (Luchta, Credne, Goibniu) as a guaranteed encounter on the 20th shape dropped that floor (balance.json's smiths block). Each grants one spear part; Goibniu's third meeting reforges it, replacing the player's ranged ability with a bolt that pierces every monster straight up their own shape column. |
| Shape reward | src/game.ts |
Clearing all 4 lines at once (once per run) draws the eye of An Dagda himself: no dialog interrupts the clear — the Good God takes a seat in the sídhe mound's corner with his cauldron, and greeting him gifts one random tier-3 Geis. |
| Floor progress dial | src/ui.ts, #hud-strip |
The HUD shows one compact segment per pending milestone — smith piece count, boss fill %, stairs pity countdown (hidden while a stairs tile is on the board) — so "keep stacking or descend?" is an informed choice. |
| The Daily Rift | src/rng.ts, src/main.ts, src/storage.ts |
One seeded run a day — the same dungeon for every player, keyed off the UTC date (hashSeed(dailySeedString())). Locked to the default difficulty with no NG+ heat so scores are comparable; class and curse are still yours to pick. One attempt per day, tracked with a streak (consecutive days, plus a best-ever), and the death/victory screen produces a shareable summary. |
| Floor readiness | src/game.ts, src/ui.ts (balance.json → floorReadiness) |
The soft answer to "how much Tetris should I play?" Each non-boss floor has a readiness target that scales with depth (baseTarget + (level-1)×perLevel blocks). The dial leads with it — building 6/12 → a glowing ripe once you've built the floor out → lingering past target×lingerMult — with one-time toasts at the ripe ("descend when you're ready") and lingering ("the rift grows restless") thresholds. Descent is never gated — it's pure guidance. The run's average pieces/target ratio is banked at each descent, and the seanchaí in the mound judges it: leaving too soon, lingering too long, or judging it well (thresholds seanchaiEarlyRatio/seanchaiLateRatio). |
| Floor omens | src/data/omens.json, src/game.ts |
~60% of non-boss floors past floor 1 roll a per-floor modifier, announced by toast and shown as a sidebar badge: the Rising Bog (low rows turn swamp), Féth Fíada (vision −2), Cluricaun's Hoard (2× line-clear gold), the Unquiet Cairn (more monsters, skeleton-heavy), Fomorian Weight (gravity +20%), Wild Rift-Surge (2.5× cursed/blessed pieces), and the Night of Bealtaine ritual below. All param-driven — adding an omen is a JSON edit. |
| Night of Bealtaine | src/game.ts |
A ritual omen: every 5th piece carries a brazier; walk into an unlit one to light it. Line clears destroy braziers (protect their rows!) but lit progress banks and replacements keep coming. Light all three → a free tier-III Geis choice. |
| Waystations | src/waystation.ts |
Every staircase opens a choice — delve deeper, or rest in a safe sídhe mound first. The mound sits between floors (visiting never consumes a floor number, so bosses/smiths can't be dodged): no falling stone, no monsters, no fog. Every between-floor choice lives here as a person or fixture: the seanchaí (mound lore, plus "ask for your own tale" — the run recap mid-run), the hearth-fire (one full heal), the Fear Dearg's stall (shop), An Draoi's deity emissary (the pact ceremony), any pending floor event as a sheltering stranger, Aoife with a vengeance bounty when none is sworn, an ogham standing stone that opens the lore codex, the Well of Segais (gold for XP, priced by depth), the Sídhe coffer (bank gold across runs — your next character inherits half), and the Ogham-mark tattooist on some visits. The ambient drone retunes warmer inside. The stairs-choice dialog names whoever is currently waiting. |
| Fidchell | src/fidchell.ts, docs/fidchell.md |
Every 7th (non-boss) floor a Fomorian gambler bars the crossing with fidchell — "the wooden wisdom," built on the Irish 7×7 tafl game brandub. You're dealt a side at random: the King's escape (slip the High King to a corner dún) or the Raiders' hunt (surround and take the King). Turn-based, tap-driven, rook-slide moves with custodial (flanking) capture and a "weak" King. Win → gold, a boon, and a shortcut past the floor; lose → the gambler drops onto the floor as an elite to fight. Both sides are played by a depth-3 alpha-beta AI so whichever role you draw is a real contest (~60/40 King/raider self-play balance). |
| Tutorial | src/tutorial.ts |
A skippable guided tutorial on the first run ever (re-runnable via "New here?" on the start screen): a non-blocking callout card whose 7 steps advance when the player actually performs each action — move, steer, drop, clear, fight, descend — observed from the game's normal event stream. Luck-dependent steps auto-advance after a few landings so it can never stall. Input-mode-aware copy (keyboard vs touch). |
| Rescues | src/data/rescues.json |
Souls-style: captives occasionally ride down inside a shape under Fomorian elite guard (captive + two captors on one piece). Kill every guard, then talk to free them — they beam away and join the mound for the rest of the run: the Gobán Saor shapes your next piece to order, Bé Chuille the Enchantress names the boss ahead, Nuada Airgetlám pours the king's draught from Goibniu's feast (+ATK until the next descent), Airmed the Herb-Wise turns gathered herbs into permanent Max HP (see below), and Abcán the Harper plays the suantraí so the next floor's monsters arrive drowsy. A line clear that swallows the captive loses them — they may ride again on a later floor. |
| Airmed's herbs | src/game.ts, src/vendorOffers.ts |
Once Airmed the Herb-Wise is freed, a herb of Miach's grave becomes a vanishingly rare rider on falling stone (~1/365 per piece — one for each herb of the myth). It locks as a green tile; walk onto it to gather it (herbs travel with you between floors, though a line clear can crush an ungathered one). Deliver your gathered herbs to Airmed in the mound and she works each into a permanent +20% Max HP — a find-and-deliver quest in place of a gold shop. Tunables in balance.json's rescues block (herbSpawnChance, herbHpPct). |
| Combat legibility | renderer.ts, game.ts |
Tap any tile to inspect it (incl. your hit-chance vs a monster). Monsters that can strike next turn are telegraphed. |
| Gorgoth endgame | src/bossEncounters.ts, monsterAI.ts |
Fixed stats (1450 HP, ATK 54, D20 — see balance.json's gorgoth block). Descends one tile every ~2 turns, phasing through terrain. No retreat once summoned — stairs vanish from the board the moment he appears. |
| Difficulty | src/data/balance.json (difficulty), src/game.ts |
Chosen at run start (before class/curse): Slí an Scéalaí (Storyteller — slower stone, +30% HP, softer foes), Slí an Laoich (Hero — the default tuning), Slí na bhFomhórach (Fomorian — faster stone, harder foes, leaner gold, +15% XP). Pure balance.json multipliers applied at the spawn/gold/gravity choke points; the last pick is remembered and a non-default choice shows a sidebar badge. |
| Mid-run save/resume | src/saveGame.ts (serialize/restore), src/storage.ts |
A complete, versioned run snapshot autosaves on every turn (throttled) and on backgrounding/pagehide, so closing the tab or the OS reaping the PWA never loses the run. The start screen grows a Continue — Floor N card; death, victory, or starting a fresh run clears it. Content references (boons/brands/omens/boss mechanics) are stored by id and re-resolved on load, degrading gracefully across content updates. |
| New Game+ (heat) | src/data/balance.json (ngplus), src/game.ts |
Winning unlocks the heat ladder: each heat level stacks one more permanent geis (the Crow's +ATK, the Horde's +spawns, the Falling Sky's faster gravity, the Empty Purse's leaner gold, Champions' more elites) in exchange for +5%/level XP. Higher heat carries every lower geis; clearing heat N unlocks heat N+1. Chosen at run start when unlocked; shows a Heat N badge and rides the save file. |
| Accessibility & input | src/keybinds.ts, src/renderer.ts, pause menu |
Fully remappable keyboard (pause → Controls; one key, one action; Esc/P/M reserved; persisted). Colorblind Marks give cursed pieces a dashed blue outline instead of red. Shake & Flash toggles impact shake and the damage flash independently of the broader Reduced Motion mode. Gamepad and touch share the keyboard's action vocabulary. |
Controls. Two virtual D-pads (Block / Hero) plus keyboard and gamepad. Keyboard defaults: WASD/arrows move the hero, Space waits/heals, Q uses a ranged ability, E cycles spells, J/L/I/K/X drive the block, H holds, M mutes, Esc/P pause — all game keys remappable from the pause menu's Controls screen. Movement and attacks are strictly orthogonal for hero and monsters alike.
- TypeScript 7 (strict) — no framework; a hand-rolled
Gamemodel + canvas renderer + DOM UI. - Native Web Components for every modal (
src/components/) — plainHTMLElementsubclasses,customElements.define, no Shadow DOM. They render into the Light DOM so the existing globalstyle.cssstyles them with zero per-component CSS — verified none of its selectors are element-type-qualified (e.g. nodiv.modal-overlay), so an outer<div>becoming a custom-element tag changes nothing visually.UIManager(src/ui.ts) is a thin typed delegator to each one; only the always-visible HUD (stats, log, sidebar, tooltip) lives inui.tsdirectly. - Vite 8 — dev server & bundler, with vite-plugin-pwa for offline/installable builds (manifest, service worker, icon set, install screenshots).
- Vitest — unit tests (pure game logic; no DOM harness — see Testing).
- Rendering is one
<canvas>; audio is the Web Audio API (src/audio.ts). - Zero runtime dependencies — everything in
package.jsonis adevDependency.
Prerequisites: Node 18+ (the repo pins @types/node 20).
npm install # install deps
npm run dev # Vite dev server → http://localhost:5173
npm run build # production build → dist/ (validates data, then Vite + PWA)
npm run preview # serve the built dist/
npm test # run the Vitest suite once
npm run test:watch # watch mode
npm run lint # tsc --noEmit (type-check only)
npm run validate-data # validate src/data/*.json against schema/*.schema.json standaloneAlways run
npm run lint.npm run builduses esbuild, which does not type-check — several past bugs were dangling references that compiled fine but threw at runtime.tsc --noEmitis the real safety net.
src/
main.ts Boot, wiring, the tick loop, pause/settings, PWA install,
drawer/fullscreen handling
game.ts The Game class — the run's shared state (grid, falling
piece, player, monsters) and the core block/floor loop
(spawn, lock, line-clears, gravity, descent, combat
orchestration). Larger self-contained subsystems are split
into their own classes, each composed onto Game as a field
and reached through thin delegating methods (see below)
fidchell.ts The brandub/tafl board challenge (every 7th floor)
causewayDuel.ts The boss-floor Causeway Duel (turn-based bridge-building)
waystation.ts The sídhe-mound rest floor + every mound/floor NPC-tile
bump verb (An Dagda, pact emissary, rescues, well, coffer,
seanchaí, ghost, smiths, hearth-fire, stall, …)
bossEncounters.ts The Bres/Gorgoth finale + per-boss hooks (shards, surge)
vendorOffers.ts Altar / tattoo artist / Fear Dearg shop / rescue services
spawning.ts Monster spawning + vault/den dungeon rooms
runSetup.ts Start-of-run pickers: class, difficulty, heat, modifier
saveGame.ts Mid-run serialize / restore (the SavedRun contract)
pact.ts An Draoi's deity-pact ceremony
npcEncounters.ts Wandering-NPC / seanchaí / ghost dialogs
smithQuest.ts Lugh's Spear questline (the three smiths)
gameMath.ts Pure helpers: rotateMatrix, tick-rate, scoring
views/ Read-only projections of Game state (no logic):
inspect.ts (tap-to-inspect), charSheet.ts, uiState.ts
(the HUD UIState snapshot), nextGoal.ts (the run-end
"what to chase next" line)
entities.ts Player, Monster, StatMath (simulation only — no rendering)
codex.ts Codex completion maths + the unlock ladder it feeds
rng.ts Seedable RNG: mulberry32 (`makeRng`), `hashSeed`, and the
UTC `dailySeedString` behind the Daily Rift
stash.ts MemoryStash — the default StashPort for hosts with no
persistence (headless runs, tests)
particles.ts Render-layer floating text/burst particles (canvas)
renderer.ts Canvas drawing (tiles, block, hero, particles, telegraphs)
ui.ts HUD (stats/log/sidebar/tooltip) + a thin delegator to
every modal component (see components/ below)
components/ One native custom element per modal (Light DOM, no
framework) — base-modal.ts (shared show/hide/render),
crash-modal, pause-modal, boss-warning-modal,
modifier-modal, class-modal, difficulty-modal, heat-modal,
controls-modal, floor-event-modal, offer-modal (shared
altar/tattoo picker), shop-modal, char-sheet-modal,
codex-modal, game-over-modal, start-modal, and an index.ts
barrel of side-effect imports that registers them all
input.ts Keyboard, on-screen buttons, gamepad
config.ts CONFIG (10×25 grid, 17px tiles) + shape re-exports
types.ts Shared types: Cell/Tile enums, EffectSpec, *Def interfaces,
GameCallbacks, UIState, …
dataLoader.ts Loads /data JSON → runtime tables (MONSTERS, BOSSES,
BOONS, BRANDS, CLASSES, BIOMES, PATRONS, …) + the effect
resolver
content.ts Re-export barrel for the data tables
balance.ts Typed loader for the "tuning knob" JSON (combat.json,
hazards.json, monster-ai.json, balance.json) — flat
numbers consumed by systems/*.ts, game.ts, entities.ts
colors.ts Tier color lookups (altar/reward glow + fill colors)
from colors.json
sprites.ts SpriteService (sprite-atlas → <canvas>/HTML icon
rendering) + HtmlUtils (shared HTML-escaping)
storage.ts localStorage (high XP, run history, mute, reduced-motion,
ghosts, lore codex, Daily Rift streak) + BrowserStash,
the browser StashPort implementation
audio.ts Web Audio SFX
errorReporting.ts Fatal-error normalization (DOM-free) wired into the
crash-recovery modal
haptics.ts Vibration API wrapper, reduced-motion gated
systems/
combat.ts Dice engine, player/monster attacks, kill & victory
abilities.ts Ranged-ability dispatch (class kits, patron spells, Spear)
monsterAI.ts Per-behaviour monster turns (melee/ranged/berserker/…, gorgoth)
statusEffects.ts Poison/stun/regen ticks
hazards.ts Spike/smoke/teleport tiles
data/ All game content as JSON (see below)
__tests__/ Vitest specs
schema/ JSON Schema (2020-12) for every file in src/data/,
validated by scripts/validate-data.mjs
scripts/
validate-data.mjs Validates every src/data/*.json against its schema;
runs standalone (`npm run validate-data`) and as the
"prebuild" step before every `npm run build`
smoke-test.mjs Playwright smoke test: boots the built app, starts a
run, drives real input, asserts no uncaught errors
headless.mjs Boots the simulation in plain Node with the browser
globals booby-trapped — the runtime half of the
sim/host boundary guard (`npm run headless`)
public/
icons/ PWA icon set (48–512px, maskable variants, favicon,
apple-touch-icon)
screenshots/ Desktop + mobile install-prompt screenshots (manifest
`screenshots`)
sprites/ The 32rogues tile/monster/item/rogues spritesheets
The game splits cleanly in two, and the split is enforced, not just intended — so the renderer can be swapped or the mechanics reused in another shell (a server, a native app, a different render stack).
| Layer | Files | Rule |
|---|---|---|
| Simulation | game.ts, entities.ts, systems/**, views/**, dataLoader.ts, content.ts, balance.ts, stash.ts, the composed subsystems |
No DOM, no canvas, no localStorage, no ambient globals. Runs in plain Node. |
| Host | main.ts, renderer.ts, ui.ts, input.ts, audio.ts, sprites.ts, particles.ts, tutorial.ts, haptics.ts, keybinds.ts, components/**, storage.ts |
Owns every browser API. |
The simulation talks to the host through three narrow seams, and nothing else:
GameCallbacks— events out (log lines, particles, toasts, modal requests). Plain function props; no DOM types.UIState— the per-turn view model pushed to the HUD. Plain data.- Injected ports —
StashPort(cross-run gold), an optionalnow()clock, and an optionalseedfor reproducible runs, all passed vianew Game(cb, { stash, now, seed }). Defaults (MemoryStash, a guardedperformance.now(),Math.random) mean the sim runs with zero host wiring.
Sprite keys ('sprite_salve') are opaque strings the host resolves — the sim never emits markup. Where an inspect line needs an inline icon it emits a [[icon:<key>]] token the host expands.
Two guards keep it that way:
no-restricted-importsin.oxlintrc.json— default-deny across all ofsrc, switched off only for the host-layer files. Any new simulation file is protected automatically, and a strayimportfrom the host layer failsnpm run lint.npm run headless(scripts/headless.mjs) — boots a realGamein Node withdocument/window/localStorage/navigatorbooby-trapped to throw, plays 1500 turns of weighted random input, then round-tripsserialize()→applySave(). Part ofnpm run verify, so CI catches a browser dependency the moment one appears.
Everything under src/data/ is imported at build time (resolveJsonModule) and turned into runtime objects by dataLoader.ts. Adding or tuning most content is a JSON edit, no code. Every file has a matching schema in schema/ — see Data validation.
Keyed by monster id. dataLoader scales HP/ATK by floor and biome at spawn.
| field | type | notes |
|---|---|---|
id, displayName |
string | |
visualAsset |
string | sprite-map key rendered by SpriteService; keys with no sprite-map.json entry render nothing |
cellTypeId |
string | must exist in CELL_MAP in dataLoader.ts |
combatLevel |
number? | die size for combat (1→D4 … 6→D20); default 2 |
baseHp, baseAtk |
number | floor-1 stats |
hpScaleCoefficient, atkScaleCoefficient |
number | added per floor |
xpValue |
number | XP on kill |
spawnMsg |
string | log line |
statusInflict |
object? | { type, chance, duration, power } (e.g. poison on hit) |
behaviorType |
string? | melee (default) / ranged / healer / berserker / swift |
attackRange, moveSpeed |
number? | ranged reach / movement |
The generic boss pool (one appears every 5th floor, gated on stack fill — see Core systems). Fields: id, displayName, visualAsset, hpMult, atkMult, xpValue, flavorText. Base HP/ATK are computed from floor and multiplied.
Note: biome-specific bosses (Cailleach's Stoneward, Balor's Herald) and Bres the Beautiful are defined in code because they carry behaviour callbacks (onHalfHp, onDeath) that data can't express.
classes.json — the three starting classes. Fields: id, emoji, name, tagline, statChips (display-only), tPieceCdReduction, effects: EffectSpec[], and a declarative ability (name, abilityType, range, damageMult, cooldownMax, params) dispatched by abilityType in game.ts's ranged-attack handler.
biomes.json — ordered deepest-first, matched by Biome.forFloor(floor) (first entry whose minFloor the floor meets). Fields: id, name, minFloor, tileRgb, moteColor, monsterHpMult, gravityPctBonus, desc, terrainType (swamp | sacred | ice) — the special-tile effect that biome's terrain pieces lay down on lock — and an optional terrainShapes array of shape keys deciding which shapes lay it (defaults to ["S","L","J"]; the deeper biomes add O/I for denser signature terrain). Crossing into a new one (including the starting floor-1 biome) logs its flavor line and toasts "Entering <name>...".
patrons.json — the three deities An Draoi can pact with. Fields: id, char, name, deity, tagline, tollDesc, effects: EffectSpec[] (the permanent toll), and spells[] — each a full ranged-ability spec (same shape as a class's ability) plus unlockLevel, unlocked progressively as the pact deepens.
These three (and classes.json's effects/patrons.json's toll effects) describe their effects declaratively using a shared EffectSpec (defined in types.ts). A small resolver in dataLoader.ts applies them:
interface EffectSpec {
target?: 'player' | 'game'; // default 'player'; only curses use 'game'
stat: string; // property name on Player or Game
op?: 'add' | 'mul' | 'set'; // default 'add'
value: number | boolean;
min?: number; // clamp result ≥ min
max?: number; // clamp result ≤ max
floor?: boolean; // floor the result
}Examples: +2 ATK → { "stat": "atk", "value": 2 }; a dodge cap → { "stat": "dodgeChance", "value": 0.1, "max": 0.75 }; halve vision → { "stat": "visionRadius", "op": "mul", "value": 0.5, "floor": true, "min": 1 }; a curse touching the game → { "target": "game", "stat": "xpMultiplier", "op": "set", "value": 1.5 }.
boons.json — stackable altar/level-up passives.
Fields: id, char (sprite-map key), name, tier (1–3), role (offense/defense/utility), desc, effects: EffectSpec[], optional special.
brands.json — permanent tattoos with set bonuses.
Fields: id, char, name, setSize (2–3), role, desc, setDesc, onEquip: EffectSpec[] (per brand), onSet: EffectSpec[] (when a set completes).
modifiers.json — run-start curses.
Fields: id, emoji, name, desc, effects: EffectSpec[] (may target player or game), optional special.
Escape hatch — special. The few effects that can't be pure data reference a named handler in dataLoader.ts:
void_loop(boon) — crit-every-N cadence that depends on stack count.full_heal(curse) — setshp = maxHpafter a Max-HP change (Glass Cannon, Berserker).void_prismis intentionally a no-op in JSON (recomputed inPlayer.addBoon). To add a new special: put a one-liner inBOON_SPECIALS/MODIFIER_SPECIALSand reference its key from JSON.
npcs.json — wandering NPC encounters. kind: 'flavor' NPCs cycle through lines[] with a returnLine on repeat meetings; other kinds intro via introLine and may grant something. Discovered NPCs are tracked in the lore codex by id. waystationOnly: true marks residents of the sídhe-mound waystation (the seanchaí), excluded from random wandering rolls.
floor-events.json — the narrative floor-event modal, offered every few descents (skip boss floors). Each event has id, emoji, title, flavor, and options[] (label, desc, handler — a named function in game.ts's handler table — plus optional params).
smiths.json — the three legendary smiths of Lugh's Spear questline, in encounter order (Luchta → Credne → Goibniu). Fields: id, char (sprite-map key), name, partKey (shaft | bolts | head), partName, tagline, flavor. Encountered via SmithQuest.triggerEncounter (src/smithQuest.ts), reusing the floor-event modal rather than a dedicated component; Goibniu's third meeting is a special-style reforge handled directly in that module rather than in JSON, since it swaps player.rangedAbility at runtime.
omens.json — per-floor modifiers rolled on floor entry (chance in balance.json's omens.rollChance). Fields: id, icon, name, toastText, logText, weight (relative roll weight), params (numeric tunables read by that omen's effect hook in game.ts, e.g. goldMult, visionPenalty, gravityPct), and optional special — the escape hatch for scripted ritual omens ("bealtaine" drives the brazier ritual). Pure stat omens need no code: their hooks read whatever params are present, so a new one is usually just a JSON entry.
shapes.json(each shape carries aweight— classics ~10, rare custom shapes ~1,0removes it from the natural rotation) — the 9 piece shapes in the random draw pool: the 7 standard shapes plus two extra non-standard shapes (Q,H), each{ matrix, color }keyed by letter.sprite-map.json— sprite-atlas coordinates (sheet,sx,sy,sw,sh) into thepublic/sprites/*.png32rogues sheets, read bySpriteService. A key with no entry renders nothing (SpriteService.iconHTMLreturns'') — there is no emoji fallback.balance.json,combat.json,hazards.json,monster-ai.json,colors.json— flat tuning numbers (dice/combat math, hazard timers, monster-AI thresholds, altar-tier colors), loaded bybalance.ts/colors.ts. Edit these to retune the game; no code changes needed.
Every file in src/data/ has a matching JSON Schema (2020-12) in schema/, checked by scripts/validate-data.mjs — run standalone via npm run validate-data, or automatically as the prebuild step before every npm run build. Shared fragments (like EffectSpec) live in schema/_effect-spec.schema.json and are referenced via $ref. Add a new data file → add its schema alongside it and list it in FILES in validate-data.mjs.
- New boon / brand / curse: edit the relevant JSON with
EffectSpecentries. No code unless you need aspecialhandler. - New monster: add an entry to
monsters.json, make sure itscellTypeIdis inCELL_MAP, give it avisualAssetwith a matching entry insprite-map.json(no entry renders nothing), and (if it should spawn from blocks) wire its cell into the spawn table ingame.ts. - New biome: add an entry to
biomes.jsonwith aminFloorand aterrainType(and optionallyterrainShapesto tune how much of that terrain the biome lays); biome-specific bosses still need a code entry (seebosses.jsonnote above). - Classes and patron spells are fully data-driven (
classes.json,patrons.json) except for theabilityTypedispatch switch itself ingame.ts— adding a new kind of ranged ability (not just a new class using an existing kind) needs a new case there.
After any content change: npm run lint && npm test && npm run build (the build's prebuild step re-runs data validation too).
One command runs every quality gate — the same set CI enforces:
npm run verify # typecheck · lint · schema validation · tests w/ coverage
# thresholds · headless sim · build · Playwright smokeIndividually: npm run typecheck (tsc), npm run lint (oxlint, warnings are errors), npm run validate-data (AJV against schema/*.json), npm run test:coverage (vitest + v8 coverage with ratchet thresholds in vitest.config.ts — raise them as coverage grows, CI fails if a change drops below), npm run headless (plays the simulation in Node with no DOM), npm run smoke (Playwright against the built app).
Versioning: every Pages deploy is stamped 1.0.<run number> (shown on the start screen and pause menu, dev locally), so bug reports can name the exact build.
Three layers of automated checking, each aimed at a different failure mode:
| Gate | What it covers |
|---|---|
Unit tests — src/__tests__/, Vitest (npm test) |
The simulation: combat math, spawning, line clears, boons/brands/curses (including the JSON effect resolver), save/restore, seeded runs, the Gorgoth endgame, monster AI. Runs in environment: 'node' with no jsdom — which is possible precisely because the sim has no DOM dependency. |
Headless sim — npm run headless |
Boots a real Game in plain Node with document/window/localStorage/navigator booby-trapped to throw, plays 1500 turns, then round-trips serialize() → applySave(). Catches a browser dependency creeping into the simulation. |
Smoke test — npm run smoke |
Playwright against the built app: starts a run, drives real keyboard/touch input, asserts the canvas renders and nothing throws. |
The UI/component/renderer layers have no unit harness by design — they're covered by the smoke test and by driving the real thing (npm run dev) rather than poking internals. When changing them, verify in a browser.
Coverage thresholds (vitest.config.ts) are scoped to the simulation layer only, and ratchet upward — CI fails if a change drops below.
npm run builddoes not type-check (the bundler strips types without checking them). Runnpm run typecheck(tsc --noEmit) before trusting a build —npm run lintis oxlint, which is a separate gate.- Movement is orthogonal only for everyone — a diagonally-adjacent enemy must step to a cardinal tile before it can attack.
- The Gorgoth fight has no line clears (blocks stop), so line-clear-oriented builds don't contribute during it — combat/dodge/crit/sustain/ranged builds carry the finale.
- Modals are Light DOM custom elements, not Shadow DOM. Every existing
id/classon a modal's markup is preserved verbatim when it moved from a static<div>inindex.htmlinto a component'stemplate()string —style.cssneeds no per-component changes. Keep it that way: don't introduce element-type-qualified CSS selectors (div.modal-overlay), or a future component could stop matching them. - Normal boss floors gate on overall board fill (≥50% of all field cells built), not the tallest single column — a narrow one-column spike from careless hard-dropping doesn't trigger it early.