20 KiB
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:
- Headless core — game state is a plain data model, no DOM, no wall clock.
- Authoritative-host capable — the same core can run on a host that owns the RNG, the clock, and the move sequence, so game state and timing can be computed by an authoritative host rather than solely on the client.
- Backwards compatible — the existing DOM UI, CSS, and jsdom tests keep working; today's offline play is just "the core with a local transport."
- 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<Cell> 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}) → validate
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)
renderer.js # events → DOM (the ONLY place document is touched) — EXTRACTED
transport.js # LocalTransport (RemoteTransport added later) — EXTRACTED
# Decision: the input state machine (mouse/touch/chording/long-press) and the
# timer stay INLINE in mnswpr.js for now — not extracted to input-adapter.js /
# timer-view.js. The input code is intricate and under-tested (no chord/touch/
# middle-click coverage yet), so extraction is deferred until those
# characterization tests exist or a concrete need arises. See §9.
package.json exports (sub-path is the only surface change consumers see):
"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/andsession/never importminesweeper/.minesweeper/depends ongrid/+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.
// Coord is a plain [r, c] tuple everywhere (cheap, serializable).
// Grid<Cell> — 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 & host authority (session/)
The most reusable layer, and the one that makes an authoritative host 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)
/**
* @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
class GameSession {
/**
* @param rules a GameRules implementation
* @param opts.seed number — seeds board gen + RNG (host-held when a host owns the session)
* @param opts.config game config (level/difficulty)
* @param opts.clock () => number — INJECTED time source (whoever runs the session owns it)
*/
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'sDate.now. On an authoritative host it's the host's clock — soelapsed()is owned by whoever runs the session. - Injected, seedable RNG (
rng.js, e.g. mulberry32 seeded byopts.seed). Board generation is a pure function ofseed(+ first click), so a run is bit-for-bit reproducible. A host that owns the session holds the seed and sends only projected views, so unrevealed board state need not be sent to the client mid-game.
Replay (replay.js)
// Re-runs a game from scratch from its recorded inputs and returns the
// recomputed outcome. A host can use this to validate a submitted
// { seed, config, log }:
// - does the log actually solve the board?
// - is the move timeline monotonic and within plausible bounds?
// - does the recomputed time match the recorded time?
replay(rules, { seed, config, log }) // → { status, time, valid, reason? }
Because the core is deterministic, a full game can be reconstructed from its
inputs alone — no live per-move host needed; replay() can run wherever a host
processes a submission. It requires exactly this headless core and nothing else.
Determinism is a hard rule for Layers 1–2: no
Date.now(), noMath.random(), nonew 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)
// Cell — replaces the <td> + data-status/data-value attributes.
Cell = { mine: boolean, adjacent: number, status: 'hidden' | 'flagged' | 'revealed' }
// State
{
grid: Grid<Cell>,
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)
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)
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 host 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 onerevealevent. - Chording (ports the left+right behavior), returns a
revealorexplode. - Win = every non-mine cell revealed. Loss = a mine revealed.
Hidden-information projection (project.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). A host 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 (<table> 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 a host (authoritative host)
Transport — one interface, two implementations (mirrors the leaderboard adapter pattern)
// 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
}
LocalTransportwraps aGameSessionwithclock = Date.now. This is today's behavior exactly: fast, offline, timing owned by the client — fine for offline play and the standalone npm engine.RemoteTransportforwards moves to a host, which holds the authoritativeGameSession, and streams back projected events. Timing is host-owned. Used when a game runs on an authoritative host.
Renderer
render(container, view) builds the initial <table>; 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
TimerServicesplits in two: the authoritative clock moves intoGameSession(injected); the display becomes a dumbTimerViewthat showstransport-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 buildsgameas today. In Remote mode the host produces the authoritative{ time, status }and records the leaderboard result — the client renders it rather than computing it.
The public constructor stays hook-shaped for compatibility, e.g.:
Minesweeper(appId, version, {
transport: new LocalTransport({ level }), // or RemoteTransport({ endpoint })
levelChanged(setting) { … },
gameDone(game) { … } // Local: client-built; Remote: host-authoritative
})
6. Two run modes, one codebase
| Local / offline | Authoritative host | |
|---|---|---|
| Where the core runs | in the browser | on a host |
| Clock | Date.now |
host clock |
| Board/seed | in browser | host-held, sent as rules allow |
| Transport | LocalTransport |
RemoteTransport |
| Needs a host tier | no | yes (Function/Worker + session store) |
| Use | offline play, published npm engine | host-owned sessions |
The published @ayo-run/mnswpr stays fully functional standalone (Local mode).
Running on a host 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:
- Transport is
async.send(move)returns a Promise (or fires a callback) even thoughLocalTransportresolves instantly. Violation: client code assumes synchronous returns → everyRemoteTransportcall breaks. - Only serializable messages cross the Transport. Moves in; Events + a
projected view out; plain JSON. Never hand the client a live
GameSession,Grid, orState. Violation: nothing survives a network hop. - The Renderer consumes only
project(state)+ events — never raw mine positions, even offline where it technically has them. Violation: host mode (which withholds unrevealed board state) needs a renderer rewrite; hidden- information projection stops being a drop-in. - 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 replay/validation path has no substrate. - 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)
- 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. - Renderer + LocalTransport. Build the event-driven Renderer that reproduces
today's exact DOM, and
LocalTransportaroundGameSession. - Port InputAdapter to emit
Moves into the transport instead of mutating the DOM. - Swap
apps/mnswpr/main.jsto construct the client withLocalTransport. Behavior is identical to today — the existing jsdom tests (real DOM events on#app) are the regression harness and must stay green. - (Later) Remote. Add a host runtime running
GameSessionauthoritativelyRemoteTransport; the host records results.
- (Later) Extract
grid/+session/into@cozy-games/grid+@cozy-games/game-sessiononce 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(), assertvalidand 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.randomappear incore/outside the injectedclock/rngseams.
11. Open decisions
- Package boundary: ✅ resolved — one
@ayo-run/mnswprpackage, core at the./coresub-path (§1). Extraction to@cozy-games/grid+@cozy-games/game-sessiondeferred to when Sudoku lands; the sub-path stays stable across that move. - Host mode (replay-validation vs live authority): deferred. Ship
offline-only for now (
LocalTransport, client-owned 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
Transportinterface reserves the seam.