Step 5 - Game flow and persistence
5 min read
Game flow and persistence
Wire the engine, the AI and the views into a running application, and remember settings and scores between visits.
Application state (src/state/app-state.ts)
export type GameMode = 'human-vs-human' | 'human-vs-computer';
export type StartingMark = 'X' | 'O' | 'alternate';
export interface Settings {
readonly mode: GameMode;
readonly difficulty: Difficulty;
readonly startingMark: StartingMark;
/** Which mark the human plays in human-vs-computer mode. */
readonly humanMark: Player;
}
export interface Scoreboard {
readonly xWins: number;
readonly oWins: number;
readonly draws: number;
}
export interface AppState {
readonly game: GameState;
readonly settings: Settings;
readonly scoreboard: Scoreboard;
/** True while the computer's move is pending. Blocks all input. */
readonly isComputerThinking: boolean;
/** Which mark opens the next round when startingMark is 'alternate'. */
readonly nextStarter: Player;
}
Defaults: mode human-vs-computer, difficulty medium, startingMark alternate, humanMark X,
all scores zero, nextStarter X.
Transitions are pure functions (state, payload) => AppState, each returning a new object:
selectCell, startNewRound, changeSettings, resetScores, setComputerThinking. No transition
touches the DOM or localStorage.
Round lifecycle
- Start.
startNewRoundcomputes the opening mark:X,O, ornextStarterwhen the setting isalternate. It callscreateGame(openingMark)and, whenalternateis active, flipsnextStarterfor the round after. - Human move. The cell handler ignores the event when
isComputerThinkingis true or the game is over. Otherwise it applies the move and re-renders. - Outcome check. If the new status is
winordraw, increment the scoreboard exactly once, persist it, and stop. The board stays visible with the winning line highlighted until the player starts a new round; it must never auto-restart. - Computer move. In
human-vs-computermode, if the game is still in progress and it is the computer's turn, setisComputerThinking, schedulechooseMoveafter the cosmetic delay, apply the result, and re-run from step 3. - Settings change. Changing mode, difficulty, starting mark or human mark immediately abandons the current round and starts a fresh one. Scores are kept. Cancel any pending computer move timer first, so a move from the abandoned round cannot land on the new board.
Guard against double-counting: the scoreboard is incremented only on the transition from
in-progress to a terminal status, never during a re-render.
If the human plays O against the computer and the round opens with X, the computer must move
first without any user interaction.
Persistence (src/state/storage.ts)
Two keys, both namespaced and versioned:
ttt.settings.v1— theSettingsobject.ttt.scoreboard.v1— theScoreboardobject.
Rules:
- Every stored object carries a
schemaVersion: 1field. - Reads are defensive: wrap
JSON.parseintry/catch, then validate every field against the allowed values before use. Anything missing, mistyped, out of range, negative or of an unknown schema version causes that key to be discarded and the defaults returned. Never trust storage. - Writes are best-effort:
localStoragemay throw (private mode, quota, disabled storage). Catch, log once withconsole.error, and continue. A storage failure must never break the game. - Detect availability once at startup with a write/read/delete probe; if unavailable, run entirely in memory and skip further attempts.
- Settings are written on change; the scoreboard is written on each round outcome and on reset.
- The in-progress board is not persisted. Reloading starts a fresh round with remembered settings and scores — simpler, and it avoids restoring a half-finished game the player has forgotten.
Scoreboard view
Shows three counters labelled X, O and Draws — in human-vs-computer mode label them
You, Computer and Draws, mapped through humanMark. A Reset scores button zeroes all three
after a confirmation step; use an inline "Are you sure? Yes / No" control rather than
window.confirm, which is not stylable and interrupts screen-reader flow.
Composition root (src/main.ts)
The only module that may hold mutable state. It owns one let state: AppState, loads persisted
values, builds views, wires callbacks, and defines a single update(next: AppState) that assigns
the state and calls every render. All rendering flows through update; no view is ever re-rendered
directly from a handler.
Wrap render calls in requestAnimationFrame batching only if a profile shows it is needed; a game
this small should re-render synchronously in well under one frame.
Acceptance criteria
- All state transitions are pure functions returning new
AppStateobjects;src/state/touches no DOM. -
src/main.tsis the only module with a mutable module-level variable. - Every render goes through the single
updatefunction. - The scoreboard increments exactly once per finished round, verified by a test that re-renders a terminal state repeatedly.
- With startingMark
alternate, the opening mark flips every round; withXorOit is fixed. - Changing any setting starts a fresh round, keeps the scores and cancels any pending computer move.
- When the human plays
Oand the round opens withX, the computer moves first unprompted. - Settings and scores survive a page reload; the in-progress board does not.
- Corrupt, truncated, mistyped, negative or unknown-version stored data is discarded silently and replaced with defaults, covered by tests for each case.
- The game runs correctly with
localStoragethrowing on every call, covered by a test with a throwing storage stub. - Reset scores requires an inline confirmation and does not use
window.confirm. - Statement coverage of
src/state/is at least 90%. -
npm run verifypasses.
Discussion
0 commentsNo comments yet. Start the discussion.