Skip to main content

State Pattern

Allow an object to alter its behavior when its internal state changes, as if it switched to a different implementation.


The Problem​

You're building a tennis scoring system for Roland Garros. Tennis scoring is notoriously complex: 0 β†’ 15 β†’ 30 β†’ 40 β†’ Game, but at 40-40 it's Deuce, then Advantage, then either Game or back to Deuce.

The naive approach:

function scorePoint(server: Score, receiver: Score, whoScored: "server" | "receiver") {
if (whoScored === "server") {
if (server === "40" && receiver === "40") {
return { server: "AD", receiver: "40" };
} else if (server === "40" && receiver !== "AD") {
return { server: "Game", receiver };
} else if (server === "AD") {
return { server: "Game", receiver: "40" };
} else if (receiver === "AD") {
return { server: "40", receiver: "40" }; // back to Deuce
} else if (server === "0") {
return { server: "15", receiver };
} else if (server === "15") {
return { server: "30", receiver };
}
// ... 15 more branches for all combinations
}
// ... duplicate everything for receiver
}

Nested conditionals everywhere. Easy to miss edge cases. No guarantee you've covered all states.


The Solution​

Model each score combination as a state with explicit transitions:

import { createMachine } from "@pithos/core/eidos/state/state";

// TS infers all states and events β€” invalid transitions are compile errors
const tennisGame = createMachine({
"0-0": { p1: { to: "15-0" }, p2: { to: "0-15" } },
"15-0": { p1: { to: "30-0" }, p2: { to: "15-15" } },
"0-15": { p1: { to: "15-15" }, p2: { to: "0-30" } },
"30-0": { p1: { to: "40-0" }, p2: { to: "30-15" } },
// ... all score combinations
"40-40": { p1: { to: "AD-40" }, p2: { to: "40-AD" } }, // Deuce!
"AD-40": { p1: { to: "Game-P1" }, p2: { to: "40-40" } }, // Advantage or back to Deuce
"40-AD": { p1: { to: "40-40" }, p2: { to: "Game-P2" } },
// Simplified β€” live demo uses "Deuce", "AD-P1", "AD-P2" for readability
"Game-P1": {}, // Terminal state
"Game-P2": {},
}, "0-0");

tennisGame.send("p1"); // "15-0"
tennisGame.send("p1"); // "30-0"
// ... play continues

Every state transition is explicit. The Deuce ↔ Advantage loop is clear. Terminal states have no transitions.


Live Demo​

Score points for each player and watch the state machine handle Deuce, Advantage, and Game transitions.

Code
import { createMachine } from "@pithos/core/eidos/state/state";

/**
* Tennis game scoring state machine.
*
* States represent score combinations: "P1Score-P2Score"
* Events: "p1" (player 1 scores) or "p2" (player 2 scores)
*/

export type TennisState =
| "0-0" | "15-0" | "0-15" | "30-0" | "15-15" | "0-30"
| "40-0" | "30-15" | "15-30" | "0-40" | "40-15" | "30-30"
| "15-40" | "40-30" | "30-40" | "Deuce" | "AD-P1" | "AD-P2"
| "Game-P1" | "Game-P2";

export type TennisEvent = "p1" | "p2";

/** Metadata for each state β€” NO if/else needed */
interface StateMetadata {
p1Score: string;
p2Score: string;
phase: "normal" | "deuce" | "gameOver";
winner: "p1" | "p2" | null;
statusLabel: string | null;
}

/** All state metadata in a single record β€” the State pattern way */
const STATE_METADATA: Record<TennisState, StateMetadata> = {
// Normal scoring
"0-0": { p1Score: "0", p2Score: "0", phase: "normal", winner: null, statusLabel: null },
"15-0": { p1Score: "15", p2Score: "0", phase: "normal", winner: null, statusLabel: null },
"0-15": { p1Score: "0", p2Score: "15", phase: "normal", winner: null, statusLabel: null },
"30-0": { p1Score: "30", p2Score: "0", phase: "normal", winner: null, statusLabel: null },
"15-15": { p1Score: "15", p2Score: "15", phase: "normal", winner: null, statusLabel: null },
"0-30": { p1Score: "0", p2Score: "30", phase: "normal", winner: null, statusLabel: null },
"40-0": { p1Score: "40", p2Score: "0", phase: "normal", winner: null, statusLabel: null },
"30-15": { p1Score: "30", p2Score: "15", phase: "normal", winner: null, statusLabel: null },
"15-30": { p1Score: "15", p2Score: "30", phase: "normal", winner: null, statusLabel: null },
"0-40": { p1Score: "0", p2Score: "40", phase: "normal", winner: null, statusLabel: null },
"40-15": { p1Score: "40", p2Score: "15", phase: "normal", winner: null, statusLabel: null },
"30-30": { p1Score: "30", p2Score: "30", phase: "normal", winner: null, statusLabel: null },
"15-40": { p1Score: "15", p2Score: "40", phase: "normal", winner: null, statusLabel: null },
"40-30": { p1Score: "40", p2Score: "30", phase: "normal", winner: null, statusLabel: null },
"30-40": { p1Score: "30", p2Score: "40", phase: "normal", winner: null, statusLabel: null },
// Deuce phase
"Deuce": { p1Score: "40", p2Score: "40", phase: "deuce", winner: null, statusLabel: "Deuce" },
"AD-P1": { p1Score: "AD", p2Score: "40", phase: "deuce", winner: null, statusLabel: "Advantage P1" },
"AD-P2": { p1Score: "40", p2Score: "AD", phase: "deuce", winner: null, statusLabel: "Advantage P2" },
// Terminal states
"Game-P1": { p1Score: "Game", p2Score: "", phase: "gameOver", winner: "p1", statusLabel: "Game" },
"Game-P2": { p1Score: "", p2Score: "Game", phase: "gameOver", winner: "p2", statusLabel: "Game" },
};

