# slotplate — full docs This file is the entire documentation of slotplate concatenated for AI agents. Each page is separated by a horizontal rule. --- # slotplate — Opinionated slot-game client on pixi-reels URL: https://slotplate.dev/ > slotplate — an opinionated, agent-ready boilerplate for slot game clients built on pixi-reels. Pure flow, FSM phases, server-authoritative math. v0.1 · MIT · client-only · agent-ready # Slot clients that don't rot. An opinionated boilerplate for slot game clients on [pixi-reels](https://github.com/schmooky/pixi-reels). FSM-driven flow, MobX stores, GSAP timing, Pixi behind one presenter. The server owns the math; the client renders what it's told. [ npm create slotplate ](/docs/quickstart/) [ See the architecture → ](/architecture/) ◆FSM phases ◆MobX stores ◆pixi-reels ◆server-authoritative ◆GSAP ticker ◆no setTimeout ◆Spine wrappers ◆typed events ◆composition root ◆disposables ◆anticipation ◆slam-stop ◆bonus sub-FSM ◆llms.txt ◆agent-ready ◆FSM phases ◆MobX stores ◆pixi-reels ◆server-authoritative ◆GSAP ticker ◆no setTimeout ◆Spine wrappers ◆typed events ◆composition root ◆disposables ◆anticipation ◆slam-stop ◆bonus sub-FSM ◆llms.txt ◆agent-ready ## A client that knows its place. slotplate is a boilerplate for slot game **clients**. That word matters. The server owns the math, the paytable, the RNG, and the source of truth for balance. The client renders what the server said happened — with the timing, feel, and polish that makes a slot worth playing. Who owns what [diagram] If it's math, it's server. If it's pixels, it's client. Crossing this line is the fastest way to a cert failure. What slotplate gives you: a shape that resists the common failure modes of slot codebases. Spin-then-settle spaghetti, bespoke reel engines that reinvent [pixi-reels](https://github.com/schmooky/pixi-reels) badly, `setTimeout` timers leaking across scenes, paytables hardcoded into TS that the math team never sees. We've seen each of those ship. Each has the same fix shape; slotplate bakes it in. [diagram] The slotplate opinion Never build a bespoke reel renderer. You will rebuild pooling, wrapping, anticipation, and Spine integration — badly — and then spend a year fixing the bugs pixi-reels already fixed. ## The mile-high view Layers with enforced import direction. Domain at the bottom holds only wire types — the shapes that cross between client and server. No win evaluation lives here. [diagram] [ Start here Quickstart npm create slotplate. Two commands, one running client. ](/docs/quickstart/) [ Architecture How it fits together Layers, FSM, spin sequence, event flow — all with real diagrams. ](/architecture/) [ Rules The 10 principles Hard rules, enforced by Biome. Know them before editing. ](/docs/principles/) For agents ### Designed to be edited by AI Every scaffolded project ships with `CLAUDE.md`, `AGENTS.md`, slash commands, and Biome rules that encode the principles. Point your agent at [/llms-full.txt](/llms-full.txt) and it stays on-spec. [ How to use slotplate with an AI agent → ](/docs/agents/) --- # Architecture URL: https://slotplate.dev/architecture/ > A tour of how slotplate is put together — one diagram per concept, with the rationale on the side. All the diagrams, in one place # Architecture A tour of how slotplate is put together — one diagram per concept, with the rationale on the side. ## 1. Who owns what: client vs server This is the first diagram to read. slotplate is a **client** — it does not own the math. Confusing this split is how slot codebases end up with parallel evaluators (one on the server, one on the client) that drift until certification fails. [diagram] Client animates. Server computes. Between them flows exactly one pair of messages per round. [diagram] The slotplate opinion The client does not evaluate wins. Not as a "just for display" fallback, not for offline mode, not for "faster response." If your architecture has an `evaluateWin` on the client, you have two sources of truth and one of them is wrong. ## 2. The layer stack Inside the client, layers import downward only. Biome enforces the boundaries — you can't import `pixi-reels` from a store, you can't import MobX from domain types, you can't reach from `view/` back into `flow/`. [diagram] The orange block (FSM) is privileged — it's the only writer to the stores. Everything else reads. ## 3. Unidirectional data flow Data moves one way around the loop. Input triggers an FSM transition. The transition mutates state through a store action. Presenters observe state changes and drive the view. The view runs, and when it finishes (reels land, animations complete) that resolution feeds back to the FSM — which transitions again. [diagram] [diagram] The slotplate opinion The moment a slot codebase has two writers to the same store, it has at least one race condition you haven't found yet. The FSM is the one writer. ## 4. The finite state machine A slot round is a finite state machine. slotplate makes the states explicit. Each named phase is a file in `src/flow/phases/`. The FSM runs exactly one phase at a time. Transitions are explicit calls — never flag-based. [diagram] idle → spin → stopSpin → winShow → idle. Bonus games nest as sub-FSMs inside their own phase. ## 5. The spin lifecycle, in sequence The same flow as a sequence diagram — who calls whom, when. Time flows top to bottom. The `par` block (marked with a dashed rectangle) is the critical bit: reels start rolling and the network request goes out in parallel. Whichever finishes first, the FSM is ready for it. [diagram] ## 6. Timing: why no setTimeout The quiet architectural detail that bites every slot codebase eventually. `setTimeout` runs on the platform clock and keeps firing in backgrounded tabs — even while Pixi's ticker pauses. slotplate uses `gsap.delayedCall` on a GSAP ticker that's synced to `app.ticker`. When Pixi pauses, game timing pauses with it. [diagram] Left: the common bug. Right: the fix. All scheduled game-time calls go through the Ticker abstraction in src/infrastructure/timing.ts. [diagram] The slotplate opinion Game code never calls `setTimeout`. Not once. Not "just this time." Use the ticker. Future you will thank present you the first time a tester reports a double-spin from a backgrounded tab. ## Where to next - [Client vs server](/architecture/client-vs-server/) — the boundary in more detail, with concrete dos and don'ts. - [Spin lifecycle](/architecture/spin-lifecycle/) — phase-by-phase walkthrough. - [FSM & phases](/architecture/fsm/) — how to add new states. - [The 10 principles](/docs/principles/) — the rules distilled. [ [diagram] Edit this page on GitHub ](https://github.com/schmooky/slotplate/edit/main/docs/src/pages/architecture.astro) docs/src/pages/architecture.astro --- # Client vs server URL: https://slotplate.dev/architecture/client-vs-server/ > The single most important boundary in a slot codebase. Architecture # Client vs server The single most important boundary in a slot codebase. slotplate is a client. The server is the source of truth — for the paytable, for the RNG, for the grid that comes back from a spin, and for the balance after it settles. This page is the definitive list of what belongs on which side. [diagram] Checkmarks = owned here. Strikethroughs = must not be here. ## The wire Exactly one request-response pair crosses the boundary per round: ``` // src/domain/types.ts — the whole wire contract. interface SpinRequest { bet: number; sessionId?: string; } interface SpinResponse { grid: Grid; // what to show on the reels totalWin: number; // what to credit the balance winlines: Winline[]; // which cells won and for how much teasingReels?: number[]; // which reels to slow down (optional) bonus?: { id: string; payload: unknown }; // triggered bonus round } ``` If a piece of state the client needs isn't in `SpinResponse`, the server extends the response — not the client reverse-engineering it from `grid`. ## Why Regulated slot games get certified. The certification body runs the server's evaluator against millions of seeds, produces an RTP report, and signs it. If the client has its own evaluator, the two drift — even starting from the same code — and one ends up wrong. That's a live incident and a certification re-run. The second reason is operational: when a win doesn't show correctly in the field, you need to know whether the bug is in the math or in the replay. If they're separate codebases with separate concerns, the answer takes minutes. If they're entangled, it takes days. [diagram] The slotplate opinion The client does not evaluate wins. Not as a "just for display" fallback. Not for offline mode. Not for "faster response." If your architecture has an `evaluateWin` on the client, you have two sources of truth and one of them is wrong. ## The mock server in slotplate `src/infrastructure/NetworkManager.ts` ships with a `MockNetworkManager` that returns a plausible `SpinResponse` with a random grid and no wins. It exists so the client can boot during development without a real server. **Do not extend it with real math.** If you want reproducible dev spins, run a real (even minimal) server alongside. The mock is a placeholder, not a sandbox. ## Common temptations (all wrong) ### "The client should know the paytable for tooltips." Tooltips should come from the server too, or from a static asset the server publishes. Bundling the paytable into the client means shipping a new client to adjust a payout — exactly what you want to avoid. Serve paytable-as-data over an endpoint. ### "We need optimistic win display for responsiveness." No — the network response arrives before the reels finish landing. You're not waiting on the server; you're waiting on the reels. The order is: reels start → server responds → reels land on the server's grid → spotlight the server's winlines. No optimism needed. ### "What about free spins? The server already committed, so the client should evaluate them." No — each free spin is still a server round. The server is authoritative on whether the free-spin session continues, on the multiplier, on retriggers. The client plays them back one by one. ### "For demo mode, the client must generate results." Then run a demo server. Or add a server endpoint `/demo/spin` that uses a fixed seed. The client still just replays. Never two evaluators. ## What the client DOES own - **When to show things.** The FSM phases sequence the reveal. - **How to animate.** Reel behavior, win pulses, spotlight, bigwin scenes. - **Speed modes.** Turbo / SuperTurbo skip phases — purely presentation. - **Skip / slam-stop.** Cancelling animations to reveal early. State is unchanged. - **Input validation.** Reject invalid bets before the request goes out. - **Optimistic balance display.** Debit the bet locally for snappy feedback; the server's number is truth. In short: the client owns *presentation and timing*; the server owns *game state and outcomes*. Keep the line clean. [ [diagram] Edit this page on GitHub ](https://github.com/schmooky/slotplate/edit/main/docs/src/pages/architecture/client-vs-server.astro) docs/src/pages/architecture/client-vs-server.astro --- # Event flow URL: https://slotplate.dev/architecture/events/ > Continuous state via MobX reactions; discrete events via typed emitters. Architecture # Event flow Continuous state via MobX reactions; discrete events via typed emitters. slotplate uses two observer primitives. Pick the one that matches the shape of the data, not the one you reach for out of habit. ## MobX reactions for continuous state `autorun` and `reaction` fire every time the observable they depend on changes. They're perfect for "update the balance display when the balance changes" — the data is a continuous value and the reaction is idempotent. ``` // HUDPresenter autorun(() => { balance.textContent = `Balance: $${stores.balance.balance.toFixed(2)}`; }); ``` ## Event emitters for discrete events `pixi-reels` ships a typed `EventEmitter`. Use it for discrete moments: "a reel landed", "all reels landed", "the spotlight started". A reaction would fire on every frame while reels spin — the wrong shape. ``` reelSet.events.on('spin:reelLanded', ({ reel, index }) => { analytics.track('reel_landed', { index }); }); ``` ## Where events flow in a round [diagram] The loop above closes via *resolution events* — `allLanded` from the reels, `onComplete` from win animations. A phase awaits these (or its `enter` function returns a promise that resolves when the resolution event arrives) and only then calls `ctx.fsm.transition`. ## Who emits what EmitterEventsConsumers `HUD DOM``click`, `keydown``HUDPresenter` `ReelSet (pixi-reels)``spin:start`, `spin:reelLanded`, `spin:allLanded`, `spin:complete``ReelsPresenter`, `Analytics` `MobX stores`reactions on observablesPresenters `NetworkManager`promise resolutionFSM phases ## What NOT to build [diagram] The slotplate opinion Don't build a central app-wide event bus unless you have three subsystems that genuinely need to talk to each other without knowing about each other. In practice, slots rarely do — the FSM is the bus. Adding a second event bus is a second thing to debug. Specifically, avoid: - A `globalEvents` singleton you emit to from everywhere. Direct references are fine; they grep better. - Re-emitting MobX reactions as events to "decouple." Reactions already decouple — that's their job. - Re-emitting `pixi-reels` events on a custom bus. Consumers can subscribe to the original. [ [diagram] Edit this page on GitHub ](https://github.com/schmooky/slotplate/edit/main/docs/src/pages/architecture/events.astro) docs/src/pages/architecture/events.astro --- # FSM & phases URL: https://slotplate.dev/architecture/fsm/ > Named phases, explicit transitions, one current state. Architecture # FSM & phases Named phases, explicit transitions, one current state. The default FSM [diagram] idle at rest, three active phases during a round. ## Why an FSM A slot round is a finite sequence of states. It can be in *exactly one* at a time. Anything that tracks game state with booleans (`isSpinning`, `canStop`, `awaitingResponse`) ends up with four booleans, then five, then six — and you can't prove which combinations are reachable. A named FSM collapses those booleans to a single enum. The set of reachable states is the set of registered phases. The set of transitions is readable by grepping for `fsm.transition`. Review, test, debug — all easier. ## The `Phase` contract ``` interface Phase { readonly name: string; enter(ctx: PhaseContext): void | Promise; skip?(ctx: PhaseContext): void; exit?(ctx: PhaseContext): void; } ``` - `enter` — the phase runs. Await work here; call `ctx.fsm.transition(...)` to move on. - `skip` — fast-forward. Called when the user slam-stops, or when Turbo skips a reveal. - `exit` — dispose anything the phase allocated (tickers, listeners). ## The context Every phase receives the same `PhaseContext`: ``` interface PhaseContext { fsm: FSM; stores: RootStore; ticker: Ticker; network: NetworkManager; reels: ReelsPresenter; hud: HUDPresenter; } ``` Phases don't reach for globals. They use `ctx`. This makes them trivially testable — instantiate with mocks and assert what happens. No `vi.mock` gymnastics. ## Adding a phase - Create `src/flow/phases/MyPhase.ts`. Implement `Phase`. - Register in `src/composition.ts` with `fsm.register(new MyPhase())`. - Add a transition to it from wherever in the existing flow should enter it. - Add a test in `tests/flow/MyPhase.test.ts`. The slash command `/add-phase MyPhase` does all of this for you. [diagram] The slotplate opinion Do not loop inside a phase. If you find yourself writing `while (stillGoing)` inside `enter`, you have two phases, not one. Cascade is the canonical example: evaluate, collapse, refill, re-evaluate — each is its own phase the FSM runs until the loop condition fails. ## Nested FSMs A bonus game runs its own FSM inside a single parent phase. The parent phase's `enter` instantiates a new `FSM`, registers bonus phases (`bonusIntro`, `bonusRound`, `bonusReveal`, `bonusOutro`), runs it to completion, then calls `ctx.fsm.transition` on the parent to move past the bonus. Main-game state stays frozen during the bonus. No main-game phase runs until the parent phase exits. ## What belongs here vs elsewhere - **In the phase:** orchestration, timing, transitions. - **In a presenter:** translating state changes into view calls. - **In a store:** holding observable state. - **In infrastructure:** I/O (network, assets, analytics). - **In the server:** everything about what outcomes happen. [ [diagram] Edit this page on GitHub ](https://github.com/schmooky/slotplate/edit/main/docs/src/pages/architecture/fsm.astro) docs/src/pages/architecture/fsm.astro --- # Layered architecture URL: https://slotplate.dev/architecture/layers/ > The stack with enforced import direction. Architecture # Layered architecture The stack with enforced import direction. [diagram] Each named block is a folder under src/. Arrows show the only direction imports may point. ## Reading the stack Top to bottom is outer to inner: the UI (HTML or React) mounts scenes, scenes hand off to the FSM, the FSM drives presenters, presenters observe stores, stores hold domain wire-types. Infrastructure (network, timing, assets) is a sibling service layer that flow and presenters can call into. ## Import rules - A layer may import from layers *below* it. - A layer may NOT import from layers *above* or *alongside* it (except `infrastructure`, which is a shared service layer callable by `flow/` and `presenters/`). - `domain/` imports nothing but types and other `domain/` files. - Only `view/` and `presenters/` may import `pixi-reels` / `pixi.js`. These are enforced by `biome.json` using `no-restricted-imports` rules. Violating them fails lint locally and in CI. ## One layer per folder Here's what lives where: FolderContent `src/composition.ts`Composition root. Wires every service. `container/`Tiny DI container (type-keyed, lazy). `state/`MobX stores. Observable state + `@action` mutators. `domain/`Wire types only. `SpinRequest`, `SpinResponse`, `Grid`, `Winline`. `infrastructure/`I/O. Network, ticker, assets, analytics. `flow/`FSM + phase handlers. Owns game time. `presenters/`State → view. `ReelsPresenter`, `HUDPresenter`. `view/`Pixi scenes, symbol classes, overlays. `config/`Client-side config (grid dims, symbol ids). ## Data flow through the layers [diagram] The loop never breaks. Input at the left, resolution feeds back to the FSM, FSM transitions again. ## Why layers, not packages A package per layer is overkill for a single-game repo. It introduces build orchestration, version management, and workspace linking — all of which are costs. Layers as folders plus lint boundaries give you the isolation without the ceremony. [diagram] The slotplate opinion Monorepos are a cost. Pay it only when you need to publish multiple packages or have multiple apps sharing code. A single slot doesn't. ## Violating the rules If you *really* need to cross a boundary (and sometimes you do — Pixi math utilities, for example, are a judgement call), do it with an ADR. An architecture decision record is a committed document explaining *why this case is different*. Adding a rule exception via code comment is not acceptable; the justification lives in version control where future-you can find it. [ [diagram] Edit this page on GitHub ](https://github.com/schmooky/slotplate/edit/main/docs/src/pages/architecture/layers.astro) docs/src/pages/architecture/layers.astro --- # Spin lifecycle URL: https://slotplate.dev/architecture/spin-lifecycle/ > Phase-by-phase walkthrough of a round, with the sequence diagram as the anchor. Architecture # Spin lifecycle Phase-by-phase walkthrough of a round, with the sequence diagram as the anchor. End-to-end sequence [diagram] Time flows top to bottom. Solid arrows = calls; dashed = returns. ## Phase-by-phase ### idle The resting state. `UIStore.spinning = false`, the spin button is enabled, the stop button is disabled. `HUDPresenter`'s click handler on `#spin` is live, waiting for user input. **Enter:** sets UI flags. **Exit:** nothing — idle holds no resources. ### spin Three things happen in `enter`, in this order: - `BalanceStore.debitBet()` — optimistic UI update, not authoritative. - `DataStore.clear()` — forget the previous round. - `reels.startSpin()` and `network.spin({ bet })` launch in parallel via `Promise.all`. The reels start rolling immediately; the network promise is awaited. When `network.spin` resolves, the response is written to `DataStore` and the FSM transitions to `stopSpin`. **Timing note:** the reels usually haven't finished their wind-up by the time the response arrives — which is fine. The reels keep rolling; the `stopSpin` phase is what lands them. ### stopSpin Reads `DataStore.grid` and `DataStore.teasingReels`. If the server directed teasers, they're passed to the engine first (`reels.setAnticipation(teasers)`) so the specified reels slow down before landing. Then `reels.stopWithResult(grid)` — this is where `pixi-reels` lands each reel on its target symbols in staggered sequence. `skip()` calls `reels.forceStop()`, which jumps every reel to its final position immediately. Used by Turbo / SuperTurbo speed and by slam-stop. No state mutates — skipping is purely visual. ### winShow Reads `DataStore.winlines` and `totalWin`. - If there are no winlines, transitions to `idle` immediately. - Otherwise: `reels.showWin(winlines)` (spotlight + per-symbol win animation), `BalanceStore.credit(totalWin)`, then `ctx.ticker.schedule(WIN_HOLD_MS, () => transition('idle'))` to pause on the win for 1.5 seconds before returning to idle. `skip()` cancels the hold and transitions immediately. `exit()` disposes the scheduled ticker handle — important if the FSM is torn down mid-phase (scene change, page unload). ## Key invariants - Exactly one phase is *in* at a time. `fsm.phase` returns its name. - Transitions are explicit: `await fsm.transition('next')`. No flag-based branching. - Phases don't call each other. Only the FSM transitions. - The FSM is the only writer to stores. Presenters and views read. ## Adding a phase See [Add a phase](/guides/add-phase/) for the end-to-end steps. ## Nested FSMs (bonus games) Bonus games — "pick three chests", mini-slot, wheel-spin — nest as an inner FSM inside a parent phase. Same `Phase` interface, same rules, own lifecycle. See [Add a bonus game](/guides/bonus/). [ [diagram] Edit this page on GitHub ](https://github.com/schmooky/slotplate/edit/main/docs/src/pages/architecture/spin-lifecycle.astro) docs/src/pages/architecture/spin-lifecycle.astro --- # Changelog URL: https://slotplate.dev/changelog/ > Every release of create-slotplate. Generated by changesets, sourced from CHANGELOG.md. Releases # Changelog Every published version of `create-slotplate`, newest first. Currently shipping v0.1.0. Releases are managed by [Changesets](https://github.com/changesets/changesets) — see [how it works](https://github.com/schmooky/slotplate/blob/main/.changeset/README.md). No releases yet. The first release will appear here as soon as a changeset lands on `main`. Watch [npm](https://www.npmjs.com/package/create-slotplate) and [GitHub Releases](https://github.com/schmooky/slotplate/releases) for notifications. Spotted something? [Edit the source](https://github.com/schmooky/slotplate/blob/main/CHANGELOG.md) or [open an issue](https://github.com/schmooky/slotplate/issues/new). --- # The composition root URL: https://slotplate.dev/concepts/composition-root/ > One file that wires everything. Grepable. Replaceable. Concept # The composition root One file that wires everything. Grepable. Replaceable. `src/composition.ts` is the only file in the project that says `new Foo()` for a service. Every other file receives its dependencies. ## The shape ``` export async function compose(host, hud) { const container = new Container(); container.register('stores', () => new RootStore()); container.register('ticker', () => new GsapTicker()); container.register('network', () => new MockNetworkManager(...)); // ... await scene.init(host); const fsm = new FSM({ stores, ticker, network, reels, hud }); fsm.register(new IdlePhase()); fsm.register(new SpinPhase()); // ... return { start: () => fsm.transition('idle'), dispose }; } ``` All `new` calls happen here. Phase handlers, presenters, and stores receive what they need via constructor arguments or the FSM context — they never reach for a global. ## Anti-patterns this kills - "Let me just import the singleton here." — No singleton exists. - "I'll `new MyService()` inside this helper." — The helper takes it as a parameter. - "The service locator will find it." — The locator is only populated here; elsewhere it's read-only. [diagram] The slotplate opinion There is exactly one composition root per app. `new` in a random component is a future bug. Also a future merge conflict, because "where do I wire this?" has one answer. [ [diagram] Edit this page on GitHub ](https://github.com/schmooky/slotplate/edit/main/docs/src/pages/concepts/composition-root.astro) docs/src/pages/concepts/composition-root.astro --- # Disposables URL: https://slotplate.dev/concepts/disposables/ > One cancellation primitive for everything. Concept # Disposables One cancellation primitive for everything. Every allocation — a ticker handle, a MobX reaction, an event listener, a Pixi resource — implements `Disposable` and is owned by a parent that tears it down. ``` export interface Disposable { dispose(): void; } ``` Phases dispose their tickers on `exit`. Scenes dispose their presenters on teardown. `DisposableBag` collects children so the parent doesn't have to remember handles individually. ## Rule of thumb If you write `const x = something.on(...)` or `const x = ticker.schedule(...)`, the return value has a `dispose` and the parent is responsible for calling it. ## Why A slot runs for hours. Tiny leaks — a callback here, a listener there — compound. Resource-leak bugs are usually invisible for the first week and lethal by the second. A consistent disposal contract turns "did anyone clean this up?" into "yes, its parent did, in `dispose()`." ## Idempotence `dispose` is safe to call twice. Implementations no-op after the first call. This lets parents dispose aggressively without tracking "already disposed" flags. [ [diagram] Edit this page on GitHub ](https://github.com/schmooky/slotplate/edit/main/docs/src/pages/concepts/disposables.astro) docs/src/pages/concepts/disposables.astro --- # Fail loud URL: https://slotplate.dev/concepts/fail-loud/ > Errors you see on day one are cheap. Concept # Fail loud Errors you see on day one are cheap. Throw when something is wrong. Never catch-and-ignore. Never fall back to a hidden default. Never silence the type system without comment. ## Forbidden ``` // ❌ Silent catch try { risky(); } catch {} // ❌ Silent fallback — now you never discover config drift const bet = config.defaultBet ?? 1; // ❌ Type lie without reason const x = response as any; ``` ## Allowed ``` // ✅ Throw with who/what/where if (!config.defaultBet) { throw new Error('[composition] missing defaultBet — set in src/config/gameConfig.ts'); } // ✅ Explicit, explained escape hatch // The spine-pixi-v8 types lag the runtime by a version. // Remove once https://github.com/.../issue-123 lands. const spine = instance as unknown as SpineWithMissingField; ``` ## Why Silent failures produce a worst-case pattern: the code looks fine in PR review, passes tests, ships — and starts misbehaving in week four, when the diff is cold and the person who wrote it has moved on. A thrown error in week one is a commit away from a fix. A silent divergence in week four is an incident. [diagram] The slotplate opinion Silent failures are the #1 way AI-written code rots in production. LLMs are trained on a lot of catch-and-continue. Your lint and your review must push back. [ [diagram] Edit this page on GitHub ](https://github.com/schmooky/slotplate/edit/main/docs/src/pages/concepts/fail-loud.astro) docs/src/pages/concepts/fail-loud.astro --- # Infrastructure (I/O) URL: https://slotplate.dev/concepts/infrastructure/ > Everything that talks to the outside world. Concept # Infrastructure (I/O) Everything that talks to the outside world. `src/infrastructure/` holds anything that touches the network, the platform clock, the file system, or the asset pipeline. ## What ships - `Ticker` (`timing.ts`) — GSAP-synced game time scheduling. - `NetworkManager` — spin requests. `MockNetworkManager` for dev; your RGS transport for prod. - `AssetLoader` — Pixi bundle loader facade. - `Analytics` — pluggable analytics transport. ## Rules - Called by `flow/` (phases orchestrate it) and `presenters/` (they need the ticker). - Never depends on `state/` or `view/` — those should run without it. - Keep transport-specific code in one file per service. Two RGS providers = two implementations of `NetworkManager`, picked in `composition.ts`. ## Why a layer, not scattered Keeping I/O in one folder means swapping a transport is a one-file change. It also gives you a clear place to add cross-cutting concerns: retry policy, request tracing, rate limiting, offline fallback. [ [diagram] Edit this page on GitHub ](https://github.com/schmooky/slotplate/edit/main/docs/src/pages/concepts/infrastructure.astro) docs/src/pages/concepts/infrastructure.astro --- # Presenters URL: https://slotplate.dev/concepts/presenters/ > State → view. Nothing more. Concept # Presenters State → view. Nothing more. A presenter is a thin class that: - Observes state (MobX `autorun` or reactions). - Translates state into view calls (`reelSet.setResult(...)`, DOM updates). - Exposes a narrow API the FSM can drive (`startSpin()`, `showWin()`). It does **not** decide *when*. A presenter with a `setTimeout` inside it is a presenter doing too much. ## The two slotplate ships - **`ReelsPresenter`** — wraps the reel engine (`pixi-reels`). Only file outside `view/` allowed to import it. Defines the `ReelsEngine` interface — swap implementations here. - **`HUDPresenter`** — binds MobX state to the plain DOM buttons in `index.html`. Swap for a React component if you want, as long as it keeps the same surface (`mount(root)`, `dispose()`). ## When to add a presenter Add a new presenter when you have a new view surface to bridge: - `WinlinePresenter` for an overlay that draws payline paths. - `BigWinPresenter` for a scene that dims the reels and counts up. - `SoundPresenter` if sound cues are driven by state (probably, for slots). ## The MVP name Yes this is Model-View-Presenter, roughly. Close cousin to MVVM. slotplate uses "presenter" because they tend to be thinner than a ViewModel — no two-way binding, just observable read and view write. [ [diagram] Edit this page on GitHub ](https://github.com/schmooky/slotplate/edit/main/docs/src/pages/concepts/presenters.astro) docs/src/pages/concepts/presenters.astro --- # Scenes URL: https://slotplate.dev/concepts/scenes/ > Pixi application lifecycle, one scene per screen. Concept # Scenes Pixi application lifecycle, one scene per screen. A scene owns a Pixi `Application` and the root of its display tree. slotplate ships one scene (`MainScene`). Games with splash screens or bonus games add more. ## Responsibilities - Initialize `Application` with the right resolution, antialias, background. - Sync GSAP to `app.ticker` via `syncGsapToPixi` — once, at boot. - Construct and own the `ReelsEngine`. Expose it via `createReelsEngine()`. - `dispose()` destroys the app and all children. ## Why scenes, not one root Splash, main, bonus, free-spin-intro — each has its own display tree, assets, and lifecycle. Rolling them into one `Application` leaks resources across phases. Separate scenes with clear mount/unmount contracts are the smaller error surface. [diagram] The slotplate opinion Scenes are where the Pixi-specific gnarl lives. Keep them thin — if a scene has more than ~150 lines of code that isn't asset loading, some of it probably belongs in a presenter or overlay. [ [diagram] Edit this page on GitHub ](https://github.com/schmooky/slotplate/edit/main/docs/src/pages/concepts/scenes.astro) docs/src/pages/concepts/scenes.astro --- # State (MobX stores) URL: https://slotplate.dev/concepts/state/ > Observable domain state — the read surface for presenters. Concept # State (MobX stores) Observable domain state — the read surface for presenters. slotplate uses MobX for state. One `RootStore` composes three domain stores: - `BalanceStore` — `balance`, `bet`, `lastWin` + actions. - `DataStore` — last server response: `grid`, `winlines`, `totalWin`, `teasingReels`. - `UIStore` — `spinEnabled`, `stopEnabled`, `spinning`, `speed`. ## Rules - Mutations go through `@action` methods only. No direct writes from outside. - Presenters observe via `autorun` or `reaction`. - Stores don't import from `view/`, `presenters/`, or `flow/`. - Stores don't call each other's actions. A phase orchestrates cross-store changes. ## Why MobX The alternatives that come up: Redux, Zustand, signals, raw React state. For slot games: - Redux is overkill — the ceremony tax shows up in every diff, and slot state is small enough that you don't benefit from time-travel debugging outside dev tools. - Zustand is fine — the deciding factor for MobX was the cleaner story for cross-cutting reactions (`reaction` with `fireImmediately: false`). - Signals are promising but ecosystem maturity isn't there yet for this kind of app. - React state alone doesn't work because the Pixi view isn't in React's tree. [diagram] The slotplate opinion You don't need Redux. MobX observables on a RootStore cover every slot I've shipped, and the ceremony tax of Redux shows up in every diff. [ [diagram] Edit this page on GitHub ](https://github.com/schmooky/slotplate/edit/main/docs/src/pages/concepts/state.astro) docs/src/pages/concepts/state.astro --- # Timing (no setTimeout) URL: https://slotplate.dev/concepts/timing/ > Why game code never calls setTimeout — and what it calls instead. Concept # Timing (no setTimeout) Why game code never calls setTimeout — and what it calls instead. Principle #2: game code never calls `setTimeout` or `setInterval`. All scheduled work goes through `Ticker.schedule(ms, fn)` from `src/infrastructure/timing.ts`. ## The bug [diagram] setTimeout keeps firing in hidden tabs. Pixi's ticker pauses. The two desync. The user switches tabs. Pixi's ticker pauses — `requestAnimationFrame` is throttled or suspended by the browser. Good: the game freezes in place. Except `setTimeout` doesn't pause. That win-show callback you queued with `setTimeout(..., 1500)` fires while the reels haven't moved. Now the balance updates before the animation plays. When the user switches back, you get a ghost animation and (on a cascade game) a phase mismatch that crashes the FSM. ## The fix `gsap.delayedCall` runs on GSAP's ticker. slotplate syncs that ticker to `app.ticker` at boot: ``` // src/infrastructure/timing.ts export function syncGsapToPixi(pixiTicker) { gsap.ticker.remove(gsap.updateRoot); pixiTicker.add(() => gsap.updateRoot(performance.now() / 1000)); } ``` Now GSAP pauses when Pixi pauses. `Ticker.schedule` wraps `gsap.delayedCall` and returns a `Disposable`: ``` schedule(delayMs: number, fn: () => void): Disposable { const tween = gsap.delayedCall(delayMs / 1000, fn); return { dispose: () => tween.kill() }; } ``` ## Usage ``` // In a phase's enter(): this.cancel = ctx.ticker.schedule(1500, () => { ctx.fsm.transition('idle'); }); // In the phase's exit(): this.cancel?.dispose(); ``` ## Enforcement `biome.json` declares `setTimeout` and `setInterval` as restricted globals with a message pointing at `timing.ts`. Lint will fail any PR that adds them. ## What about Promises? - Promises that resolve on ticker events (reel landed, animation done) — **fine**. - Promises that resolve on wall-clock delay via `setTimeout` — **same bug as raw setTimeout**. Use the ticker. ## What about network timeouts? `AbortController` + `fetch`'s built-in timeout are infrastructure-layer concerns. They deliberately run on wall-clock time — you want the request to fail after N seconds of real time even if the tab is hidden. The rule is about *game* timing (sequences, animations, debounces), not network. [diagram] The slotplate opinion Game code never calls setTimeout. Not once. Not "just this time." Use the ticker. Future you will thank present you the first time a tester reports a double-spin from a backgrounded tab. [ [diagram] Edit this page on GitHub ](https://github.com/schmooky/slotplate/edit/main/docs/src/pages/concepts/timing.astro) docs/src/pages/concepts/timing.astro --- # View (pixi-reels) URL: https://slotplate.dev/concepts/view/ > The rendering surface, behind one presenter. Concept # View (pixi-reels) The rendering surface, behind one presenter. `src/view/` is the only layer (besides `presenters/`) that imports `pixi.js` or `pixi-reels`. Everything else talks to the view through `ReelsPresenter`. ## Contents - `scenes/MainScene.ts` — Pixi app lifecycle, creates the reel engine. - `symbols/SpriteReelSymbol.ts` — default symbol class using Pixi sprites + GSAP. - `symbols/SpineReelSymbol.ts` — optional Spine wrapper (stub; enable with peer dep). - `symbols/SymbolAnimationPlayer.ts` — companion for Spine symbols (bonbon-hw pattern). - `symbols/SymbolFactory.ts` — picks sprite vs spine per symbol id. - `overlays/` — payline renderers, bigwin, anticipation glows. ## Wiring pixi-reels `MainScene.createReelsEngine()` is where you call `new ReelSetBuilder(app)`. The builder's fluent API lets you configure columns, rows, symbols, middleware, and spotlight in one chain. See the [Add a symbol](/guides/add-symbol/) guide for concrete wiring. ## What goes in view vs presenter - **view/**: Pixi objects, display tree, rendering, texture/animation lifecycle. - **presenter/**: "when state changes, call these view methods." The test: can a presenter's API be re-implemented over a totally different renderer (Phaser, Canvas2D, Three.js)? If yes, the boundary is in the right place. [ [diagram] Edit this page on GitHub ](https://github.com/schmooky/slotplate/edit/main/docs/src/pages/concepts/view.astro) docs/src/pages/concepts/view.astro --- # What is slotplate? URL: https://slotplate.dev/docs/ > An opinionated client boilerplate for slot games built on pixi-reels. Start here # What is slotplate? An opinionated client boilerplate for slot games built on pixi-reels. slotplate is a **client**. It is the presentation and flow layer of a slot game: it renders reels, runs the FSM that orchestrates a spin, animates wins, drives the HUD, plays sound. It is not a game server. It does not compute wins, does not hold the paytable, does not run the RNG, does not certify RTP. It's shaped deliberately. Every choice here is a bet that a specific failure mode will not happen to your codebase: no silent timers, no bespoke reel engine, no paytable in TypeScript that math never audits, no two stores writing to each other. ## What ships when you run `npm create slotplate` - TypeScript project with Vite, MobX, GSAP, and [pixi-reels](https://github.com/schmooky/pixi-reels) wired. - Composition root, tiny DI container, MobX `RootStore` (Balance, Data, UI). - A finite state machine with `idle`, `spin`, `stopSpin`, `winShow` phases — one file each. - `ReelsPresenter` as the sole bridge to `pixi-reels`. Biome prevents other layers from importing it. - `GsapTicker` — all scheduled calls go through it. Biome rejects raw `setTimeout`. - Sprite + optional Spine symbol wrappers. The Spine one follows the bonbon-hw `SymbolAnimationPlayer` pattern. - `MockNetworkManager` producing plausible `SpinResponse`s — swap for your RGS transport in one file. - `CLAUDE.md`, `AGENTS.md`, `.claude/commands/` — agent rules committed in the template. - Vitest with real phase + store tests. No canvas required. ## The shape [diagram] One layer per folder. Downward imports only. The domain folder holds only the wire types that cross the network boundary. ## What slotplate is NOT - A game server. Your RGS is the server. If you don't have one, build one separately. - A math tool. RTP harnesses, volatility analysis, and paytable certification live on the server. - A UI framework. The HUD is plain DOM. Swap in React / Vue / Svelte as needed. - A monorepo template. One game per repo — monorepos are a cost that most slot teams don't need. ## Who it's for - Teams starting a new slot client and want the architecture decision made for them. - Existing slot codebases that want a reference shape to refactor toward. - Anyone editing slot code with an AI agent — the Biome rules + CLAUDE.md keep the agent on-spec. [diagram] The slotplate opinion Opinionated boilerplates are load-bearing. "Unopinionated" = "we left the hard choices for you." slotplate makes the choices that, in practice, you'd make six months in anyway. ## Next - [Quickstart](/docs/quickstart/) — zero to running client. - [10-minute tour](/docs/tour/) — guided walk through the generated code. - [Architecture](/architecture/) — diagrams for every concept. - [The 10 principles](/docs/principles/) — the rules. [ [diagram] Edit this page on GitHub ](https://github.com/schmooky/slotplate/edit/main/docs/src/pages/docs.astro) docs/src/pages/docs.astro --- # Using slotplate with AI agents URL: https://slotplate.dev/docs/agents/ > Point any coding agent at your slotplate project and keep it on-spec. AI-ready # Using slotplate with AI agents Point any coding agent at your slotplate project and keep it on-spec. slotplate is designed to be edited by AI agents as a first-class case. Every principle has a lint rule. Every common task has a slash command. Every concept has a docs page the agent can fetch. ## The feeds - [`/llms.txt`](/llms.txt) — terse machine-skimmable index, one line per doc page. Good for routing queries. - [`/llms-full.txt`](/llms-full.txt) — the entire manual concatenated. One fetch, everything. Paste into your agent at session start. Both regenerate on docs build. Point your agent at `https://slotplate.dev/llms-full.txt` once per session and it has the full manual. ## Claude Code `CLAUDE.md` is committed to the template root. It codifies the 10 principles as project rules. Slash commands live in `.claude/commands/`: ``` /add-symbol Cherry # scaffolds a new ReelSymbol + tests + registration /add-phase BonusSpin # scaffolds a new FSM phase ``` Open the project in Claude Code and the rules apply automatically. `.claude/settings.json` pre-allows the common commands (`pnpm`, `git status`, etc.) so you're not clicking "approve" for every step. ## Cursor, Codex, Aider, Continue `AGENTS.md` mirrors the principles for non-Claude agents. Point your agent at: - `AGENTS.md` in the project root, or - `https://slotplate.dev/llms-full.txt` for the full manual. Cursor users: symlink `AGENTS.md` → `.cursor/rules/main.md` and it loads automatically as a rule. ## Recommended agent prompt ``` You are editing a slotplate project — an opinionated slot-game CLIENT boilerplate on pixi-reels. This is a client, not a server: no win evaluation, no paytable, no RTP here. The server returns resolved results; the client renders them. Read CLAUDE.md before making any changes. The 10 principles listed there are enforced by Biome; diffs that violate them will not pass. Key rules: - src/domain/ holds WIRE TYPES only — no evaluators, no pure math. - Use Ticker.schedule, never setTimeout. - Only src/view/ and src/presenters/ import pixi-reels. - Every FSM phase is a file in src/flow/phases/. - State mutates through MobX @action methods only. For anything not covered here, fetch https://slotplate.dev/llms-full.txt. ``` ## What the agent rules prevent - Creating a `src/domain/winEval.ts` because the agent "saw it in another slot repo." - Using `setTimeout` for win-display hold timing. - Importing `pixi.js` from a phase or store. - Silent `catch ` around network errors. - `as any` to silence the type system without a comment. - Writing a paytable JSON to "make the mock more realistic." These are the failure modes of AI-edited slot code. slotplate encodes each as a lint rule, a docs page, and a principle the agent reads at session start. [diagram] The slotplate opinion An agent without project rules will drift toward average-repo patterns — service locators in every file, `setTimeout` for animations, `as any` to silence TS. slotplate's Biome config catches many of these, but the rules are what prevent them from being written. Agent rules are not a replacement for lint — they're the spec. ## Building on top Add your own slash commands in `.claude/commands/`. Mirror them in `AGENTS.md` so non-Claude agents know about them. The pattern: - Markdown file with a frontmatter `description` and `argument-hint`. - Body is a checklist the agent works through. - End with "run `pnpm typecheck && pnpm test && pnpm lint`" to loop on green. [ [diagram] Edit this page on GitHub ](https://github.com/schmooky/slotplate/edit/main/docs/src/pages/docs/agents.astro) docs/src/pages/docs/agents.astro --- # Opinions URL: https://slotplate.dev/docs/opinions/ > Every opinionated call slotplate makes, collected so you can decide if we're right for your team. The positions we're taking # Opinions Every opinionated call slotplate makes, collected so you can decide if we're right for your team. Many of these show up inline throughout the docs. This page exists as the one-place-to-share version. If one of these stops you cold, slotplate is probably not for you — and that's fine, our opinions being wrong for you doesn't make them wrong in general. ## Architecture [diagram] On state management You don't need Redux. MobX observables on a RootStore cover every slot I've shipped, and the ceremony tax of Redux shows up in every diff. If you want time-travel debugging, use MobX dev tools. [diagram] On reel engines Never build a bespoke reel renderer. You will rebuild pooling, wrapping, anticipation, and Spine integration — badly — and then spend a year fixing the bugs pixi-reels already fixed. [diagram] On FSMs Slot games are finite state machines. Pretending otherwise produces spaghetti of `isSpinning`, `canStop`, `showingWin`, `awaitingResponse`. One FSM with named phases is ten times easier to read and correct by construction. [diagram] On composition There is exactly one composition root per app. `new` in a random component is a future bug. ## Client vs server [diagram] On client-side evaluation The client never evaluates wins. Not as a fallback. Not "for offline mode." Not "for faster response." Two evaluators means two sources of truth, and one is wrong. [diagram] On RTP on the client There is no RTP harness in slotplate. That's a server concern. The client's job is fidelity of replay, not correctness of math. ## Timing [diagram] On setTimeout Game code never calls `setTimeout`. Not once. Not "just this time." Use the ticker. Future you will thank present you the first time a tester reports a double-spin from a backgrounded tab. [diagram] On GSAP GSAP is fine. It's battle-tested and its easing curves are what designers expect. Just sync its root ticker to Pixi's — otherwise it runs on a separate clock and all your nice invariants break. ## Errors [diagram] On silent failures No silent catches. An error you see on day one is cheap. A silent divergence discovered in week four is not. [diagram] On fallbacks Do not fall back to a default when a config is missing. Throw with a message that names what's missing and who should have provided it. ## Scope [diagram] On premature abstraction Three similar lines is better than a premature abstraction. Build the second case before you build the interface. [diagram] On PR size One logical change per PR. If the description needs "and" twice, split it. Small PRs ship; large ones get reverted. ## Monorepos [diagram] On monorepos Monorepos are a cost. Pay it only when you need to publish multiple packages or have multiple apps sharing code. A single slot doesn't. ## AI agents [diagram] On agent-ready docs Docs exist to be consumed. The llms.txt convention is five minutes of work and makes your project legible to tools that will increasingly read it. There is no downside. [diagram] On committing agent rules Commit `CLAUDE.md`. Commit `.claude/commands/`. An agent without project-specific rules will drift; with them, it stays on-spec. [ [diagram] Edit this page on GitHub ](https://github.com/schmooky/slotplate/edit/main/docs/src/pages/docs/opinions.astro) docs/src/pages/docs/opinions.astro --- # The 10 Principles URL: https://slotplate.dev/docs/principles/ > Hard rules. Break them and the diff should not pass review. Rules # The 10 Principles Hard rules. Break them and the diff should not pass review. These aren't suggestions. They're the load-bearing rules the architecture rests on. Every layer, every file, every lint rule in slotplate points back to one of these. Where possible we encode the rule in Biome so the codebase itself rejects violations. ## 1. The client does not evaluate The paytable lives on the server. The win evaluator lives on the server. The RNG and the reelstrips live on the server. The client sends a `SpinRequest` and receives a `SpinResponse` with `grid`, `winlines`, and `totalWin` already resolved. The client renders. That is the entire loop. [diagram] No parallel math, no client-side fallback evaluator, no offline win recomputation. The server is authoritative. **Why:** two evaluators mean two sources of truth. One of them is wrong. Finding out which in production, at 3am, with real money moving, is the bug you do not want. **Enforced by:** the `domain/` folder has no `evaluateWin`. The template has no paytable JSON. If an agent tries to generate one, it has no place to wire it. ## 2. No `setTimeout` in game code All scheduled work goes through `Ticker.schedule(ms, fn)` from `src/infrastructure/timing.ts`. Under the hood it's `gsap.delayedCall` on a GSAP ticker that's synced to Pixi's `app.ticker`. [diagram] setTimeout runs on the platform clock. When Pixi pauses in a hidden tab, setTimeout keeps firing. That's a bug factory. **Enforced by:** Biome's `no-restricted-globals` rejects `setTimeout` and `setInterval` in game code, with a message pointing at the Ticker service. ## 3. Presenters don't decide *when* A presenter observes state, translates it into view calls, and exposes a narrow API for the FSM to drive. It does not hold timers. It does not sequence animations across multiple frames of game state. That's the phase's job. **Why:** presenters are dumb — easy to test, easy to replace. The moment a presenter owns timing, it becomes a miniature state machine, and you have two of them. Two state machines are four times harder to reason about than one. ## 4. State mutates via actions only MobX `@action`-wrapped methods are the only mutators. Views read; presenters read; phases call actions. A view or presenter that writes directly to a store is a bug. ## 5. Disposable everything Any class that allocates a ticker handle, event listener, or Pixi resource implements `Disposable` and is torn down by its parent scene. Scene teardown cascades. **Why:** slots run for hours. Tiny leaks compound. Resource-leak bugs are usually invisible for the first week and lethal by the second. ## 6. Fail loud No silent `catch `. No `as any` without a one-line comment explaining *why this is the least-bad option*. No "fallback to a hidden default" that masks a missing config. Throw with a message that names what's missing and who should have provided it. [diagram] The slotplate opinion Silent failures are the #1 way AI-written code rots in production. LLMs are trained on a lot of catch-and-continue. Your lint and your review must push back. ## 7. One composition root `src/composition.ts` is the only file that instantiates services. Everything else receives dependencies. If you find yourself writing `new SomeService(...)` anywhere else, stop — pass it as a parameter instead. ## 8. `pixi-reels` lives behind one presenter Only files in `src/view/` and `src/presenters/` may import `pixi-reels` or `pixi.js`. The rest of the app talks to the view through `ReelsPresenter`. **Enforced by:** Biome's `no-restricted-imports` with a pattern that catches any import of pixi-anything outside those two folders. ## 9. Every phase is a file New FSM states go in `src/flow/phases/` and register in `src/composition.ts`. Phases implement `Phase` with `enter`, optional `skip`, optional `exit`. They're testable without a canvas — see `tests/flow/SpinPhase.test.ts` for the shape. ## 10. Docs are for agents too When you add a concept, add a docs page. `/llms.txt` and `/llms-full.txt` regenerate on build. Agents read docs the same way humans do — make sure yours exist. [diagram] The slotplate opinion If your repo isn't readable by an AI agent today, it won't be maintainable by one tomorrow — and that's the cheap labor you'll want access to. Writing docs and rules for agents is writing docs and rules for your future teammates. [ [diagram] Edit this page on GitHub ](https://github.com/schmooky/slotplate/edit/main/docs/src/pages/docs/principles.astro) docs/src/pages/docs/principles.astro --- # Quickstart URL: https://slotplate.dev/docs/quickstart/ > From zero to a running client in two commands. Start here # Quickstart From zero to a running client in two commands. ## Scaffold ``` npm create slotplate my-slot cd my-slot pnpm install pnpm dev ``` Open [http://localhost:5173](http://localhost:5173). Click **Spin**. You're watching the FSM run end-to-end: HUD click → transition → bet debit → parallel reels-start + network — then result playback → winShow → idle. The reels themselves are a *stub*. slotplate ships with a `StubReelsEngine` so the FSM can boot before your assets are loaded. You'll swap it for a real `ReelSetBuilder` call in `src/view/scenes/MainScene.ts` once you have art. ## What just booted The composition root (`src/composition.ts`) wired: - A Pixi `Application` mounted in `#app`. - A MobX `RootStore` holding `balance`, `data`, `ui`. - The `FSM` with phases `idle`, `spin`, `stopSpin`, `winShow`. - A `HUDPresenter` binding the plain HTML buttons to FSM transitions. - A `MockNetworkManager` returning a random grid after 300 ms. - A `GsapTicker` synced to Pixi's ticker — no `setTimeout` leaks. [diagram] The slotplate opinion The scaffolded client boots on empty assets by design. Don't wait until you have final art to verify the flow. Get the FSM green first; swap in the view second. ## Next checkpoints - **Read `CLAUDE.md`** in the project root. The principles there are hard rules, and Biome enforces them. You'll save yourself a lint-failure commit. - **Wire pixi-reels properly.** Replace `StubReelsEngine` in `src/view/scenes/MainScene.ts` with a real `ReelSetBuilder` call. See the [Add a symbol](/guides/add-symbol/) guide for the shape. - **Swap the network transport.** `MockNetworkManager` is for dev only. Your real RGS goes in `src/infrastructure/NetworkManager.ts`. Keep the interface; replace the class. See [Swap the network transport](/guides/swap-network/). - **Tune the HUD.** The default HUD is three DOM elements. Swap in React / Vue / Svelte when you're ready — the presenter contract stays the same. - **Run the tests.** `pnpm test`. The scaffolded project ships with real tests for the FSM and stores. ## Running commands ``` pnpm dev # vite on :5173 pnpm typecheck # tsc --noEmit pnpm test # vitest pnpm lint # enforces the principles pnpm build # static bundle in dist/ ``` ## If something breaks - Build the project first with `pnpm install && pnpm typecheck` — catches dep mismatches. - Check the browser console. `__PIXI_REELS_DEBUG.log()` shows the reel state. - Use the `/loop` of TypeScript → lint → test as your editing rhythm. [ [diagram] Edit this page on GitHub ](https://github.com/schmooky/slotplate/edit/main/docs/src/pages/docs/quickstart.astro) docs/src/pages/docs/quickstart.astro --- # Behavior scenarios + test bridge URL: https://slotplate.dev/docs/testing/ > Drive the live game from Playwright. Script every server response, simulate offline, click Pixi nodes by label, record sessions, and replay server logs. Testing # Behavior scenarios + test bridge Drive the live game from Playwright. Script every server response, simulate offline, click Pixi nodes by label, record sessions, and replay server logs. ## TL;DR — write a scenario in 30 seconds ``` // tests/scenarios/my-thing.spec.ts import { expect, test } from './slot-fixture'; test('big win credits the wallet', async ({ slot }) => { await slot.boot({ startingBalance: 100, bet: 1 }); await slot.queueWin(SOME_GRID, 250); await slot.spin(); await slot.expectBalance(349); await slot.expectLastWin(250); }); ``` Run: `pnpm test:e2e`. UI mode: `pnpm test:e2e:ui`. Step debugger: `pnpm test:e2e:debug`. ## How it works ``` ┌────────────────────────────────────────────────────────────────────┐ │ Playwright spec (test runner process, Node) │ │ │ │ const { slot } = test; │ │ await slot.queueWin(grid, 50); ←── typed wrapper around evaluate │ await slot.spin(); │ │ │ └──────────────────────────┬─────────────────────────────────────────┘ │ page.evaluate(fn) — JSON across the wire ▼ ┌────────────────────────────────────────────────────────────────────┐ │ Slotplate page (Chromium, served from `pnpm dev`) │ │ │ │ window.__SLOTPLATE_TEST = TestBridge { │ │ queueWin(grid, totalWin) → ScriptableMockNetwork.queueSpin │ │ spin() → fsm.transition('spin') │ │ state() → reads MobX stores │ │ clickPixi('spin') → emits pointertap on labeled node │ │ pauseTicker() → app.ticker.stop() + gsap.sleep() │ │ } │ │ │ │ composition.ts (when test= URL param is present): │ │ network = ScriptableMockNetwork (instead of MockNetworkManager)│ │ ticker = InstantTicker (instead of GsapTicker) │ │ reels = StubReelsEngine (instead of pixi-reels) │ │ │ └────────────────────────────────────────────────────────────────────┘ ``` Test mode is **opt-in via URL param**: `?test=` swaps three things in the composition root: In productionIn test mode (`?test=...`)Why`MockNetworkManager``ScriptableMockNetwork`Tests script every server response.`GsapTicker``InstantTicker`Win-hold + delays fire on next microtask.pixi-reels engine`StubReelsEngine`No 2-3s spin animation per round.(none)`window.__SLOTPLATE_TEST` exposedBridge for the test runner. Production builds **never** instantiate `TestBridge` — even if the URL param were forged, `?test=0` / `off` / `false` explicitly disable it, and you can build with `VITE_TEST_BRIDGE=0` to compile it out. ## The fixture: `slot` `tests/scenarios/slot-fixture.ts` exports a Playwright `test` extended with a single fixture, `slot: SlotDriver`. Every scenario gets a fresh page and a driver. ### Boot ``` await slot.boot({ startingBalance: 100, // optional; resets the mock wallet + store bet: 1, // optional; sets the bet store testId: 'my-scenario', // optional; defaults to slugified test title keepSplash: false, // optional; true to assert on intro screen }); ``` The boot sequence — same as production except the network is scripted: 1. Navigate to `/?test=`. 2. Wait for `window.__SLOTPLATE_TEST` to mount. 3. Wait for `bootStage === 'ready'` (assets + session resolved). 4. Auto-dismiss the splash (unless `keepSplash: true`). 5. Reset wallet to `startingBalance` and set the bet. 6. The FSM is in `idle`. The test can now drive. ### State ``` const snap = await slot.state(); // { // phase: 'idle' | 'spin' | 'stopSpin' | 'winShow', // spinning, bet, balance, lastWin, totalWin, // bootStage, loadProgress, loadError, // autospinRemaining, // pendingNetworkRequests, queuedSpins, // } await slot.expectPhase('idle'); await slot.expectBalance(99); await slot.expectLastWin(5); await slot.expectGrid(grid); ``` ### Programming the server ``` await slot.queueLoss(grid); // bet debits, no win await slot.queueWin(grid, 50); // bet debits, +50 credited await slot.queueWin(grid, 50, [winline]); // explicit winlines for spotlight await slot.queueError('RGS_TIMEOUT'); // next spin rejects await slot.queueError('SLOW_DOWN', 1000); // … after a 1s delay // Advanced: full SpinResponse shape await slot.queueSpin({ kind: 'response', response: { grid, totalWin: 5, winlines, balance: 104, teasingReels: [3, 4] }, delayMs: 200, }); ``` Spins fire FIFO. If a test calls `spin()` without queueing, the mock returns a deterministic no-win response so the happy path "just works". ### Connection state ``` await slot.simulateOffline(); // pending + future requests hang await slot.startSpin(); // FSM enters 'spin', request hangs expect((await slot.state()).pendingNetworkRequests).toBe(1); await slot.simulateOnline(); // queued requests now flow await slot.waitForPhase('idle'); ``` Useful for testing reconnection banners, retry UIs, and round-recovery paths. ### Actions ``` await slot.spin(); // through the bridge — synchronous transition await slot.startSpin(); // kick off, don't await await slot.skipPhase(); // fsm.skip() — for stopSpin/winShow await slot.recoverFromError(); // force-idle after a network-rejected spin await slot.setBet(2); await slot.startAutospin(10); await slot.stopAutospin(); await slot.pressKey('Space'); // hotkey via Playwright keyboard ``` ### Pixi-rendered HUD: `clickPixi(label)` The HUD in this template is **inside the Pixi canvas** (`view/hud/`), not the DOM. There's no `data-testid="spin"` element to locate. Instead, every interactive Pixi `Container` sets `.label = ''` and tests interact via: ``` await slot.clickPixi('spin'); // emits pointertap on the labeled Container await slot.clickPixi('autoplay'); await slot.clickPixi('bet:plus'); await slot.clickPixi('bet:minus'); const bounds = await slot.pixiBounds('spin'); // { x, y, width, height, centerX, centerY, visible } const labels = await slot.pixiLabels(); // every labeled node, for debugging ``` **Built-in labels** (asserted by `tests/scenarios/pixi-coverage.spec.ts`): LabelWhat`background`The full-viewport tiled BG layer.`reels-frame`The adaptive reel container.`hud`The HUD layer (parent of all controls).`spin`Spin / Stop button.`autoplay`Autoplay toggle.`bet`Bet stepper container.`bet:plus`Bet + button.`bet:minus`Bet − button.`balance`Balance chip.`win`Win counter chip. To add a label, set it on the Pixi `Container` constructor: ``` constructor(...) { super(); this.label = 'my-control'; // pixi-test-label // ... } ``` > **Pitfall:** Pixi v8's `Container.label: string` collides with anything you also call `label`. Inside HUD controls, the `Text` field is named `labelText` to avoid shadowing. If you see `TypeError: this.label.anchor.set is not a function`, you've shadowed it again — rename your Text field. ### Ticker control (for screenshots) ``` await slot.pauseTicker(); // app.ticker.stop() + gsap.ticker.sleep() const buf = await page.screenshot(); await slot.tickFrames(3); // step 3 frames forward while paused await slot.resumeTicker(); ``` Pixi rendering and GSAP both freeze. The FSM keeps moving (it's microtask-driven), so you can still wait for phase transitions. Use this to grab tear-free screenshots mid-round. ### Test identification ``` await slot.boot({ testId: 'big-win-replay' }); expect(await slot.testId()).toBe('big-win-replay'); ``` The id from `?test=` is exposed on the bridge so dev tools, log lines, and CI artifacts can correlate failures back to the scenario. ## What's automatically captured The fixture installs two listeners on every test page: ``` page.on('console', m => { if (m.type() === 'error') consoleErrors.push(m.text()); }); page.on('pageerror', err => pageErrors.push(`${err.name}: ${err.message}`)); ``` Any uncaught error or `console.error` — including TypeScript-runtime errors deep inside Pixi handlers — is **annotated** on the test result and **fails the test loudly** at teardown. Without this, an error in a click handler is swallowed by the canvas and the test fails with a mystery timeout. What you'd see in the report: ``` Error: page errors during test: TypeError: this.label.anchor.set is not a function at new SpinButton (.../SpinButton.ts:16:23) ``` ## File layout ``` template/ src/ testing/ TestBridge.ts ← Window-exposed remote control. InstantTicker.ts ← Microtask-pacing replacement for GsapTicker. StubReelsEngine.ts ← No-op replacement for pixi-reels in test mode. index.ts ← `isTestModeEnabled()` + barrel. infrastructure/network/ ScriptableMockNetwork.ts ← FIFO-queue NetworkManager. composition.ts ← Wires the test-mode swaps. tests/ flow/ ← Vitest unit tests (no canvas). balance-contract.test.ts ← Pins SpinResponse.balance == post-win. SpinPhase.test.ts scenarios/ ← Playwright behavior tests. slot-fixture.ts ← The `slot` fixture. spin-flow.spec.ts network-failure.spec.ts input.spec.ts autospin.spec.ts pixi-coverage.spec.ts full-boot.spec.ts screenshots/ ← Playwright pixel-diff suite (separate config). playwright.config.ts ← Screenshot matrix (16 viewports). playwright.scenarios.config.ts← Scenario suite (single Chromium). ``` ## Common scenarios ### The full boot flow ``` test('full boot — assets → intro → idle → spin', async ({ slot, page }) => { await slot.boot({ keepSplash: true }); await expect(page.locator('[data-testid="splash"]')).toBeVisible(); await page.evaluate(() => window.__SLOTPLATE_TEST!.tapToStart()); await slot.expectPhase('idle'); await slot.queueLoss(GRID); await slot.clickSpin(); await slot.waitForPhase('idle'); }); ``` ### Network drop mid-spin → reconnect ``` test('connection drop leaves the spin pending until online', async ({ slot }) => { await slot.boot({ startingBalance: 100, bet: 1 }); await slot.queueWin(GRID, 7); await slot.simulateOffline(); await slot.startSpin(); expect((await slot.state()).pendingNetworkRequests).toBe(1); await slot.simulateOnline(); await slot.waitForPhase('idle'); await slot.expectBalance(106); }); ``` ### Server error → recovery ``` test('server error rejects, then we recover and play again', async ({ slot }) => { await slot.boot({ startingBalance: 100, bet: 1 }); await slot.queueError('RGS_TIMEOUT'); await slot.spin().catch(() => { /* expected */ }); await slot.recoverFromError(); await slot.queueLoss(GRID); await slot.spin(); }); ``` ### Asserting on the Pixi canvas ``` test('Spin button is positioned and clickable', async ({ slot }) => { await slot.boot(); const bounds = await slot.pixiBounds('spin'); expect(bounds!.visible).toBe(true); expect(bounds!.width).toBeGreaterThan(0); await slot.clickPixi('spin'); }); ``` ### Screenshot mid-round ``` test('mid-round screenshot is deterministic', async ({ slot, page }) => { await slot.boot(); await slot.queueLoss(GRID); await slot.pauseTicker(); await slot.clickSpin(); await slot.waitForPhase('idle'); await expect(page).toHaveScreenshot('mid-round.png'); await slot.resumeTicker(); }); ``` ## Adding a new test bridge method 1. Method on `TestBridge` in `src/testing/TestBridge.ts`. 2. Add to the `Window['__SLOTPLATE_TEST']` declaration in `slot-fixture.ts`. 3. Wrap it on `SlotDriver` so specs are typed without `page.evaluate`. That's it — no composition changes needed. ## Why **not** Cypress / Puppeteer / etc. - Playwright trace viewer beats every other tool for "what happened in the browser at second 4.7". - First-class iframes, multi-context, mobile emulation, and parallel sharding for free. - The webkit/firefox engines are one config flag away if a customer asks for cross-browser screenshots. We use Playwright for **both** scenarios and screenshots — same fixture pattern, same trace tooling. ## QA cheat sheet ``` // Boot await slot.boot({ startingBalance: 100, bet: 1 }); await slot.boot({ keepSplash: true }); // assert on the intro screen await slot.boot({ testId: 'my-bug-repro' }); // override URL test id // Read state const s = await slot.state(); // phase, balance, lastWin, lastRoundMs, language, ... await slot.grid(); // current displayed grid await slot.history(); // network call log await slot.fsmTransitions(); // [{ to, at, prevDurationMs }, ...] await slot.a11yTree(); // labeled Pixi nodes with bounds + visibility await slot.dumpAll(); // everything in one JSON blob await slot.testId(); // the ?test= from the URL // Script the server (FIFO) await slot.queueLoss(grid); await slot.queueWin(grid, totalWin); await slot.queueError('RGS_TIMEOUT', 1000); await slot.queueSpin({ kind: 'response', response: full, delayMs: 200 }); await slot.simulateOffline(); /* ... */ await slot.simulateOnline(); // Replay a server log import log from './fixtures/repro.json'; await slot.replay(log); // queues + runs the entire tape // Drive the FSM / wallet await slot.spin(); // round complete, await idle await slot.startSpin(); // kick off, don't wait await slot.skipPhase(); // fsm.skip() await slot.recoverFromError(); // bring FSM back to idle after a network reject await slot.startAutospin(10); await slot.stopAutospin(); await slot.setBet(2); await slot.simulateLowBalance(0); // Pixi-rendered HUD await slot.clickPixi('spin'); await slot.clickPixi('bet:plus'); await slot.pixiBounds('balance'); // { x, y, w, h, centerX, centerY, visible } await slot.pixiLabels(); // UI actions await slot.toggleSound(); await slot.setLanguage('de'); await slot.openMenu('paytable'); await slot.closeMenu(); await slot.pressKey('Space'); // Assertions await slot.expectPhase('idle'); await slot.expectBalance(99); await slot.expectLastWin(0); await slot.expectGrid(grid); await slot.expectLastRoundFasterThan(100); // catches real-time-animation leaks await slot.expectSpinButtonEnabled(true); // Animation control (deterministic screenshots) await slot.pauseTicker(); await page.screenshot({ path: 'mid-round.png' }); await slot.tickFrames(3); await slot.resumeTicker(); // Locale matrix forEachLocale(['en', 'de', 'fr'], (lang) => { test(`spin works in ${lang}`, async ({ slot }) => { await slot.boot({ testId: `spin-${lang}` }); await slot.setLanguage(lang); /* ... */ }); }); ``` ## Inspector overlay (live diagnostics) Open the app with `?test=...` and a panel docks to the corner of the canvas. Tabs: - **state** — every field of `BridgeStateSnapshot`, refreshed at 5Hz. - **queue** — buttons to queue loss / win / error / offline / online, spin, autospin, recover, simulate $0, toggle sound. One-click bug reproduction without writing a spec. - **a11y** — every labeled Pixi node with bounds. Click a label to emit a `pointertap` on it. - **history** — last 12 network calls with outcome + amounts. - **transitions** — last 20 FSM transitions with durations. - **copy bridge dump → clipboard** — the same JSON Playwright attaches on failure. Paste into a bug report. Keyboard: - `Esc` — hide. `Alt+I` — show. Position cycles via the ↺ button. Disable for clean canvas screenshots: `?test=foo&inspector=0`. ### Pop out into a separate tab (`↗`) Click the `↗` button in the inspector header — a new browser tab opens `/__inspector.html` and connects to the running game tab over a same-origin [`BroadcastChannel`](https://developer.mozilla.org/en-US/docs/Web/API/BroadcastChannel). Identical UI, all RPC commands tunneled to the in-page TestBridge. Use it when: - you want the inspector on a second monitor without crowding the canvas; - the game canvas is fullscreen / immersive and the inspector would obscure it; - you're recording a screencast — the inspector is in a separate window for clean composition. The `popout` pill in the embedded inspector header lights up amber while a remote tab is connected, so you always know who's driving. Embedded and popout drive the **same** bridge — actions from either land on the same state. > **Protocol:** `src/testing/InspectorChannel.ts` defines the wire format > (`hello` / `ack` / `cmd` / `reply` / `state`) and the RPC method > whitelist. Bridge methods not on the allowlist can't be called from the > popout — keeps the surface intentional. ## Failure artifacts (what's attached on a fail) The fixture's afterEach inspects `testInfo.status` and on any fail attaches: - **`bridge-dump.json`** — `BridgeFullDump` with `state`, `networkHistory`, `fsmTransitions`, `pixiTree`, `grid`, `consoleAll`. A triage engineer pastes this into a bug report. - **screenshot** (Playwright default). - **trace** (`retain-on-failure`). - **annotations** for each `console.error` and `pageerror` so the HTML report shows the original stack inline. ``` # After a failed run: pnpm exec playwright show-report playwright-report-scenarios # Open the failed test → Attachments → bridge-dump.json ``` ## Scenario catalog `pnpm test:catalog` rewrites [`tests/scenarios/CATALOG.md`](../tests/scenarios/CATALOG.md) from the spec files. Run it after adding/removing tests; CI can verify the catalog is up-to-date by running it in dry-run and diffing. ## Pre-canned fixtures Don't hand-roll grids and winlines. Import: ``` import { Grids, Winlines, Wins } from './_fixtures'; await slot.queueLoss(Grids.neutralLoss); await slot.queueWin(Grids.fiveSevens, 250, [Winlines.fiveSevensTop(250)]); await slot.queueSpin({ kind: 'response', response: { ...Wins.scatter12, balance: 110 } }); ``` ## Replay from a recorded log Drop a `SpinResponse[]` JSON next to `tests/scenarios/fixtures/`, then: ``` import { readFileSync } from 'node:fs'; const log = JSON.parse(readFileSync('./tests/scenarios/fixtures/my-bug.json', 'utf8')); await slot.replay(log); ``` The bridge queues every entry and runs each as a spin. Use this to reproduce a specific player's round in seconds, or to lock down a golden tape after a major refactor. ## Recorder (zero-code spec authoring) Click **REC** in the inspector → click `● start recording` → poke the inspector / play through the scenario you want to capture → click `⏹ stop recording` → click `copy as .spec.ts → clipboard` → paste into a new file under `tests/scenarios/`. Done. The exported spec compiles and runs. Every action call routes through a `record()` helper inside the bridge, so anything you can do in the inspector — and anything a Playwright spec can call — is captured: queue, spin, clickPixi, setBet, autoplay, language, menu, low-balance, etc. ``` // From a spec, the same surface: await slot.startRecording(); await slot.queueWin(grid, 50); await slot.clickPixi('spin'); await slot.stopRecording(); const spec = await slot.formatAsSpec('big-win-replay'); // spec is a `.spec.ts` body you can write to disk ``` ## Visual snapshots The **SNAP** tab captures the Pixi canvas to a PNG data URL, stores up to 10 in `localStorage`, and renders a 2-column thumbnail strip. Click a thumbnail to open the full image. Use this for "did this look right after the spin?" triage without reaching for the screenshot suite. ``` const dataUrl = await slot.snapshotCanvas(); // any Playwright spec ``` ## Custom matchers (`expect(slot)`) `tests/scenarios/_matchers.ts` extends Playwright's `expect` so specs read like English and failure messages include relevant state context: ``` import { expect, test } from './slot-fixture'; import { expect as expectSlot } from './_matchers'; // re-exports the extended expect test('big win', async ({ slot }) => { await slot.boot({ startingBalance: 100, bet: 1 }); await slot.queueWin(grid, 50); await slot.spin(); await expectSlot(slot).toBeAtPhase('idle'); await expectSlot(slot).toShowBalance(149); await expectSlot(slot).toShowLastWin(50); await expectSlot(slot).toBeSpinning(false); await expectSlot(slot).toCompleteRoundFasterThan(100); await expectSlot(slot).toHaveLabel('spin'); }); ``` Matchers auto-poll the bridge state for ~2s, so a brief race doesn't fail your test. On failure, the message includes phase, balance, queued count, etc. — no manual `console.log` needed. ## Server contract suite `tests/contract/server-contract.test.ts` runs against your real RGS when `RGS_API_URL` is set. Without it, the suite skips (CI-friendly). ``` # Local RGS_API_URL=https://staging.example.com/api pnpm test:contract # CI (in scenarios.yml) - run: pnpm test:contract env: RGS_API_URL: ${{ secrets.STAGING_RGS_URL }} RGS_TOKEN: ${{ secrets.STAGING_RGS_TOKEN }} ``` Pins three things: 1. `POST /session` returns the `SessionResponse` shape. 2. `POST /spin` returns the `SpinResponse` shape (and winlines if `totalWin > 0`). 3. The **balance contract** holds: `response.balance == before − bet + totalWin`. If your server breaks any of these, every spin fails in production — better to find out here. ## Audio cue assertions When you ship an audio engine, hook it to the bridge: ``` // somewhere in your audio engine import { notifyAudioCue } from '@/testing/audioBridge'; play(name: string, opts: { volume?: number } = {}) { this.howl.play(name); notifyAudioCue(name, { volume: opts.volume }); } ``` Then in a spec: ``` await slot.spin(); const log = await slot.audioLog(); expect(log.map(e => e.name)).toContain('win-show'); ``` `notifyAudioCue` is a no-op when test mode is off — safe to leave in production code. The bridge buffer is cleared per-test by the fixture (or call `await slot.clearAudioLog()` to reset mid-test). ## Replay from a server log (production bug repro) ``` # Convert any server audit log into the SpinResponse[] format the bridge consumes pnpm test:import-log path/to/log.json --out tests/scenarios/fixtures/round-17-bug.json # It auto-detects: bare array, { spins: [...] }, { rounds: [...] }, JSONL. # Validates each entry has the required fields and fails loud if not. ``` Then a one-line scenario reproduces the bug locally: ``` import log from './fixtures/round-17-bug.json'; test('round-17 bug repro', async ({ slot }) => { await slot.boot({ startingBalance: log[0].balance }); await slot.replay(log); // Inspect / assert anything afterwards. }); ``` ## CI workflow [`.github/workflows/scenarios.yml`](../.github/workflows/scenarios.yml) runs typecheck → unit → e2e on every push and PR, uploads the HTML report + traces + bridge dumps as artifacts (kept for 14 days), and fails if `tests/scenarios/CATALOG.md` is stale. What you get on a failed run: - HTML report with screenshots, traces, console errors, and the `bridge-dump.json` attachment per failure. - Trace viewer (`pnpm exec playwright show-trace `) with every action, network call, and DOM mutation. - Annotations on the test for each `console.error` / uncaught exception. ## Components catalog [`docs/COMPONENTS.md`](./COMPONENTS.md) — auto-generated from `src/view/hud/controls/`, `src/view/scenes/`, and `src/ui/components/`. Every UI building block listed with its file path, exports, Pixi `.label` (for `clickPixi`), and DOM `data-testid` (for Playwright locators). Regenerate with `pnpm test:components`. ## Caveats and known issues - **Splash z-index**: there's a stacking-context clash between `.splash` and `.sp-header` that occasionally trips Playwright's pointer interception check. The full-boot scenario routes through `bridge.tapToStart()` to avoid it. CSS fix tracked in spawned task "Fix splash z-index — header intercepts pointer events". - **Double-click double-spend**: real bug pinned by `test.fail()` in `input.spec.ts`. SpinButton's `ui.spinning` gate is set inside `SpinPhase.enter`, AFTER the await chain starts; two clicks in the same microtask both pass the gate. Will be fixed by adding a re-entrancy guard to `FSM.transition`. - **Vite HMR + class instances**: rebooting the dev server mid-run is sometimes necessary if you change a class definition that's already instantiated by a long-lived page. Scenarios always navigate fresh, so this only bites during interactive `:ui` debugging. **Where this lives** This page is generated at build time from `template/docs/TESTING.md` in the slotplate repo. The same file ships into your scaffolded project as `docs/TESTING.md`. Edits land in both places automatically. [ [diagram] Edit this page on GitHub ](https://github.com/schmooky/slotplate/edit/main/docs/src/pages/docs/testing.astro) docs/src/pages/docs/testing.astro --- # 10-minute tour URL: https://slotplate.dev/docs/tour/ > A guided walk through the generated client, in reading order. Start here # 10-minute tour A guided walk through the generated client, in reading order. You've run `npm create slotplate my-slot` and `pnpm dev`. Open the project. Here's what's inside, in the order it makes sense to read. ## 1. The composition root Open `src/composition.ts` first. It's the only file that says `new Foo()` for a service. Everything else receives its dependencies. Reading this file top to bottom tells you what the app is made of and how the pieces connect. ## 2. The FSM and phases `src/flow/fsm.ts` is ~50 lines of finite state machine. Phases live in `src/flow/phases/*.ts`, one file each. Each phase has `enter(ctx)` and optional `skip()` / `exit()`. Read `SpinPhase.ts` — ten lines, three responsibilities: debit the bet, kick off reels + network in parallel, transition to `stopSpin` when the server responds. The client doesn't evaluate anything. It passes the server's answer along. ## 3. The wire types `src/domain/types.ts` is every shape that crosses the network boundary: `SpinRequest`, `SpinResponse`, `Grid`, `Winline`. Deliberately small. No `evaluateWin`. No paytable. Biome rejects any import of `pixi-reels`, `mobx`, or outer layers from this folder. ## 4. The stores `src/state/RootStore.ts` composes three stores: - `BalanceStore` — balance, bet, lastWin + actions to mutate. - `DataStore` — the last `SpinResponse`, decoded. - `UIStore` — spin/stop enabled flags, speed mode. Presenters observe stores via MobX `autorun`. Phases mutate stores through action methods. No cross-store writes; a phase orchestrates any cross-store change. ## 5. The ticker `src/infrastructure/timing.ts` defines `Ticker` with `schedule`, `every`, `nextFrame`. All three return a `Disposable` so the owner can cancel on phase exit without remembering handles. Look at `WinShowPhase.enter`. It holds the win for 1.5 seconds via `ctx.ticker.schedule(1500, () => ctx.fsm.transition('idle'))`. Not `setTimeout`. That's principle #2 in action. ## 6. The view boundary `src/presenters/ReelsPresenter.ts` is the one class outside `view/` that touches `pixi-reels`. Everything else goes through it. `MainScene.ts` lives in `view/` and is where you wire `ReelSetBuilder`. Everywhere else, Biome rejects the import. ## 7. The HUD `index.html` has three HTML elements: `#spin`, `#stop`, `#balance`. `HUDPresenter` binds MobX state to them via `autorun`. Swap for React/Vue/Svelte when you're ready — the presenter contract stays the same. ## 8. The agent setup `CLAUDE.md`, `AGENTS.md`, and `.claude/commands/` are committed in the template. Your agent loads them automatically (Claude Code) or via `AGENTS.md` / `llms.txt` (others). The principles are the same whether a human or an agent edits the code. ## The big picture [diagram] Everything you just read about, in one frame. ## Where to go next - [The 10 principles](/docs/principles/) — the rules, distilled. - [Architecture](/architecture/) — more diagrams, deeper explanation. - [Add your first symbol](/guides/add-symbol/). - [Wire your real RGS](/guides/swap-network/). [ [diagram] Edit this page on GitHub ](https://github.com/schmooky/slotplate/edit/main/docs/src/pages/docs/tour.astro) docs/src/pages/docs/tour.astro --- # Guides URL: https://slotplate.dev/guides/ > Task-oriented recipes. How-to # Guides Task-oriented recipes. Grouped by what you're trying to do. Each guide ends with the commands to run before shipping. ## Building your slot - [Add a symbol](/guides/add-symbol/) — sprite or Spine. - [Add a phase](/guides/add-phase/) — a new FSM state. - [Swap the network transport](/guides/swap-network/) — hook up your real RGS. - [Spine symbols](/guides/spine/) — rigged character animation. - [Add a bonus game](/guides/bonus/) — nested sub-FSM. ## Instrumenting & shipping - [Analytics events](/guides/analytics/) — phase-level telemetry. - [Responsive & portrait layouts](/guides/responsive/). [ [diagram] Edit this page on GitHub ](https://github.com/schmooky/slotplate/edit/main/docs/src/pages/guides.astro) docs/src/pages/guides.astro --- # Add a phase URL: https://slotplate.dev/guides/add-phase/ > Adding a new FSM phase (anticipation, bonus, big-win). How-to # Add a phase Adding a new FSM phase (anticipation, bonus, big-win). The default FSM [diagram] You're extending this. New phases go between existing transitions. ## Short version ``` /add-phase AnticipationPhase ``` ## Long version - **Create the file.** `src/flow/phases/AnticipationPhase.ts`: ``` import type { Phase, PhaseContext } from '../Phase'; export class AnticipationPhase implements Phase { readonly name = 'anticipation'; async enter(ctx: PhaseContext) { const teasers = ctx.stores.data.teasingReels; ctx.reels.setAnticipation(teasers); // wait for the anticipation animation to settle, then advance await ctx.fsm.transition('stopSpin'); } exit() { /* dispose anything this phase allocated */ } } ``` - **Register.** In `src/composition.ts`, add `fsm.register(new AnticipationPhase())` alongside the others. - **Wire the transition in.** Whichever phase *precedes* this one now transitions to it. For anticipation, that's probably `SpinPhase` — instead of going straight to `stopSpin`, it goes to `anticipation` when the server returns `teasingReels`. - **Test it.** `tests/flow/AnticipationPhase.test.ts` — instantiate the phase, mock the context, call `enter`, assert the presenter was called and the FSM transitioned. - **Run:** `pnpm typecheck && pnpm test && pnpm lint`. ## Don't - Call `setTimeout` inside the phase. Use `ctx.ticker.schedule`. - Import `pixi-reels`. Talk to `ctx.reels` (the presenter). - Loop inside the phase. Transition out and back in (or split into two phases). - Call another phase's `enter` directly. Use `ctx.fsm.transition`. ## The `skip` method Implement `skip()` if the user should be able to short-circuit this phase (Turbo / SuperTurbo). Usually that means "cancel the pending ticker handle and transition immediately." See `WinShowPhase.skip` for the canonical shape. ## The `exit` method Implement `exit()` if the phase holds timers, event listeners, or transient state. Called before the next phase's `enter` — and also on FSM teardown. Make it idempotent. [ [diagram] Edit this page on GitHub ](https://github.com/schmooky/slotplate/edit/main/docs/src/pages/guides/add-phase.astro) docs/src/pages/guides/add-phase.astro --- # Add a symbol URL: https://slotplate.dev/guides/add-symbol/ > Adding a new reel symbol to a slotplate project. How-to # Add a symbol Adding a new reel symbol to a slotplate project. ## Short version ``` /add-symbol Watermelon ``` (In Claude Code. The slash command does the steps below.) ## Long version - **Add the texture.** Drop the sprite in `public/assets/symbols/watermelon.png`, or add it to your TexturePacker atlas. - **Register in the asset bundle.** In `composition.ts`, add the alias to the asset bundle loaded before the main scene mounts. - **Create the symbol class.** `src/view/symbols/WatermelonSymbol.ts`: ``` export class WatermelonSymbol extends SpriteReelSymbol { constructor() { super('watermelon', Texture.from('watermelon')); } } ``` Subclass `SpriteReelSymbol` only if you need custom win animation, particles, or sub-sprites. For a simple sprite, reuse the base class directly from the factory. - **Register in the factory.** `SymbolFactory` picks a renderer per id. Add `{ id: 'watermelon', renderer: 'sprite', textureAlias: 'watermelon' }` to its definitions. - **Add the id to `gameConfig.ts`.** The `symbolIds` array is the whitelist of what the server is allowed to send. An id not in this list means the factory throws loud — which is intentional. - **Test it.** `tests/view/WatermelonSymbol.test.ts` — assert the lifecycle (activate, resize, playWin, dispose) works without throwing. - **Run:** `pnpm typecheck && pnpm test && pnpm lint`. ## If it's a Spine symbol Use `SpineReelSymbol` instead. See the [Spine symbols guide](/guides/spine/) for the peer-dep install, the `SymbolAnimationPlayer` wiring, and the per-symbol animation config. `SymbolFactory` picks per-id, so you can mix sprite and Spine symbols in the same game. ## Anti-patterns - Importing pixi-reels or pixi.js outside `view/` or `presenters/` — lint error. - Putting payout info on the symbol. The client doesn't know payouts. - Calling `setTimeout` for the win pulse. Use `gsap.to(this.scale, ...)`. - Scattering layout logic across constructor + `onActivate` + `resize`. Put it in `resize`; it runs on every swap anyway. [ [diagram] Edit this page on GitHub ](https://github.com/schmooky/slotplate/edit/main/docs/src/pages/guides/add-symbol.astro) docs/src/pages/guides/add-symbol.astro --- # Analytics events URL: https://slotplate.dev/guides/analytics/ > Instrumenting the FSM without coupling it to a vendor. How-to # Analytics events Instrumenting the FSM without coupling it to a vendor. `src/infrastructure/Analytics.ts` is a small interface: ``` interface Analytics { track(event: string, payload?: Record): void; } ``` Swap `ConsoleAnalytics` for your vendor (Segment, Amplitude, GA4) in `composition.ts`. Emit events from *phase handlers* — they're the natural event boundaries. ## Where to emit ``` // SpinPhase.enter ctx.analytics.track('spin_requested', { bet: ctx.stores.balance.bet }); // WinShowPhase.enter if (winlines.length > 0) { ctx.analytics.track('win_shown', { totalWin, lineCount: winlines.length, }); } ``` ## Where NOT to emit - **Not from presenters.** They fire on MobX reactions — too many duplicates. - **Not from the view.** Too deep in the tree — hard to trace causality. - **Not from stores.** Stores should be pure data, no side effects. ## What to track - Round lifecycle: spin requested, response received, wins shown, round complete. - User intent: bet changed, speed changed, skip pressed. - Errors: network timeouts, malformed responses, unexpected state transitions. ## What NOT to track (client-side) - Win amounts per se — the server has the authoritative data. - Paytable-derived metrics — you don't have the paytable. - RTP — server-side certification. The client emits *user behavior* events; the server emits *money/outcome* events. [ [diagram] Edit this page on GitHub ](https://github.com/schmooky/slotplate/edit/main/docs/src/pages/guides/analytics.astro) docs/src/pages/guides/analytics.astro --- # Add a bonus game URL: https://slotplate.dev/guides/bonus/ > Nest a sub-FSM inside a BonusPhase. How-to # Add a bonus game Nest a sub-FSM inside a BonusPhase. Bonus games — "pick one of three chests", mini-slot, wheel-spin — run their own state machine. slotplate's pattern is to nest a sub-FSM inside a parent phase. [diagram] The outer FSM (shown). Add BonusPhase between stopSpin and winShow (or wherever your trigger lives) and run the inner FSM to completion. ## Steps - **Create `BonusPhase`.** In `enter`, instantiate a new `FSM` with bonus-only phases (`bonusIntro`, `bonusRound`, `bonusReveal`, `bonusOutro`). Run it to completion, then transition the parent FSM to the next state. - **Add a `BonusSession` store.** It accumulates the bonus win without touching `BalanceStore` until the outro. The main-game balance is frozen during the bonus. - **Add a scene if needed.** If the bonus has its own display tree (splash, different layout), add `BonusScene` in `view/scenes/`. Mount it on bonus entry, unmount on exit. Don't reuse `MainScene` — the lifecycles differ. - **Add a presenter** (`BonusPresenter`) bridging the bonus store to the bonus scene. Same contract as `ReelsPresenter`. - **Wire the trigger.** `stopSpin` checks `ctx.stores.data.bonus`; if set, transition to `bonus` instead of `winShow`. ## Rules - Main-game state stays frozen during the bonus. No main-game phase runs until the parent phase exits. - Bonus phases follow all the same rules: one file each, `Ticker.schedule` for timing, `Disposable` for cleanup. - The bonus FSM is scoped to the parent phase. Tear it down in `BonusPhase.exit`. - The server decides all bonus outcomes — the client plays them back, like main-game spins. ## Wire protocol for bonus Your `SpinResponse.bonus` field carries the bonus id and payload. The server can either (a) return the whole bonus outcome up front, or (b) send per-pick updates as the user interacts. slotplate's contract is flexible — `BonusPhase` can make follow-up requests via `NetworkManager.pickChest(...)` or similar. [ [diagram] Edit this page on GitHub ](https://github.com/schmooky/slotplate/edit/main/docs/src/pages/guides/bonus.astro) docs/src/pages/guides/bonus.astro --- # Responsive & portrait layouts URL: https://slotplate.dev/guides/responsive/ > Aspect-ratio-aware scenes without branching in business code. How-to # Responsive & portrait layouts Aspect-ratio-aware scenes without branching in business code. Responsive layout is a **scene concern** — not a store, not a phase, not a domain concern. Keep it there. ## Pattern - Listen to `window.resize` in the scene. - Compute a layout (portrait / landscape / narrow / wide) and apply it to the reel container + HUD positions. - Keep reel-cell dimensions in one file (`src/view/scenes/layout.ts`) so every subsystem reads from the same source. ## Don't - Branch on aspect ratio inside presenters or phases. - Keep per-orientation duplicates of stores or phases. - Recompute cell bounds in overlays. Call `reelSet.getCellBounds(reel, row)`. ## Breakpoints Slot clients typically have two or three layouts: landscape desktop, landscape phone, portrait phone. Branch at the layout level and flow everything else from there. Don't try to interpolate — designers want each layout tuned. ## Iframe embedding RGS lobbies load slot clients in iframes with fixed aspect ratios. Verify your CSP and `postMessage` origin. Test with a narrow container in dev (`?frame=narrow`) before shipping. [ [diagram] Edit this page on GitHub ](https://github.com/schmooky/slotplate/edit/main/docs/src/pages/guides/responsive.astro) docs/src/pages/guides/responsive.astro --- # Spine symbols URL: https://slotplate.dev/guides/spine/ > Rigged skeleton animation for reel symbols, following the bonbon-hw pattern. How-to # Spine symbols Rigged skeleton animation for reel symbols, following the bonbon-hw pattern. Spine is optional. Install it when a symbol needs rigged animation — skeletal bones, blended tracks, per-symbol "idle → landing → win → bigwin" states. For everything else, stick with sprites. ## Install the peer dep ``` pnpm add @esotericsoftware/spine-pixi-v8 ``` ## The two-class pattern slotplate splits Spine handling into two classes, mirroring the bonbon-hw codebase: - **`SpineReelSymbol`** — a dumb container that owns the `Spine` instance's lifecycle (`onActivate`, `onDeactivate`, `resize`). No animation logic; delegates to the player. - **`SymbolAnimationPlayer`** — given a symbol id and an animation name (`'idle'`, `'landing'`, `'win'`, `'bigwin'`), plays the right tracks on the Spine instance. The symbol-to-tracks mapping is data — one file of configuration, not logic. ## The config ``` // src/config/spineAnimations.ts export const animations: SymbolAnimations = { idle: { default: ['idle'], wild: ['wild_idle'] }, landing: { default: ['landing'], wild: ['wild_landing'] }, win: { default: ['win_small'], wild: ['wild_win', 'wild_glow'] }, bigwin: { default: ['bigwin'] }, size: { default: ['size_idle'] }, }; ``` Each animation maps `symbolId → tracks[]`. Tracks play in parallel — so you can have a gameplay animation, a size pulse, and a glow overlay running on three tracks at once. ## Enabling the stub The shipped `SpineReelSymbol.ts` is stubbed. Uncomment the import and the `Spine.from(...)` call in the constructor, and implement the `SymbolAnimationPlayer` body using `spine.state.setAnimation(...)`. The bonbon-hw `SymbolAnimationPlayer` is a good concrete reference. ## Per-symbol positioning Sprite symbols anchor at `(0, 0)`. Spine skeletons center at origin. That means `resize(w, h)` must set `spine.position = (w/2, h/2)` and compute a scale that fits the skeleton's bounding box into the cell. Easy to miss — `resize` runs on every symbol swap, so a one-time constructor position won't cut it. [diagram] The slotplate opinion Do not mix sprite and Spine code in one class. Use `SymbolFactory` to pick per-id. Mixed classes become conditional bodies that are a maintenance trap. ## When to use Spine vs AnimatedSprite - **Spine:** character-style rigs, complex blended states, skeletal-physics secondary motion. - **AnimatedSprite (Pixi):** frame-by-frame loops. A blinking wild, a twinkling scatter. Smaller, simpler, no peer dep. - **GSAP tweens on a Sprite:** shake, scale pulse, fade. The default for win animations. [ [diagram] Edit this page on GitHub ](https://github.com/schmooky/slotplate/edit/main/docs/src/pages/guides/spine.astro) docs/src/pages/guides/spine.astro --- # Swap the network transport URL: https://slotplate.dev/guides/swap-network/ > Replace the mock with your RGS client in one file. How-to # Swap the network transport Replace the mock with your RGS client in one file. slotplate ships with `MockNetworkManager` (random grids, 300ms latency). Real games talk to an RGS over WebSocket or HTTPS. ## The interface ``` export interface NetworkManager { spin(req: SpinRequest): Promise; } ``` That's it. Write your own class, register it in `composition.ts`: ``` container.register( 'network', () => new RgsNetworkManager(config.rgsUrl, config.sessionToken), ); ``` Nothing else in the codebase knows about the transport. Phases call `network.spin(...)`; the return type is the same. No other change needed. ## What goes in the real implementation - **Session handshake / authentication.** Usually a one-time call on app boot; store the token in `infrastructure`, not in a MobX store. - **Request/response logging** for support tickets. Log correlation IDs so you can trace a spin end-to-end. - **Retry on transient errors.** Finite retries with backoff. Throw loud after N attempts — do not silently return an empty response. - **Schema validation.** Don't trust the payload shape. Parse it (Zod, io-ts, hand-rolled) at the boundary and throw if malformed. ## What does NOT go here - Game rules. The server enforces them; the client doesn't recompute. - UI state. `NetworkManager` is infrastructure — it doesn't touch `UIStore`. - Paytable caching. If you need the paytable on the client (for tooltips), serve it as a separate endpoint. [diagram] The slotplate opinion Keep provider-specific code in one file. If you ship to two providers, write two `NetworkManager` classes, not one with `if (provider === 'X')` branches. The branches will multiply; the boundary between them will become load-bearing. ## Testing Phase tests use a mock `NetworkManager` via `vi.fn()` — see `tests/flow/SpinPhase.test.ts`. For integration with the real server, use a dev seed if your RGS supports it. Otherwise, mock at the `NetworkManager` boundary only — don't mock deeper. [ [diagram] Edit this page on GitHub ](https://github.com/schmooky/slotplate/edit/main/docs/src/pages/guides/swap-network.astro) docs/src/pages/guides/swap-network.astro --- # Patterns URL: https://slotplate.dev/patterns/ > The design patterns slotplate uses, one page each. Reference # Patterns The design patterns slotplate uses, one page each. Each page names the pattern, points at where it lives in slotplate, and says when to use it. ## Structural - [Composition root](/patterns/composition-root/) - [Presenter (MVP)](/patterns/presenter/) - [Adapter / DTO](/patterns/adapter/) ## Behavioral - [Finite state machine](/patterns/fsm/) - [Command](/patterns/command/) (phase handlers) - [Strategy](/patterns/strategy/) (spin modes) - [Observer](/patterns/observer/) (MobX + event emitters) ## Creational - [Factory](/patterns/factory/) (SymbolFactory) ## Resource management - [Object pool](/patterns/object-pool/) - [Disposable](/patterns/disposable/) [ [diagram] Edit this page on GitHub ](https://github.com/schmooky/slotplate/edit/main/docs/src/pages/patterns.astro) docs/src/pages/patterns.astro --- # Adapter / DTO URL: https://slotplate.dev/patterns/adapter/ > Translating between layer shapes. Pattern # Adapter / DTO Translating between layer shapes. **Where:** `ReelsPresenter` (adapts app state to the `pixi-reels` API), response mappers in `infrastructure/`. **The rule:** when two layers have different shapes, put a thin adapter at the boundary. Don't leak the outer shape inward. **DTO note:** network responses are DTOs. Validate them at the boundary (`NetworkManager`) and hand domain-shaped data to the rest of the app. Do not pass raw JSON through phases. [ [diagram] Edit this page on GitHub ](https://github.com/schmooky/slotplate/edit/main/docs/src/pages/patterns/adapter.astro) docs/src/pages/patterns/adapter.astro --- # Command URL: https://slotplate.dev/patterns/command/ > Phase handlers as dispatchable commands. Pattern # Command Phase handlers as dispatchable commands. **Where:** `src/flow/phases/*.ts`. **The rule:** each phase is a self-contained unit with `enter`, `skip`, `exit`. They're commands: you dispatch one, it runs, transitions to the next. **Why:** phases are testable in isolation. You can also log/record phase transitions for replay or telemetry. [ [diagram] Edit this page on GitHub ](https://github.com/schmooky/slotplate/edit/main/docs/src/pages/patterns/command.astro) docs/src/pages/patterns/command.astro --- # Composition root URL: https://slotplate.dev/patterns/composition-root/ > One file that wires everything. Pattern # Composition root One file that wires everything. **Where:** `src/composition.ts`. **The rule:** all `new Service(...)` calls happen here. Every other file receives dependencies. **Why:** grepability. "Where does this service come from?" has one answer. Swapping implementations is a one-file change. **Reading:** Mark Seemann, *Dependency Injection Principles, Practices, and Patterns* (ch. 4). See [Composition root concept](/concepts/composition-root/) for more. [ [diagram] Edit this page on GitHub ](https://github.com/schmooky/slotplate/edit/main/docs/src/pages/patterns/composition-root.astro) docs/src/pages/patterns/composition-root.astro --- # Disposable URL: https://slotplate.dev/patterns/disposable/ > One cancellation primitive for everything. Pattern # Disposable One cancellation primitive for everything. **Where:** `src/utils/Disposable.ts` (`Disposable`, `DisposableBag`). **The rule:** any class that allocates a resource (listener, ticker handle, reaction, Pixi object) implements `Disposable`. Parents dispose children in their own `dispose()`. **Why:** the resource-leak bug you don't catch in review is a day-of-launch support ticket. One primitive, one contract, no surprises. See [Disposables concept](/concepts/disposables/) for patterns. [ [diagram] Edit this page on GitHub ](https://github.com/schmooky/slotplate/edit/main/docs/src/pages/patterns/disposable.astro) docs/src/pages/patterns/disposable.astro --- # Factory URL: https://slotplate.dev/patterns/factory/ > SymbolFactory picks renderer per id. Pattern # Factory SymbolFactory picks renderer per id. **Where:** `src/view/symbols/SymbolFactory.ts`. **The rule:** given a symbol id, return the right `Container` subclass (sprite, animated sprite, Spine). The factory reads per-id config; call sites are agnostic. **Why:** lets you mix sprite and Spine symbols in the same game. Extends cleanly to new renderer types without touching the caller. [ [diagram] Edit this page on GitHub ](https://github.com/schmooky/slotplate/edit/main/docs/src/pages/patterns/factory.astro) docs/src/pages/patterns/factory.astro --- # Finite state machine URL: https://slotplate.dev/patterns/fsm/ > Named phases with enter/skip/exit. Pattern # Finite state machine Named phases with enter/skip/exit. **Where:** `src/flow/fsm.ts`, `src/flow/phases/`. **The rule:** the game is a set of named phases. Transitions are explicit. Each phase has `enter(ctx)`, optional `skip(ctx)`, optional `exit(ctx)`. **Why a machine, not flags:** flags like `isSpinning`, `canStop`, `showingWin` multiply until you can't reason about them. A machine has one current state; transitions are auditable. See [FSM & phases](/architecture/fsm/) for the full treatment. [ [diagram] Edit this page on GitHub ](https://github.com/schmooky/slotplate/edit/main/docs/src/pages/patterns/fsm.astro) docs/src/pages/patterns/fsm.astro --- # Object pool URL: https://slotplate.dev/patterns/object-pool/ > Recycling symbols to avoid GC pressure. Pattern # Object pool Recycling symbols to avoid GC pressure. **Where:** `pixi-reels`' `ObjectPool`. **The rule:** symbols, frame contexts, event payloads — things that flow through the ticker — live in pools. Allocate on cold start; recycle on every cycle. **Watch for:** references escaping the pool. A symbol that ends up in a bigwin presenter and isn't returned leaks. Use the `Disposable` pattern to enforce return. [ [diagram] Edit this page on GitHub ](https://github.com/schmooky/slotplate/edit/main/docs/src/pages/patterns/object-pool.astro) docs/src/pages/patterns/object-pool.astro --- # Observer URL: https://slotplate.dev/patterns/observer/ > MobX reactions + typed event emitters. Pattern # Observer MobX reactions + typed event emitters. **Where:** MobX `autorun`/`reaction` for continuous state; `pixi-reels`' `EventEmitter` for discrete domain events. **The rule:** - Continuous state → MobX reaction. - Discrete event ("reel landed") → event emitter. Both are observer patterns; pick the primitive that fits the shape of the data. Reactions that fire every frame while reels spin are the wrong shape for "reel landed." [ [diagram] Edit this page on GitHub ](https://github.com/schmooky/slotplate/edit/main/docs/src/pages/patterns/observer.astro) docs/src/pages/patterns/observer.astro --- # Presenter (MVP) URL: https://slotplate.dev/patterns/presenter/ > State → view, nothing more. Pattern # Presenter (MVP) State → view, nothing more. **Where:** `src/presenters/`. **The rule:** observe state, translate into view calls, expose a narrow API for the FSM to drive. Never decide timing. Never mutate state. **MVP vs MVVM:** close cousins. slotplate uses presenters because they tend to be thinner than ViewModels — no two-way binding, just observable read and view write. [ [diagram] Edit this page on GitHub ](https://github.com/schmooky/slotplate/edit/main/docs/src/pages/patterns/presenter.astro) docs/src/pages/patterns/presenter.astro --- # Strategy URL: https://slotplate.dev/patterns/strategy/ > Swappable algorithms — spin modes, reel behaviors. Pattern # Strategy Swappable algorithms — spin modes, reel behaviors. **Where:** `pixi-reels`' `SpinningMode` interface (Standard, Cascade, Immediate). Registered via `ReelSetBuilder`. **The rule:** pick an algorithm at construction, not at call time. Classic cascade games choose `CascadeMode`; classic 5×3 games use `StandardMode`; demos use `ImmediateMode`. **Don't:** branch on `mode === 'cascade'` in 50 places. One strategy, one implementation, one registration. [ [diagram] Edit this page on GitHub ](https://github.com/schmooky/slotplate/edit/main/docs/src/pages/patterns/strategy.astro) docs/src/pages/patterns/strategy.astro