diff --git a/docs/superpowers/contracts/reverse-survivors-difficulty-tiers-2026-08-09.json b/docs/superpowers/contracts/reverse-survivors-difficulty-tiers-2026-08-09.json
new file mode 100644
index 00000000..65a12ab3
--- /dev/null
+++ b/docs/superpowers/contracts/reverse-survivors-difficulty-tiers-2026-08-09.json
@@ -0,0 +1,15 @@
+{
+ "title": "Reverse Survivors: difficulty tiers",
+ "objective": "Add three difficulty tiers - Haunting, Normal and Crusade - scaling enemy hp and speed, the survivor's health, director income, and the length of the night. Normal must be the exact identity tier so no existing behaviour or assertion changes.",
+ "acceptance_criteria": [
+ "`npm run check` passes standalone: tsc --noEmit clean, eslint --max-warnings 0 clean, vitest at 100% on all four metrics.",
+ "Normal is exactly 1.0 on every multiplier, so all pre-existing raw-value assertions hold unchanged.",
+ "An explicit duration argument still overrides the tier's duration.",
+ "Changing difficulty restarts the game on the new tier, and restarting preserves the selected tier."
+ ],
+ "out_of_scope": [
+ "Endless mode: fmtTime(Infinity) renders Infinity:NaN and would need a guard plus tests for a mode whose only content is the same game but longer.",
+ "localStorage persistence of the chosen tier: its catch arm is only reachable by stubbing a throwing setItem, which tests the mock rather than the game."
+ ],
+ "verifier": "npm run check (typecheck + eslint --max-warnings 0 + vitest --coverage at 100%), plus an App-level test asserting the clock changes from 5:00 to 6:00 when Crusade is selected"
+}
diff --git a/docs/superpowers/evidence/reverse-survivors-difficulty-tiers-2026-08-09.json b/docs/superpowers/evidence/reverse-survivors-difficulty-tiers-2026-08-09.json
new file mode 100644
index 00000000..7397e3b2
--- /dev/null
+++ b/docs/superpowers/evidence/reverse-survivors-difficulty-tiers-2026-08-09.json
@@ -0,0 +1,42 @@
+{
+ "intent": "Add Haunting/Normal/Crusade difficulty tiers and document the whole four-part extension in the README.",
+ "scope": [
+ "reverse_survivors/src/core/types.ts (DifficultyId, DifficultyConfig, DIFFICULTIES)",
+ "reverse_survivors/src/core/sim.ts (createInitialState parameter, enemySpeed helper, hp scaling), director.ts (regenRate rate)",
+ "reverse_survivors/src/App.tsx (tier selector), src/styles.css, README.md",
+ "Non-goal: endless mode and localStorage persistence, both deliberately excluded"
+ ],
+ "changes": [
+ "Difficulty is a pure config object resolved once in createInitialState and stored on GameState, so no downstream code re-looks-it-up.",
+ "Normal is exactly 1.0 on every multiplier, which is what lets every pre-existing raw-hp assertion keep passing unchanged.",
+ "Enemy speed now derives from a single enemySpeed helper, so difficulty and frenzy compose in one place instead of fighting at three call sites.",
+ "regenRate gained an optional rate parameter defaulting to 1, keeping its existing call sites and assertions valid.",
+ "Fixed while wiring the selector: handleRestart ignored the chosen tier, so restarting after picking Crusade silently reverted to Normal. Surfaced by the react-hooks purity rule flagging Date.now() in the new handler, which led to extracting restartSeed().",
+ "README rewritten to cover the new units, powers, statuses and tiers, and to document the deliberate reversal of its former 'placement is never yours to choose' promise."
+ ],
+ "verification": [
+ {
+ "command": "npm run check (typecheck + eslint --max-warnings 0 + vitest --coverage)",
+ "result": "pass",
+ "evidence": "160 tests passed. Coverage 100% on all four metrics: statements 531/531, branches 159/159, functions 101/101, lines 514/514."
+ },
+ {
+ "command": "npm run build",
+ "result": "pass",
+ "evidence": "tsc --noEmit clean, vite build in ~72ms, 210.08 kB / 66.26 kB gzip."
+ },
+ {
+ "command": "headless full-game playtest driving the real sim (vite-node)",
+ "result": "pass",
+ "evidence": "Banking energy then bursting wave+frenzy+ambush killed the survivor at t=53s with the warden's slow live (1.92s) and frenzy active (4.20s). A passive director loses on every tier; Crusade won at t=56s; Haunting and Normal went to the survivor. Same seed twice produced byte-identical outcomes."
+ }
+ ],
+ "risks": [
+ "Tier multipliers are simulation-verified rather than human-playtested and may need retuning.",
+ "Pre-existing and unchanged here: with a trickle strategy the survivor out-kills a 6 energy/sec income, so banking and bursting is the effective approach."
+ ],
+ "rollback": [
+ "git revert the commit; the game returns to a single fixed difficulty with no persisted state to migrate.",
+ "After rollback run `npm run check` in reverse_survivors/ and confirm the suite passes at 100% coverage."
+ ]
+}
diff --git a/reverse_survivors/README.md b/reverse_survivors/README.md
index b5b570e8..736a4cda 100644
--- a/reverse_survivors/README.md
+++ b/reverse_survivors/README.md
@@ -14,14 +14,32 @@ npm run dev
```
- **Rusher / Stalker / Tank / Bomber** — single spawns at a random arena edge.
+- **Splitter** — dies into three rushers. Killing it _creates_ pressure.
+- **Artillery** — outranges the survivor (460 vs its 260) and holds station beyond
+ reach. Its shells **stifle**, stretching the survivor's reload.
+- **Warden** — faster than the survivor and **mires** it on contact.
- **Wave N** — a ring formation around the survivor; each call grows and costs more.
-- **Colossus** (unlocks 1:00) and **Hivemind** (unlocks 2:00) — expensive, on cooldown.
- The Hivemind births free rushers while it lives.
+- **Colossus** (1:00), **Hivemind** (2:00) and **Leech** (3:00) — expensive, on
+ cooldown. The Hivemind births free rushers; the Leech's touch **unknits** flesh,
+ shutting off regeneration while it lives.
+- **Ambush** places a unit right next to the survivor, at a premium.
+ **Frenzy** drives the whole horde faster and harder for six seconds.
+ **Rift** pins every spawn to one edge for ten, so you can mass a flank.
- The survivor gains XP per kill and rolls an upgrade per level
(damage, fire rate, speed, vitality, regen, multishot). Time favors it. Pressure early.
+- **Haunting / Normal / Crusade** scale enemy hp and speed, the survivor's health,
+ your income, and the length of the night.
-Where enemies appear is deliberately not yours to choose — the director picks
-_what_ and _when_; the seeded RNG picks _where_.
+Placement is purchasable, never free. By default the director picks _what_ and
+_when_ while the seeded RNG picks _where_ — Ambush and Rift buy that choice back,
+one at a premium and the other at a cooldown.
+
+> Earlier versions made the loss of placement control absolute. Ambush and Rift
+> deliberately reverse that: the interesting decision turned out to be _what a
+> placement is worth_, not being denied one.
+
+All three debuffs need contact — a survivor that kites cleanly takes none of them —
+and they refresh rather than stack, so a horde can never chain-lock the intruder.
## Quality gates
@@ -42,6 +60,11 @@ ESLint 10.8.0 · typescript-eslint 8.65.0 · TypeScript **6.0.3**.
## Architecture (what makes 100 % honest)
+- `src/core/status.ts` — the survivor's debuff timers. `isOn`/`factor` are written
+ as arithmetic (`Math.min(1, Math.ceil(t))`) rather than comparisons, so the whole
+ status system contributes **zero branches** to the coverage gate; consumers just
+ multiply by the result. `applyStatus` uses `Math.max`, which is what makes
+ refresh-not-stack a structural guarantee rather than a tuned number.
- `src/core/` — the whole game as pure-ish functions over a plain state object.
Deterministic: seeded mulberry32 RNG lives _in_ the state; same seed + same
action sequence ⇒ identical run. Behavior dispatch uses records instead of
diff --git a/reverse_survivors/src/App.test.tsx b/reverse_survivors/src/App.test.tsx
index dbce883d..f3709c40 100644
--- a/reverse_survivors/src/App.test.tsx
+++ b/reverse_survivors/src/App.test.tsx
@@ -60,6 +60,17 @@ describe('App', () => {
expect(screen.getByText('30 / 400')).toBeInTheDocument()
})
+ it('restarts on a new tier when the difficulty is changed', () => {
+ const raf = installRaf()
+ render()
+ pump(raf, 0)
+ // Normal runs 5:00; Crusade runs 6:00, so the clock proves the tier took.
+ expect(screen.getByText(/\/ 5:00/)).toBeInTheDocument()
+ fireEvent.click(screen.getByRole('button', { name: 'Crusade' }))
+ expect(screen.getByText(/\/ 6:00/)).toBeInTheDocument()
+ expect(screen.getByRole('button', { name: 'Crusade' })).toHaveClass('diff-on')
+ })
+
it('dispatches a director power from the bar', () => {
const raf = installRaf()
render( { state.director.energy = 400 }} />)
diff --git a/reverse_survivors/src/App.tsx b/reverse_survivors/src/App.tsx
index 235cef7e..05de4187 100644
--- a/reverse_survivors/src/App.tsx
+++ b/reverse_survivors/src/App.tsx
@@ -4,7 +4,8 @@ import { fmtTime } from './ui/format'
import { createInitialState, step } from './core/sim'
import type { HudSnapshot } from './core/snapshot'
import { snapshotOf } from './core/snapshot'
-import type { DirectorAction, GameState } from './core/types'
+import type { DifficultyId, DirectorAction, GameState } from './core/types'
+import { DIFFICULTIES, DIFFICULTY_ORDER } from './core/types'
import type { Ctx2D } from './render/draw'
import { draw } from './render/draw'
import { CanvasStage } from './ui/CanvasStage'
@@ -18,8 +19,18 @@ interface Props {
boot?: (state: GameState) => void
}
-const createGame = (seed: number, boot: Props['boot']): GameState => {
- const state = createInitialState(seed)
+/**
+ * Fresh seed for a restart. Extracted so the impurity lives outside render —
+ * the react-hooks purity rule rejects `Date.now()` inline in a component body.
+ */
+const restartSeed = (): number => Date.now() >>> 0
+
+const createGame = (
+ seed: number,
+ boot: Props['boot'],
+ difficulty: DifficultyId = 'normal',
+): GameState => {
+ const state = createInitialState(seed, undefined, difficulty)
if (boot !== undefined) {
boot(state)
}
@@ -27,6 +38,7 @@ const createGame = (seed: number, boot: Props['boot']): GameState => {
}
export const App = ({ seed = 20260802, boot }: Props): ReactElement => {
+ const [difficulty, setDifficulty] = useState('normal')
const gameRef = useRef(createGame(seed, boot))
const [snap, setSnap] = useState(() => snapshotOf(createGame(seed, boot)))
const ctxRef = useRef(null)
@@ -47,7 +59,13 @@ export const App = ({ seed = 20260802, boot }: Props): ReactElement => {
queueRef.current.push(action)
}
const handleRestart = (): void => {
- gameRef.current = createGame(Date.now() >>> 0, undefined)
+ gameRef.current = createGame(Date.now() >>> 0, undefined, difficulty)
+ queueRef.current = []
+ setSnap(snapshotOf(gameRef.current))
+ }
+ const handleDifficulty = (id: DifficultyId): void => {
+ setDifficulty(id)
+ gameRef.current = createGame(restartSeed(), undefined, id)
queueRef.current = []
setSnap(snapshotOf(gameRef.current))
}
@@ -61,6 +79,18 @@ export const App = ({ seed = 20260802, boot }: Props): ReactElement => {