# Headless core + client design — `@ayo-run/mnswpr/core` and the client that consumes it Design for splitting the Minesweeper engine into a **headless, isomorphic core** (runs identically in a browser or on a server) and a **thin client** that renders it. The core is internally layered so the generic bottom can later be lifted out into `@cozy-games/grid` + `@cozy-games/game-session` once a second game (Sudoku) exists to validate the abstraction. We are **not** building the generic grid engine now — only structuring for it. Goals, in priority order: 1. **Headless core** — game state is a plain data model, no DOM, no wall clock. 2. **Server-authoritative capable** — the same core can run on a server that owns the RNG, the clock, and the move sequence, so leaderboard times are witnessed, not claimed. (Closes the score-spoofing gap from the security review.) 3. **Backwards compatible** — the existing DOM UI, CSS, and jsdom tests keep working; today's offline play is just "the core with a local transport." 4. **Extraction-ready** — a clean seam between generic (grid/session) and Minesweeper-specific (mines/reveal) code. --- ## 1. Package layout & the seam **Decision (settled): one published package, core exposed as a sub-path.** Open- source consumers keep installing a single `@ayo-run/mnswpr` and get the headless core for free at `@ayo-run/mnswpr/core` — no second package to publish, version, or document. The DOM client stays the default entry (`.`). ``` packages/mnswpr/ # @ayo-run/mnswpr — ONE published package mnswpr.js # "." → DOM client (browser entry; today's default) levels.js # shared by client + core (level presets) core/ # "./core" → headless, isomorphic, ZERO DOM, ZERO wall-clock index.js # the sub-path entry — public core API grid/ # Layer 0 → future @cozy-games/grid grid.js # Grid container, coords, inBounds neighbors.js # neighbor STRATEGIES (eightWay, orthogonal, …) serialize.js session/ # Layer 1 → future @cozy-games/game-session session.js # GameSession: lifecycle, injected clock, move log rng.js # seedable deterministic PRNG (mulberry32) replay.js # replay(rules, {seed, config, log}) → verify minesweeper/ # Layer 2 → Minesweeper-specific rules rules.js # GameRules impl: init/apply/status/project board.js # deterministic board gen + first-click safety reveal.js # flood-fill, chording client/ # DOM client internals (consume ./core) — added in migration renderer.js # events → DOM (the ONLY place document is touched) input-adapter.js # gestures → Move intents transport.js # LocalTransport (RemoteTransport added later) timer-view.js ``` `package.json` `exports` (sub-path is the only surface change consumers see): ```jsonc "exports": { ".": { "default": "./dist/mnswpr.js" }, // DOM client (browser) — unchanged default "./core": { "default": "./core/index.js" }, // headless, isomorphic core (source ESM) "./dist/*": { "default": "./dist/*" }, "./*": { "default": "./*" } } ``` - `import mnswpr from '@ayo-run/mnswpr'` → DOM client, exactly as today. - `import { GameSession, MinesweeperRules } from '@ayo-run/mnswpr/core'` → headless. The core entry pulls in **zero DOM**, so a server (later) imports it without dragging in browser code, and the browser bundle for `.` never includes the core's server-only helpers. **The seam** = the boundary *inside* `core/` between `grid/` + `session/` (generic) and `minesweeper/` (specific). Exposing the core via a sub-path does **not** compromise the future extraction — the publish surface and the internal layering are orthogonal. Rules of the seam: - `grid/` and `session/` **never import** `minesweeper/`. - `minesweeper/` depends on `grid/` + `session/` only through their public interfaces (below) — no reaching into internals. - Anything Minesweeper-specific that leaks *down* (8-way adjacency, "mine", "reveal") is a bug in the layering. Adjacency is **injected**, not assumed. When Sudoku lands, `core/grid/` and `core/session/` move out verbatim into `@cozy-games/grid` + `@cozy-games/game-session`; `core/minesweeper/` (and a new `sudoku/`) depend on them, and `@ayo-run/mnswpr/core` re-exports from them so the consumer-facing sub-path is unchanged. --- ## 2. Layer 0 — generic grid (`grid/`) A dumb, dense 2D container of opaque cells. Knows nothing about game meaning. ```js // Coord is a plain [r, c] tuple everywhere (cheap, serializable). // Grid — Cell is opaque to this layer. class Grid { constructor(rows, cols, fill) // fill: (r, c) => Cell get rows(); get cols() at(r, c) // → Cell (throws/undefined out of bounds) set(r, c, cell) inBounds(r, c) // → boolean forEach(fn) // fn(cell, r, c) map(fn) // → Grid of new cells clone() } // Neighbor STRATEGY — the critical extraction seam. Injected, never baked in. // eightWay is Minesweeper's; Sudoku would inject `peers` (row ∪ col ∪ box). const eightWay = (grid, r, c) => [...8 in-bounds diagonal+orthogonal coords] const orthogonal = (grid, r, c) => [...4 in-bounds N/E/S/W coords] ``` `serialize.js`: `toJSON(grid)` / `fromJSON(data, reviveCell)` — used by the session for the move log and by the server for persistence/replay. --- ## 3. Layer 1 — generic session & authority (`session/`) The most reusable layer, and the one that makes server authority possible. It owns **lifecycle, time, and the move log**, and delegates game meaning to an injected `GameRules` object. ### The rules contract (what any game implements) ```js /** * @template State, Move, Event * A pure, deterministic game definition. NO Date, NO Math.random, NO DOM. */ const GameRules = { init(seed, config), // → State (deterministic from seed) apply(state, move, rng), // → { state, events } (pure; rng passed in) status(state), // → 'active' | 'won' | 'lost' project(state) // → ClientView (hides secrets; see §4) } ``` ### The session ```js class GameSession { /** * @param rules a GameRules implementation * @param opts.seed number — seeds board gen + RNG (server-held for authority) * @param opts.config game config (level/difficulty) * @param opts.clock () => number — INJECTED time source (authority lives here) */ constructor(rules, opts) applyMove(move) // stamps t=clock(); appends {move, t} to log; // rules.apply(...); returns projected events + view. status() // 'active' | 'won' | 'lost' view() // rules.project(state) — safe to send to a client elapsed() // authoritative: t(last decisive move) − t(first move) log() // [{move, t}] — the audit trail result() // on terminal: { status, time, seed, config, log } } ``` Two decisions that unlock everything: - **Injected clock.** The session never calls `Date.now()`. The *caller* supplies the clock. On the client it's `Date.now` (cosmetic). On the server it's the server's clock — so `elapsed()` is authoritative and unforgeable. - **Injected, seedable RNG** (`rng.js`, e.g. mulberry32 seeded by `opts.seed`). Board generation is a pure function of `seed` (+ first click), so a run is **bit-for-bit reproducible**. The server holds the seed; the client never sees it mid-game (or it could regenerate the board and cheat). ### Replay-verify (`replay.js`) ```js // Re-runs a submitted game from scratch and returns the authoritative outcome. // The server uses this to VERIFY a client-submitted { seed, config, log }: // - does the log actually solve the board? // - is the move timeline monotonic and within plausibility bounds? // - does the recomputed time match the claimed time? replay(rules, { seed, config, log }) // → { status, time, valid, reason? } ``` This is the lighter "verifiable replay" anti-cheat path from the prior discussion: no live per-move server needed — just call `replay()` in a Cloud Function at submit time. It requires exactly this headless core and nothing else. > **Determinism is a hard rule for Layers 1–2:** no `Date.now()`, no > `Math.random()`, no `new Date()` inside core logic — all injected. This is what > makes replay reproducible and tests deterministic. (Same constraint we already > follow elsewhere in the repo.) --- ## 4. Layer 2 — Minesweeper rules (`minesweeper/`) ### State (plain data — the DOM's job is gone) ```js // Cell — replaces the + data-status/data-value attributes. Cell = { mine: boolean, adjacent: number, status: 'hidden' | 'flagged' | 'revealed' } // State { grid: Grid, config: { rows, cols, mines, id }, // from levels.js phase: 'fresh' | 'active' | 'won' | 'lost', minesPlaced: boolean // false until the first reveal (safety) } ``` ### Moves (intents — what the client sends) ```js Move = | { type: 'reveal', r, c } | { type: 'flag', r, c } // toggle | { type: 'chord', r, c } // reveal neighbors when flag-count satisfied ``` ### Events (deltas — what the renderer/server emits) ```js Event = | { type: 'reveal', cells: [{ r, c, adjacent }] } // whole flood-filled region | { type: 'flag', r, c, flagged: boolean } | { type: 'explode', r, c, mines: [{ r, c }] } // loss reveal | { type: 'win' } ``` Emitting **deltas** (not full state) is what lets the client render incrementally *and* lets an authoritative server withhold the rest of the board. ### First-click safety, done right Generate the board **on the first reveal**, from `(seed, firstClick, config)`, excluding the first cell and its neighbors from mine placement. Replaces today's `transferMine()` relocation. Benefits: nothing exists to leak before move 1, the first click is provably safe, and generation stays a pure seed function. ### `reveal.js` - Flood-fill of the connected zero-adjacency region (ports `handleEmpty`), returns the revealed cells as one `reveal` event. - Chording (ports the left+right behavior), returns a `reveal` or `explode`. - Win = every non-mine cell revealed. Loss = a mine revealed. ### Hidden-information projection (`project.js`) — the authority crux ```js project(state) // → ClientView ``` Returns only what a client is allowed to know: revealed cells + their adjacency, flags, and phase. **Unrevealed mine positions are never included** (until a terminal `explode`/`win`, when the full board is disclosed for the reveal animation and verification). The server sends `view()` / event deltas — never the raw state, never the seed. A client therefore cannot see unrevealed mines even if it inspects every byte it receives. --- ## 5. The client (`packages/mnswpr` → consumer of the core) The client keeps today's look, CSS, and DOM shape (`` with `game-status` / `data-status` / `data-value`) — but it becomes a **consumer** of core state, not the owner of it. Four parts: ``` InputAdapter Transport Renderer TimerView (gestures → Move) ──▶ (Local | Remote) ──▶ Event[] ──▶ (deltas → DOM) (time → DOM) │ ├─ Local : in-process GameSession (offline / npm engine) └─ Remote: HTTP/WS to server (ranked / authoritative) ``` ### Transport — one interface, two implementations (mirrors the leaderboard adapter pattern) ```js // The client talks ONLY to this — it never knows if the game runs here or on a server. Transport = { start(config) // → { view, time } begin a game send(move) // → { events, view, time } apply a move onEvent(cb) // (Remote may push server events; Local resolves inline) result() // → { status, time, … } when terminal } ``` - **`LocalTransport`** wraps a `GameSession` with `clock = Date.now`. This is today's behavior exactly: fast, offline, timing cosmetic (still spoofable — fine for casual/offline and the standalone npm engine). - **`RemoteTransport`** forwards moves to the server, which holds the authoritative `GameSession`, and streams back projected events. Timing is server-owned. Used for ranked play; pair with **App Check** so only the real app can submit. ### Renderer `render(container, view)` builds the initial `
`; `applyEvents(events)` mutates the DOM from deltas. This is the *only* place `document` is touched. It reuses the current markup/attributes so existing CSS and the jsdom tests survive. (It is basically today's DOM-building code, inverted: driven by events instead of owning state.) ### InputAdapter Keep the existing mouse/touch state machine (left/right/middle, chording, long-press-to-flag, `isBusy` debounce) — but instead of mutating the DOM, it **emits `Move` intents** to the transport. This is the trickiest existing code; porting it as "same gestures, different output" keeps the hard-won input feel. ### TimerView & `gameDone` - `TimerService` splits in two: the **authoritative clock** moves into `GameSession` (injected); the **display** becomes a dumb `TimerView` that shows `transport`-reported time. In Remote mode it shows server time (optionally a locally-interpolated estimate reconciled on each server message). - The current `hooks.gameDone(game)` fires from the terminal event. In **Local** mode the client builds `game` as today. In **Remote** mode the **server** produces the authoritative `{ time, status }` and writes/sign the leaderboard result — the client submits nothing it could forge. That is the whole point. The public constructor stays hook-shaped for compatibility, e.g.: ```js Minesweeper(appId, version, { transport: new LocalTransport({ level }), // or RemoteTransport({ endpoint }) levelChanged(setting) { … }, gameDone(game) { … } // Local: client-built; Remote: server-authoritative }) ``` --- ## 6. Two run modes, one codebase | | **Local / offline** | **Server-authoritative** | |---|---|---| | Where the core runs | in the browser | on the server | | Clock | `Date.now` (cosmetic) | server clock (authoritative) | | Board/seed | in browser | server-held, never sent | | Transport | `LocalTransport` | `RemoteTransport` | | Leaderboard trust | claimed (spoofable) | witnessed / verified | | Needs a server tier | no | yes (Function/Worker + session store) | | Use | offline play, published npm engine | ranked play | The published `@ayo-run/mnswpr` stays fully functional standalone (Local mode). Ranked play opts into Remote. Same renderer, same input, same rules. --- ## 7. Server-readiness invariants (the offline build MUST hold these) Going offline-first is only "server-easy later" if the offline build refuses a few tempting in-process shortcuts. Each maps to a concrete failure if violated. These are the disciplines that make the server additive rather than a rewrite: 1. **Transport is `async`.** `send(move)` returns a Promise (or fires a callback) even though `LocalTransport` resolves instantly. *Violation:* client code assumes synchronous returns → every `RemoteTransport` call breaks. 2. **Only serializable messages cross the Transport.** Moves in; Events + a projected view out; plain JSON. Never hand the client a live `GameSession`, `Grid`, or `State`. *Violation:* nothing survives a network hop. 3. **The Renderer consumes only `project(state)` + events** — never raw mine positions, even offline where it technically has them. *Violation:* server mode (which withholds the board) needs a renderer rewrite; board-secrecy stops being a drop-in. 4. **The core is deterministic now** — seeded RNG + injected clock + a working `replay()`, even though offline play doesn't need them. *Violation:* retrofitting determinism into board generation later is a rewrite, and the verify/authority path has no substrate. 5. **The client is stateless about rules.** All win/loss/reveal logic lives in the core; the client only renders. *Violation:* client-side rule shortcuts aren't authoritative on a server. A determinism guard (see §10 Testing) fails the build if `Date`/`Math.random` appear in `core/` outside the injected `clock`/`rng` seams. ## 8. Mapping from today's engine | Today (`packages/mnswpr/mnswpr.js`) | Moves to | |---|---| | `document.createElement` grid build | client **Renderer** | | `data-status` / `data-value` / `game-status` attrs | core **State** (`Cell`, `phase`) | | `getStatus`/`setStatus`/`isMine`/`isFlagged` | core model ops | | `minesArray` + `transferMine` (first-click safety) | `minesweeper/board.js` (seeded gen) | | `handleEmpty` flood-fill, chording | `minesweeper/reveal.js` | | mouse/touch handlers, chording, long-press | client **InputAdapter** (emits Moves) | | `TimerService` (`Date.now`, rAF, DOM write) | clock → `GameSession`; display → `TimerView` | | `hooks.gameDone(game)` | terminal Event → Local builds `game` / Remote = server | | `levels.js` | `minesweeper/levels.js` | --- ## 9. Migration plan (each step keeps the suite green) 1. **Core, headless & tested.** Port board gen, flood-fill, chording, win/loss into `core/` as pure functions with unit tests over plain data (fast, no DOM). Add seeded RNG + `replay()`. **← this diff.** 2. **Renderer + LocalTransport.** Build the event-driven Renderer that reproduces today's exact DOM, and `LocalTransport` around `GameSession`. 3. **Port InputAdapter** to emit `Move`s into the transport instead of mutating the DOM. 4. **Swap `apps/mnswpr/main.js`** to construct the client with `LocalTransport`. Behavior is identical to today — the existing jsdom tests (real DOM events on `#app`) are the regression harness and must stay green. 5. **(Later) Remote.** Add a server runtime hosting `GameSession` authoritatively + `RemoteTransport` + App Check; server writes verified scores. 6. **(Later) Extract** `grid/` + `session/` into `@cozy-games/grid` + `@cozy-games/game-session` once Sudoku exists. ## 10. Testing strategy - **Core:** pure data-model unit tests — deterministic via fixed seeds; property tests (e.g. flood-fill never reveals a mine; win ⇔ all non-mines revealed). - **Replay:** generate a random valid game, feed its log to `replay()`, assert `valid` and matching time; mutate the log and assert rejection. - **Client:** keep the current jsdom tests (mount, dispatch real mouse events, assert on cell/grid attributes) — now exercising Renderer + InputAdapter + LocalTransport end-to-end. - **Determinism guard:** a lint/test that fails if `Date`/`Math.random` appear in `core/` outside the injected `clock`/`rng` seams. ## 11. Open decisions - **Package boundary:** ✅ *resolved* — one `@ayo-run/mnswpr` package, core at the `./core` sub-path (§1). Extraction to `@cozy-games/grid` + `@cozy-games/game-session` deferred to when Sudoku lands; the sub-path stays stable across that move. - **Enforcement level (verifiable-replay vs full authority):** *deferred.* Ship offline-only for now (`LocalTransport`, cosmetic timing — matches today's UX). The deterministic core + `replay()` are built now so either path is a later add-on, not a rewrite. - **Remote transport / server host / latency & cost:** *deferred* (offline-first). HTTP-vs-WebSocket, Netlify Function vs Worker, and the session store are picked when we do the server; the `Transport` interface reserves the seam.