/** State transitions β€” the machine definition */
const TENNIS_TRANSITIONS = {
"0-0": { p1: { to: "15-0" }, p2: { to: "0-15" } },
"15-0": { p1: { to: "30-0" }, p2: { to: "15-15" } },
"0-15": { p1: { to: "15-15" }, p2: { to: "0-30" } },
"30-0": { p1: { to: "40-0" }, p2: { to: "30-15" } },
"15-15": { p1: { to: "30-15" }, p2: { to: "15-30" } },
"0-30": { p1: { to: "15-30" }, p2: { to: "0-40" } },
"40-0": { p1: { to: "Game-P1" }, p2: { to: "40-15" } },
"30-15": { p1: { to: "40-15" }, p2: { to: "30-30" } },
"15-30": { p1: { to: "30-30" }, p2: { to: "15-40" } },
"0-40": { p1: { to: "15-40" }, p2: { to: "Game-P2" } },
"40-15": { p1: { to: "Game-P1" }, p2: { to: "40-30" } },
"30-30": { p1: { to: "40-30" }, p2: { to: "30-40" } },
"15-40": { p1: { to: "30-40" }, p2: { to: "Game-P2" } },
"40-30": { p1: { to: "Game-P1" }, p2: { to: "Deuce" } },
"30-40": { p1: { to: "Deuce" }, p2: { to: "Game-P2" } },
// Deuce and Advantage loop
"Deuce": { p1: { to: "AD-P1" }, p2: { to: "AD-P2" } },
"AD-P1": { p1: { to: "Game-P1" }, p2: { to: "Deuce" } },
"AD-P2": { p1: { to: "Deuce" }, p2: { to: "Game-P2" } },
// Terminal states (no transitions)
"Game-P1": {},
"Game-P2": {},
} as const;

export function createTennisGame() {
return createMachine(TENNIS_TRANSITIONS, "0-0");
}

/** Get metadata for a state β€” just a lookup, no conditionals */
export function getStateMetadata(state: TennisState): StateMetadata {
return STATE_METADATA[state];
}

export interface ScoreDirections {
p1: "forward" | "backward";
p2: "forward" | "backward";
}

/** Determine flip direction per player between two states */
export function getScoreDirections(from: TennisState, to: TennisState): ScoreDirections {
const prev = STATE_METADATA[from];
const next = STATE_METADATA[to];

return {
p1: isScoreBackward(prev.p1Score, next.p1Score) ? "backward" : "forward",
p2: isScoreBackward(prev.p2Score, next.p2Score) ? "backward" : "forward",
};
}

const SCORE_ORDER = ["", "0", "15", "30", "40", "AD", "Game"];

function isScoreBackward(from: string, to: string): boolean {
const fi = SCORE_ORDER.indexOf(from);
const ti = SCORE_ORDER.indexOf(to);
if (fi === -1 || ti === -1) return false;
return ti < fi;
}
Result

Real-World Analogy​

A tennis umpire's scoreboard. The umpire doesn't think "if player 1 has 40 and player 2 has 30, then...". They just know: from this score, a point for player 1 leads to that score. The rules are encoded in the transitions, not in conditional logic.


When to Use It​

  • Object behavior depends on its state
  • You have complex conditional logic based on state
  • State transitions should be explicit and validated
  • Building workflows, game logic, or multi-step processes

When NOT to Use It​

If your state is just a boolean flag (on/off, enabled/disabled), a state machine adds unnecessary ceremony. Use it when you have 3+ states with non-trivial transition rules.


API​

  • createMachine β€” Create a finite state machine with typed states and events