Saboteur ▪ Reversed Tetris — Game Design Spec

Type: Product Requirements Document Status: Draft for review Series: Do villain things (sibling of minelayer, ghost-squad) Deliverable target: single offline HTML file

1. Background and Goals

Concept. Saboteur is a reversed Tetris: the player is the dealer, choosing which tetromino falls next, while an AI opponent — the Stacker — plays NES-style Tetris in real time. The player wins by forcing the Stacker to top out, as fast as possible. Where the original asks "survive as long as you can," the reversal asks "kill as fast as you can."

Design lineage. Candidate selection for the series applied four filters, which this design passes: (1) the player's core verb is one causal step from the outcome (a dealt piece immediately becomes the Stacker's problem); (2) outcomes are attributable to player decisions (visible fumbles, pressure-scaled mistakes); (3) no dominant strategy exists (free dealing is provably solved — alternating S/Z forces a loss against any opponent — so dealing is constrained by cooldowns); (4) the opponent is a local heuristic, no network, no remote AI.

Goals (observable outcomes).

2. Product Contract

ElementDefinition
Primary userA casual player in a browser; solo only
TriggerOpening the HTML file; boots to TITLE with no external resources
Core jobDeal tetrominoes that force the AI Stacker to top out
Completion (level cleared)The Stacker tops out; level N+1 unlocks
FailureNone by design (mirror of the original's absent win state). Stalling simply means remaining on the current level; quitting is always available. See FR-03.
Trust boundariesFully local: no network calls, no external assets, no accounts; progression and best scores only in localStorage

3. Scope

P0 — required for the complete game P0

P1 — valuable, not blocking P1

Explicitly excluded OUT

Rejected during design: a "pressure budget" meter (drains per beat, bot line clears drain extra, empty = player loses). Rejected by the product owner as foreign to Tetris culture. Termination and difficulty instead come from the original's own vocabulary: the level system (SR-3). A passive player never loses but never progresses — disengagement is its own failure.

4. Game Model

4.1 Objects

ObjectCanonical definition
WellStandard 10×20 Tetris playfield, NES rules: no hold, no hard drop, NES rotation states with no wall kicks (implemented; bot rotates CW only, like mashing A).
Tetromino setThe 7 classic pieces — I, O, T, S, Z, J, L — unlimited supply; availability governed only by cooldowns (SR-2).
Dealer (player)Picks the next piece during the current piece's fall. One verb: deal.
Stacker (AI)The opponent. Plays by NES rules with visible, speed-limited manipulation (SR-4). Sees the next-piece preview exactly as a human would.
BeatThe match's atomic time unit: the lifespan of one falling piece, from spawn to lock. All cooldowns and the deal window are measured in beats (SR-1).
CooldownPer-piece lockout after being dealt, counted in beats. S and Z carry the longest cooldowns (SR-2).
LevelA fixed-speed match configuration, 1–29, unlocked sequentially. Higher level = faster gravity and faster Stacker hands, coupled so the bot stays equally competent (SR-3).
FumbleA visibly suboptimal placement locked by the Stacker, telegraphed by a wobble cue (SR-4).
Stacker's ChoiceThe timeout punishment: an un-dealt beat is filled by the Stacker's own heuristic, with level-scaled quality — mostly random at L1, converging to the heuristic's favorite at L20+ (FR-05, SR-3).

4.2 Match state machine (canonical)

TITLE ──▶ LEVEL SELECT ──(pick unlocked level)──▶ READY (~1.5 s) ──▶ PLAY ▲ ▲ │ │ │ ┌── Stacker tops out ──▶ CLEARED ──▶ SUMMARY └──(Esc menu)────┴────────┤ (unlock N+1, save best) │──▶ LEVEL SELECT │ └──▶ next level READY PLAY ◀──(Esc / blur)──▶ PAUSED: all beats, falls, and cooldowns frozen PLAY ──(Q / quit)──▶ LEVEL SELECT (match abandoned; no score recorded)

There is no LOSS state by design (§2). The only terminal states of PLAY are CLEARED and player-initiated quit.

5. Functional Requirements

FR-01NES-faithful presentation

FR-02Level ladder and progression

FR-03Match loop (deal-ahead pipeline)

AspectRule
Ready phase~1.5 s countdown; well empty; first falling piece is chosen uniformly at random (neutral start, implemented). No input accepted.
Deal windowWhile piece k falls, the player picks piece k+1 (SR-1). The window closes the instant piece k locks. The picked piece appears in NEXT; the Stacker plans with this preview, as a human would.
TimeoutNo pick when piece k locks → Stacker's Choice (FR-05).
Clear conditionStacker tops out (spawn blocked or lock-out per NES rules) → CLEARED: collapse animation, SUMMARY (FR-08), unlock N+1.
No lose stateA match continues until the Stacker tops out or the player quits (Q → LEVEL SELECT, nothing recorded). Stalemate is possible at low levels against passive dealing; it yields no score and no unlock — inaction is self-penalizing.
ReplaySUMMARY offers: next level (Enter), retry this level (R), LEVEL SELECT (Esc).
ExceptionsTab hidden or window blurred during PLAY → auto-PAUSED (SR-5).

FR-04Dealing controls and cooldowns

Purpose. One verb, one resource: pick pieces, manage cooldowns. Cooldowns exist because free dealing is mathematically solved — alternating S/Z forces a top-out against any possible opponent — so S and Z carry the longest cooldowns.

Interactive demo — FR-04 deal bar / FR-05 timeout

The bar auto-advances one beat every few seconds. Click a piece (or press 1–7) to deal it; watch S and Z carry long cooldowns. Let a beat expire and the Stacker serves himself. Demo values are shortened; game values are SR-2/SR-3 tuning.

NEXT: —
Deal before the bar empties…
Demo: cooldown S/Z = 4 beats, others = 2. In game, timeout deals the Stacker's heuristic favorite, never random.

FR-05Timeout punishment — Stacker's Choice

FR-06The Stacker bot

Purpose. A convincing, fallible, fully local opponent. This component carries the design's concentrated risk (§8, R-1): the match is only fun if its mistakes read as caused.

FR-07Per-level configuration

FR-08Scoring and attribution stats

FR-09Audio

FR-10Result sharing (community layer)

6. Shared Rules

SR-1 · Beat and timing model (canonical)

A beat = the lifespan of one piece, spawn → lock. The deal window for piece k+1 opens when piece k spawns and closes when it locks. Cooldowns tick per beat, not per second — the whole economy compresses uniformly as levels speed up. Pause freezes beats entirely.

SR-2 · Cooldown rule (canonical)

Each piece type has a cooldown length in beats: S = 6, Z = 6, others = 3 (implemented). Dealing starts that piece's cooldown when the deal finalizes (at the lock that closes the window); every locked piece ticks all cooldowns down by 1. Values are tuning targets, chosen so S/Z denial is a meaningful but not dominant strategy.

Evidence base: cooldowns are anti-degeneracy guardrails, not a piece-value encoding. Brzustowski (1992, Can You Win at Tetris?, UBC) proved single-piece winning strategies exist for all seven tetrominoes — no repeated single piece is ever lethal, so spam alone cannot justify differentiating cooldowns. The only statically-proven lethal pattern is S/Z alternation (Burgiel 1997, How to Lose at Tetris, Mathematical Gazette 81: a hole is forced within 240 pieces against any play). Beyond S/Z, adversarial dealing is rich but unranked (Demaine, Hohenberger, Liben-Nowell 2003, Tetris is Hard, Even to Approximate), and empirical piece-set experiments (Zhang, U. Twente: L+J-only games outscore the full 7-piece game while S/Z-only games collapse immediately) support a binary {S,Z}-vs-rest split with no gradient inside the five. Adversarial-dealer implementations (Bastet, HATETRIS) likewise show piece badness beyond S/Z is board-dependent, not intrinsic. Uniform short cooldowns on the other five are therefore deliberate: reading which piece is worst in context is the player's skill, not a static tax. Sole candidate for a medium tier is O (nasty on ragged boards, harmless on flat ones) — resolved in tuning [TBD].

SR-3 · Level configuration (canonical)

BandGravityStacker hand speedLookahead weightChoice qualityFumble base rateFlavor
1–548 → 28 f/row3 actions/s (clamped floor — hands have slack vs gravity here)0.0 (no lookahead)0.15 → 0.350.35 → 0.28Very forgiving: bot is myopic, timeout gives random pieces, dies in ~40 optimal / ~137 auto
6–1023 → 6 f/row3 → 8 actions/s (clamp releases)0.15 → 0.75 (ramping)0.40 → 0.640.26 → 0.19Bot starts planning ahead; timeout improving; auto-mode reaches 500+
11–205 → 2 f/row10 → 24 actions/s, compensated0.75 (full)0.70 → 0.860.17 → 0.07Deal window is the pressure; only optimal/80% play wins from L16
21–282 f/row (NES table plateau — kept authentic)24 actions/s, near physical limit0.75 → 0.90 (ramping)0.86 → 0.950.063 → 0.015The bot "tightens up" level by level — every rung has real progression; expert play only
291 f/row — the NES original's terminal kill-screen gravity24 actions/s, hard cap → forced misdrops0.75 (reverts — physics must win)0.950 (physical only)Forced misdrops are physical, not injected; the ⅓ s windows mean a human effectively plays auto-mode, so the kill screen stays lethal (~51 auto); beating 29 is the series trophy

Implemented: gravity is the NES frames-per-row table with level L mapped to NES index L−1 (L29 = 1 f/row); hand speed = clamp(round(50/fpr), 3, 24) actions/s. Once aligned the Stacker soft-drops at ~3× gravity (floor(fpr/3) f/row), keeping L1's deal window ≈ 5 s/beat. All per-level tuning values are smooth breakpoint curves (linear interpolation — no per-band restarts, every level's config is distinct, verified 29/29). Four difficulty levers beyond gravity speed: (1) level-scaled lookahead — 0.0 at L1–5 (bot is purely reactive), 0.75 from L10, ramping to 0.90 across L21–28, reverting to 0.75 at L29 so the kill screen stays physics-lethal; (2) level-scaled choice quality — the Stacker's Choice is mostly random at L1 (0.15), rising to 0.95; deliberately never 1.0 (see below); (3) fumble base — 0.35 at L1 tapering smoothly to 0.015 at L28, 0 at L29; (4) clear-miss + danger feedback (see SR-4), implicitly curved through the fumble base. Why choice quality caps at 0.95: at L29 the ⅓ s deal windows mean a human cannot deal at all — the level is auto-mode for a human player, so the 5% random timeout pieces are the margin by which kill-screen physics still kills the bot; benchmark-verified that 1.0 lets it survive 500+ pieces, making L29 unbeatable. Measured lower bound (5-seed avg): auto-mode survives ~137 at L1, 446 at L5, 500+ at L8–L28, ~51 at L29. Measured upper bound: optimal dealer kills L1 in ~44, L10 in ~60, L20 in ~104, L22 in ~126, L28 in ~135, L29 in ~22; 50% optimal kills L1 in ~46, L10 in ~127, wall at L16+; random kills L1 in ~91, wall at L10+. Strategy (withhold I, deal S/Z/J) is rewarded at every level.

