SP slotplate
Architecture

FSM & phases

Named phases, explicit transitions, one current state.

The default FSM
idle waits for click spin debitBet + request stopSpin reels land on grid winShow spotlight + credit click response received allLanded win hold elapsed (or skip) no winlines → idle immediately skip() = forceStop skip() = forceStop skip() = cancel hold idle / resting phase active phase normal transition conditional shortcut
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<void>;
  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

  1. Create src/flow/phases/MyPhase.ts. Implement Phase.
  2. Register in src/composition.ts with fsm.register(new MyPhase()).
  3. Add a transition to it from wherever in the existing flow should enter it.
  4. Add a test in tests/flow/MyPhase.test.ts.

The slash command /add-phase MyPhase does all of this for you.

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.