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).
| Element | Definition |
|---|---|
| Primary user | A casual player in a browser; solo only |
| Trigger | Opening the HTML file; boots to TITLE with no external resources |
| Core job | Deal tetrominoes that force the AI Stacker to top out |
| Completion (level cleared) | The Stacker tops out; level N+1 unlocks |
| Failure | None 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 boundaries | Fully local: no network calls, no external assets, no accounts; progression and best scores only in localStorage |
| Object | Canonical definition |
|---|---|
| Well | Standard 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 set | The 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. |
| Beat | The 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). |
| Cooldown | Per-piece lockout after being dealt, counted in beats. S and Z carry the longest cooldowns (SR-2). |
| Level | A 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). |
| Fumble | A visibly suboptimal placement locked by the Stacker, telegraphed by a wobble cue (SR-4). |
| Stacker's Choice | The 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). |
There is no LOSS state by design (§2). The only terminal states of PLAY are CLEARED and player-initiated quit.
I cyan, O yellow, T purple, S green, Z red, J blue, L orange).localStorage; blocked storage → session-only, silently.| Aspect | Rule |
|---|---|
| Ready phase | ~1.5 s countdown; well empty; first falling piece is chosen uniformly at random (neutral start, implemented). No input accepted. |
| Deal window | While 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. |
| Timeout | No pick when piece k locks → Stacker's Choice (FR-05). |
| Clear condition | Stacker tops out (spawn blocked or lock-out per NES rules) → CLEARED: collapse animation, SUMMARY (FR-08), unlock N+1. |
| No lose state | A 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. |
| Replay | SUMMARY offers: next level (Enter), retry this level (R), LEVEL SELECT (Esc). |
| Exceptions | Tab hidden or window blurred during PLAY → auto-PAUSED (SR-5). |
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.
7 8 9 / 4 5 6 / 1 2 3): each piece sits at the cell matching its key, so on-screen position equals physical numpad position (keys 8 and 9 are inert placeholders). Mobile taps use touch-action: manipulation plus a touchend fast path for zero-delay selection.matchMedia('(max-width:720px)'). Desktop keeps the fixed 500×512 NES landscape stage, uniformly scaled via transform: scale(); only screens narrower than 300px or shorter than 380px are rejected. Mobile reflows instead (scaling is zoom, not layout — it would shrink the side panel into an unusable sliver): the stage becomes a vertical column with the well canvas on top and the panel full-width beneath it, secondary chrome is compacted, the deal bar keeps its 3×3 numpad order with ≥46px tap targets, and the level grid becomes auto-fill. The well's height is computed in JS to fit the space above the panel, so nothing is clipped. Touch devices (detected via ontouchstart / maxTouchPoints) get an html.is-touch class that swaps all instruction copy from keyboard to touch and reveals on-screen controls: a ⏸ pause button during play, and RESUME/QUIT buttons on the pause overlay and NEXT/RETRY/LEVEL SELECT buttons on the summary overlay. The title overlay is tappable to advance.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.
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.
title_brand + " — " + title_suffix, both localized per language), a hook line with the level and piece count ("topped out the Level N Stacker in N pieces"), the deal history rendered as a row of colored squares matching each piece's NES color (I🟦 O🟨 T🟪 S🟩 Z🟥 J🟫 L🟧, Stacker's Choice beats prefixed with 🔴, capped at 12 symbols with …), and a call-to-action line with the page URL.public/shared/share.js. Clicking SHARE RESULT always opens a retro CRT preview modal first — the player sees the exact card before anything leaves the device. From the modal: COPY (clipboard + execCommand legacy fallback + retro toast), native SHARE via the Web Share API sheet where available (a dismissed sheet is not an error), CLOSE. Modal button labels are localized in all 8 languages by the engine's own dictionary. Purely additive: no server, no account, no change to the match loop.share_btn, share_copied, share_hook, share_cta dictionary keys in all 8 languages; card assembly (buildShareCard) is a pure function pinned byte-for-byte by the 05-share test suite.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.
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].
| Band | Gravity | Stacker hand speed | Lookahead weight | Choice quality | Fumble base rate | Flavor |
|---|---|---|---|---|---|---|
| 1–5 | 48 → 28 f/row | 3 actions/s (clamped floor — hands have slack vs gravity here) | 0.0 (no lookahead) | 0.15 → 0.35 | 0.35 → 0.28 | Very forgiving: bot is myopic, timeout gives random pieces, dies in ~40 optimal / ~137 auto |
| 6–10 | 23 → 6 f/row | 3 → 8 actions/s (clamp releases) | 0.15 → 0.75 (ramping) | 0.40 → 0.64 | 0.26 → 0.19 | Bot starts planning ahead; timeout improving; auto-mode reaches 500+ |
| 11–20 | 5 → 2 f/row | 10 → 24 actions/s, compensated | 0.75 (full) | 0.70 → 0.86 | 0.17 → 0.07 | Deal window is the pressure; only optimal/80% play wins from L16 |
| 21–28 | 2 f/row (NES table plateau — kept authentic) | 24 actions/s, near physical limit | 0.75 → 0.90 (ramping) | 0.86 → 0.95 | 0.063 → 0.015 | The bot "tightens up" level by level — every rung has real progression; expert play only |
| 29 | 1 f/row — the NES original's terminal kill-screen gravity | 24 actions/s, hard cap → forced misdrops | 0.75 (reverts — physics must win) | 0.95 | 0 (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.
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).
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.
Stored in localStorage: unlocked level count, per-level best kill score, mute preference, last played level. Storage unavailable → session-only, no error surfaced.
| Case | Behavior |
|---|---|
| Small viewport / portrait phone | Proportional scaling; below usable minimum, show "enlarge your window" notice (touch is P1; phones are not P0 targets) |
| Autoplay-blocked audio | Engine initializes on first gesture; game fully playable silent |
localStorage blocked | Session-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 lock | Window-close wins ties: input timestamped after lock applies to the next window |
| Re-pick within window | Replaces pending deal; replaced piece's cooldown refunded (implemented — cooldowns apply at lock, so re-pick is free) |
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.
tests/saboteur/harness.js): reads the real index.html, stubs browser APIs (DOM, canvas 2D, Web Audio, rAF), injects a window.__S hook that exposes game internals (state, board, cur, cooldowns, stats, save, bot target, fumble flag) plus control functions (pick, setBoard, setCell, forceTarget, pieceScores, choiceFor, etc.) for deterministic scenario construction. The hook is shared with the E2E driver so the two test layers never drift.tests/saboteur/run.js): tiny per-file-isolation runner. Each test file gets a fresh module registry so the game's global state reloads cleanly. Run: node tests/saboteur/run.js.tests/saboteur/e2e.js): launches the system's headless Edge/Chrome via the DevTools protocol directly (zero npm dependencies — uses Node ≥ 22's built-in fetch/WebSocket). Serves index.html through a local HTTP server that injects the same __S hook in memory at serve time. Drives real OS-level keyboard events (Input.dispatchKeyEvent), asserts on real rendered canvas pixels (getImageData), real localStorage, and real rAF timing. Run: node tests/saboteur/e2e.js (requires unsandboxed execution — launches a real browser process).| File | Focus | Scenarios |
|---|---|---|
01-dealing.test.js | Dealing economy | Deal 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.js | Bot behavior | Bot 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.js | State machine & persistence | READY 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.js | Edge cases | Pick 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) |
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.
| # | Scenario | Asserts |
|---|---|---|
| 1 | Page boots to TITLE | State is title, title overlay visible, zero console errors |
| 2 | Any key → LEVEL SELECT | 29 level cells, 1 unlocked / 28 locked |
| 3 | Enter starts level 1: READY → PLAY | State transitions to play, well canvas has drawn pixels, HUD shows level 1 |
| 4 | Dealing O via key 2 | pending set to O, NEXT canvas contains yellow O blocks (verified via getImageData) |
| 5 | Beats advance | Dealt count ≥ 2, dealt O spawned, O on cooldown, HUD score updates |
| 6 | Esc pauses and freezes the beat clock; Esc resumes | Pause overlay visible, dealt count and gravity frozen for 700 ms, then resumed |
| 7 | Q quits to level select; nothing recorded | No best score for level 1 in localStorage |
| 8 | Top-out → SUMMARY with attribution stats, unlock + best persisted | Summary overlay visible, attribution stats filled, level 2 unlocked, best persisted |
| 9 | Reload keeps progression | After page reload, 2 levels unlocked (27 locked) |
| 10 | Mute persists across reload (SR-6) | Mute flag survives page reload in localStorage |
| 11 | No console errors accumulated across the entire sweep | Zero Runtime.exceptionThrown and zero console.error calls throughout |
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.
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.
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.
| Parameter | Initial | Final | Reason |
|---|---|---|---|
| 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/row | floor(fpr/3) f/row | L1 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 weight | 0.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 model | base × (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 implemented | clearMissP = base × 0.5 × danger, capped at 0.20, only when danger ≥ 0.3 | The 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.068 | 0.35 → 0.17 | Initial 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.024 | 0.17 → 0.06 | Mid-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.02 | 0.04 | Slight increase; physics dominates at these levels regardless. |
| Fumble cap | 0.45 | 0.40 | Slightly 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 best | v1 (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→21 | All 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 L28 | A 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. |
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.