SR-4 · Fumble and clear-miss model (canonical)

The bot has two independent fallibility mechanisms, both gated by board danger (danger = stackDanger(board), ranging 0 on an empty board to 1 at the top):

1. Clear-miss (attacks the bot's line-clear healing): when danger ≥ 0.3 and the best placement clears lines, the bot has a clearMissP = fumbleBase × 0.5 × danger (capped at 0.20) chance to instead pick the best non-clearing placement (within 20 points of best). This specifically prevents the bot from healing via line clears when under pressure, creating a death spiral: bad pieces → stack grows → clear-miss → no heal → stack grows more → death. On an empty board (danger ≈ 0), clear-miss never triggers, so auto-mode (good pieces, low stack) is unaffected.

2. Fumble (suboptimal placement): p = fumbleBase × (0.7 + 0.8 × danger), capped at 0.40. A fumble locks a placement chosen uniformly (seeded) among those scoring 10–50 points below the heuristic's best — clearly suboptimal but never braindead. Always preceded by a 16-frame wobble cue.

Comeback cap: the Stacker never fumbles or clear-misses away a saving line clear (clears > 0 and danger ≥ 0.7) more than once per match — comebacks are earned, not gifted.

Why two mechanisms: the flat fumble alone could not overcome the bot's line-clear healing (199 lines cleared in 500 auto-mode pieces). The clear-miss specifically targets the healing, creating the death-spiral feedback loop that makes the player's bad deals stick. The fumble adds visible, telegraphed mistakes on top. Together they create a bot that is competent with good pieces (auto-mode survives) but vulnerable to bad dealing (player kills in ~35 pieces at L1).

SR-5 · Pause and focus

Esc toggles PAUSED; beats, gravity, cooldowns, and the Stacker freeze. Window blur or tab hidden forces PAUSED. PAUSED dims the well and shows controls; no state is lost.

SR-6 · Persistence

Stored in localStorage: unlocked level count, per-level best kill score, mute preference, last played level. Storage unavailable → session-only, no error surfaced.

7. Exceptions and Boundaries

CaseBehavior
Small viewport / portrait phoneProportional scaling; below usable minimum, show "enlarge your window" notice (touch is P1; phones are not P0 targets)
Autoplay-blocked audioEngine initializes on first gesture; game fully playable silent
localStorage blockedSession-only progression and bests; no error surfaced (SR-6)
Player never deals (idle)Stacker's Choice every beat; at low levels the bot gets mostly random pieces and eventually dies (~137 pieces at L1); at high levels the bot gets near-optimal pieces and survives indefinitely. Either way: no score, no unlock, no mercy cap — the match simply continues until the player engages or quits (FR-03)
Pick in the same instant as lockWindow-close wins ties: input timestamped after lock applies to the next window
Re-pick within windowReplaces pending deal; replaced piece's cooldown refunded (implemented — cooldowns apply at lock, so re-pick is free)

8. Open Questions and Risks

9. Testing and Difficulty Verification

The game ships with three test layers: headless unit tests (33 scenarios), a browser-level E2E sweep (11 scenarios), and a difficulty-benchmarking methodology used during tuning. All tests load the real index.html in-memory — the game file on disk is never modified.

9.1 Test harness infra

9.2 Headless unit tests (33 scenarios) P0

FileFocusScenarios
01-dealing.test.jsDealing economyDeal sets next piece · S/Z 6-beat cooldown ticking 1/beat · re-pick within window replaces & refunds · cooling-piece input ignored · timeout deals Stacker's heuristic favorite deterministically · quit mid-match records nothing · score counts pieces dealt (lower is better)
02-stacker.test.jsBot behaviorBot always locks within bounded time (never stalls) · line clear heals board & counts · fumble telegraphed by wobble before lock · top-out ends match, unlocks next, records best · spawn-blocked top-out also ends match · pause freezes beats/gravity/cooldowns · level config: L1 slow, L29 kill-screen, hands capped · NES rotation: no wall kick — blocked rotation stays put
03-flow.test.jsState machine & persistenceREADY phase ignores deal input · window blur during play auto-pauses (SR-5) · quit from pause records nothing · SUMMARY Enter advances to next level · SUMMARY R retries same level · level select refuses locked levels · mute toggles and persists to storage (SR-6) · best never worsens: slower kill does not overwrite · clearing L29 does not unlock phantom L30
04-edge.test.jsEdge casesPick after window close (during clear anim) applies to next window (FR-04 tie rule) · pick during fumble wobble still counts (window closes at lock, not at rest) · Stacker's Choice piece goes on cooldown like any dealt piece · same seed + same inputs → identical boards (match determinism) · kill screen L29: forced misdrop is physical, match continues · no legal placement for dealt piece: bot locks anyway, match ends cleanly (FR-06 exception) · Stacker hands are speed-limited, not teleporting (frame-exact check at L1) · double line clear counts both rows · passive player never loses but never progresses (FR-03 stalemate contract)

9.3 Browser-level E2E sweep (11 scenarios) P0

End-to-end coverage of the full player journey in a real headless Chromium browser over the DevTools protocol. Each scenario drives real keyboard events and asserts on real rendered pixels, real localStorage, and real rAF timing. Screenshots saved to tests/saboteur/e2e-shots/ on each run.

#ScenarioAsserts
1Page boots to TITLEState is title, title overlay visible, zero console errors
2Any key → LEVEL SELECT29 level cells, 1 unlocked / 28 locked
3Enter starts level 1: READY → PLAYState transitions to play, well canvas has drawn pixels, HUD shows level 1
4Dealing O via key 2pending set to O, NEXT canvas contains yellow O blocks (verified via getImageData)
5Beats advanceDealt count ≥ 2, dealt O spawned, O on cooldown, HUD score updates
6Esc pauses and freezes the beat clock; Esc resumesPause overlay visible, dealt count and gravity frozen for 700 ms, then resumed
7Q quits to level select; nothing recordedNo best score for level 1 in localStorage
8Top-out → SUMMARY with attribution stats, unlock + best persistedSummary overlay visible, attribution stats filled, level 2 unlocked, best persisted
9Reload keeps progressionAfter page reload, 2 levels unlocked (27 locked)
10Mute persists across reload (SR-6)Mute flag survives page reload in localStorage
11No console errors accumulated across the entire sweepZero Runtime.exceptionThrown and zero console.error calls throughout

9.4 Difficulty-verification methodology

Two complementary benchmarks establish the lower bound and upper bound of each level's difficulty. Both were used during tuning to validate the fumble model (SR-4) and level curve (SR-3). The benchmarks are not shipped as test files — they are throwaway scripts run during tuning — but the methodology is canonical and should be re-run whenever difficulty parameters change.

Lower bound: auto-mode survival (can the Stacker hold its own?)

Motivation (analogous to Ghost Squad's auto-mode test): in auto mode the player never deals, so every piece is the Stacker's Choice. The choice quality is level-scaled (SR-3): at L1 only 15% of timeout pieces are the heuristic's favorite, so the bot gets mostly random pieces — it is competent but not immortal. If the bot dies too quickly even with good pieces, it is too weak to be fun. If it survives forever at every level, the timeout is too rewarding.

Method: boot a match, never call pick(), step every frame until state === 'summary' or a piece cap (500) is reached. Record survival length, lines cleared, and fumble count.

Acceptance criterion: the Stacker survives ≥ 500 pieces at L8+ (high choice quality — bot gets near-optimal pieces). At L1–5, auto-mode survival is expected to be shorter (137–446) because the bot gets mostly random pieces — this is by design, not a bug. If auto-mode kills the bot at L8+, the fumble model is too aggressive. If auto-mode survives 500+ at L1, the choice quality is too high. This test caught: (1) v1 flat model with 0.28 base → 41% auto-mode fumble rate, bot died in 68 pieces; (2) v2 piece-quality-aware model → auto-mode immortal at every level, optimal couldn't kill; (3) the Stacker's Choice always being the best piece → auto-mode immortal regardless of fumble model. The v3 model (level-scaled choice quality + flat fumble + clear-miss + level-scaled lookahead) is the first to satisfy both bounds.

Upper bound: adversarial-dealer pieces-to-kill (can the player win, and does skill matter?)

Motivation: if even optimal play can't kill the bot, the level is too hard. If random dealing wins as easily as optimal, skill doesn't matter. The gap between optimal and random is the "skill ladder" — the space where the player's choices matter.

Method: boot a match, each beat deal the piece the heuristic scores worst (for optimal) or a mix of worst/random (for skill-mixed), step one beat, repeat until top-out or cap. Run 3–5 seeds per level.

Acceptance criterion: optimal dealing kills the bot at every level (including L29, which is physics-bound at ~20 pieces). At L1, optimal kills in ~35, 50%-optimal in ~41, even random in ~68 — the level is beatable by casual players. At L10+, only skilled (70%+) dealing wins. If optimal can't kill, either the fumble rate is too low, the clear-miss is too weak, or the lookahead is too strong for that level.

Measured values (current tuning, 3-seed avg): auto-mode survives 500+ at every level except L29 (~20). Optimal dealer: L1 ≈ 35, L5 ≈ 40, L10 ≈ 35, L20 ≈ 81, L29 ≈ 20. 50%-optimal: L1 ≈ 41, L5 ≈ 64, L10 ≈ 126, L20 ≈ 200+ (can't beat). Random: L1 ≈ 68, L5 ≈ 190, L10+ can't beat. The skill gap at L1 is 35 (optimal) vs 68 (random) — a 2× speed advantage for skilled play. Re-run both benchmarks whenever fumbleBase, lookahead, the clear-miss formula, or the fumble-severity band (10–50 below best) changes.

9.5 Tuning changes from initial implementation

ParameterInitialFinalReason
Stacker's Choice quality (new)1.0 (always best piece, all levels)0.15 at L1 → 0.95 at L20 (level-scaled)Always-best-piece made auto-mode immortal at every level (the bot always got exactly what it wanted). Player insight: the Stacker's Choice is "too strong a boost." Level-scaling means: at L1 the timeout gives a random piece 85% of the time (forgiving — missing a beat isn't catastrophic, and auto-mode is beatable at ~137 pieces); at L20 the timeout gives the best piece 98% of the time (punishing — the player MUST deal actively). This is the single most impactful difficulty-curve lever: it directly controls the lower bound (auto-mode survival) without touching the bot's intelligence.
Soft-drop interval (aligned)floor(fpr/6) f/rowfloor(fpr/3) f/rowL1 deal window was 3.25 s — too short to think. Now 5.3 s. High levels unaffected (L29 fpr=1 → 1 f/row either way).
Lookahead weight0.75 (all levels)0.0 at L1–5, ramping 0.15→0.75 at L6–10, 0.75 at L11+The Dellacherie heuristic with full lookahead cleared 199 lines in 500 auto-mode pieces — no fumble rate could overcome this healing without also killing auto-mode. Removing lookahead at low levels makes the bot naturally myopic (clears fewer lines, doesn't set up future clears), creating a difficulty curve beyond just gravity speed. This is the primary difficulty lever.
Fumble probability modelbase × (0.5 + 1.5 × danger) (v1, flat)base × (0.7 + 0.8 × danger) capped at 0.40 (v3, flat with danger feedback)v1 was too weak (optimal needed 100+ pieces to kill). v2 (piece-quality-aware awkwardness) made auto-mode immortal but optimal still needed 123+ — no parameter combination could satisfy both bounds. v3 returns to a flat model but adds the clear-miss mechanism (below) to specifically attack line-clear healing. The danger multiplier creates a death-spiral feedback loop.
Clear-miss (new)Not implementedclearMissP = base × 0.5 × danger, capped at 0.20, only when danger ≥ 0.3The flat fumble alone couldn't overcome the bot's line-clear healing (79 lines cleared in 200 pieces by a 50/50 dealer). Clear-miss specifically prevents the bot from clearing lines when under pressure, creating the death-spiral that makes bad deals stick. On an empty board (danger ≈ 0) it never triggers, so auto-mode is unaffected.
Fumble base curve (L1–10)0.14 → 0.0680.35 → 0.17Initial curve was too gentle. v2 (0.70 → 0.385) was too aggressive (auto-mode died). v3 (0.35 → 0.17) is the middle ground — combined with clear-miss and no-lookahead, it creates the right pressure.
Fumble base curve (L11–20)0.06 → 0.0240.17 → 0.06Mid-levels have full lookahead, so the fumble rate can be lower — the bot's competence comes from planning, not fumble frequency.
Fumble base curve (L21–28)0.020.04Slight increase; physics dominates at these levels regardless.
Fumble cap0.450.40Slightly lower — the clear-miss handles the heavy lifting now, so individual fumbles don't need to be as frequent.
Fumble placement severity≥ 5.0 below best (no upper cap)10–50 below bestv1 (no cap) could select the absolute worst placement (braindead-looking, e.g. I-on-mess). v2 (5–25 band) was too gentle — fumbles added height but not holes. v3 (10–50 band) creates more lasting damage (holes/gaps) while still being clearly suboptimal, not catastrophic.
Curve smoothing pass (v4)Per-band piecewise formulas; L21–28 byte-identical configs; choiceQ dipped 0.98→0.95 at L21; fumbleBase halves instantly at L20→21All curves single-slope breakpoints; lookahead ramps 0.75→0.90 across L21–28; choiceQ rises monotonically to 0.95; fumbleBase tapers smoothly to 0.015 at L28A config audit revealed eight identical levels at the top of the ladder (the only progression signal was the level number) plus two non-monotonic artifacts. The smoothing gives L21–28 real per-level progression ("the bot tightens up"). Two follow-up findings from re-benchmarking: (a) lookahead 0.90 at L29 let the bot survive the kill screen (auto 500+) — reverted to the balanced 0.75; (b) choiceQ 1.0 at L29 also made it unkillable (the 5% random timeout pieces are the margin physics needs, since a human cannot deal in ⅓ s windows) — capped at 0.95. Verified: 29/29 distinct configs, kill screen lethal again (auto ~51), optimal progression monotonic 44→135 across L1–28.

10. Internationalization

Scope. The game UI is localized through the series' shared engine public/shared/i18n.js (loaded as an external, offline-safe script) plus a per-page dictionary public/shared/lang/saboteur.js. No network, no runtime dependencies.

Supported languages (8). en (English), zh (中文), es (Español), de (Deutsch), fr (Français), ru (Русский), ja (日本語), ko (한국어). English is stored in the dictionary like any other language (the en block, first in the file) and doubles as the default.

Detection order. On boot, RG_I18N.init('saboteur') resolves the language as: (1) the localStorage key rg.lang (a user override, written by the switcher); (2) the browser languages — navigator.languages, then navigator.language, matched by exact id or 2-letter prefix (e.g. zh-CN → zh); (3) en. A fixed top-right <select> switcher (injected by the engine) lets the player change language at any time; the choice persists in rg.lang.

Two-string pattern. Static markup carries data-i18n="saboteur.<key>"; the engine swaps in the active translation — English included, from the dictionary's en block — and captures the element's original innerHTML once as a last-resort fallback (inline <kbd>/<br>/entities are preserved). Dynamic, JS-built strings use the page-local helper T('saboteur.<key>', 'English default'), which the engine resolves against the saboteur dictionary namespace. On a language change the page re-renders its JS-built DOM (the deal-bar cards, the level-select grid, and the canvas NEXT "?" glyph) via an RG_I18N.onChange(...) listener. Numeric HUD cells (#vLevel, #vScore, #sDealt, …) are never localized — only their surrounding labels are. The game title is a standalone field: title_brand is the first key of every language block (it is the title-screen wordmark), and the document <title> (doc_title) is derived from it as title_brand + " — " + descriptor in the dictionary's GAME TITLE section, so renaming the game in one place follows into the browser tab.

Harness safety. The deterministic headless harness (tests/saboteur/harness.js) extracts and evals only the single bare inline <script> block; the engine and dictionary are loaded as external <script src> tags and are therefore invisible to it. Inside that block window.RG_I18N is undefined, so the guarded boot is skipped and T() returns its English default — the game (and all value assertions) behave exactly as before localization.