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.
- tennisMachine.ts
- Usage
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;
}
import { RotateCcw } from "lucide-react";
import { useTennisGame } from "@/hooks/useTennisGame";
import { ClayNoiseOverlay, clayCourtStyle } from "./ClayNoiseOverlay";
import { Scoreboard } from "./Scoreboard";
import { GameControls } from "./GameControls";
import { FlipDigit } from "./FlipDigit";
import { LedClock } from "./LedClock";
import { useGameClock } from "@/hooks/useGameClock";
import { TransitionHistory } from "./TransitionHistory";
const bigScoreColor = (val: string) => {
if (val === "AD") return "#f59e0b";
if (val === "β") return "#6b7280";
return "#ffffff";
};
export function TennisScoreboard() {
const { currentState, phase, winner, statusLabel, history, transition, handlePoint, handleReset } = useTennisGame();
const clock = useGameClock(history.length, phase === "gameOver");
return (
<>
{/* Mobile layout */}
<div className="flex flex-col h-screen md:hidden">
<div className="shrink-0 px-4 pt-4 pb-3" style={{ ...clayCourtStyle, background: "#c75b12" }}>
<ClayNoiseOverlay />
<div style={{ position: "relative", zIndex: 2 }}>
<div className="flex items-center justify-between mb-3">
<h2 className="text-lg font-semibold text-white">πΎ Tennis Scoreboard</h2>
<ResetButton onClick={handleReset} />
</div>
<Scoreboard phase={phase} winner={winner} statusLabel={statusLabel} transition={transition} />
</div>
</div>
<div className="shrink-0 px-4 py-3" style={{ ...clayCourtStyle, background: "#b34e0f" }}>
<ClayNoiseOverlay />
<div className="flex flex-col gap-3" style={{ position: "relative", zIndex: 2 }}>
<p className="text-white/80 text-sm text-center">Tap to score a point<br />Watch the state machine transitions</p>
<GameControls phase={phase} onPoint={handlePoint} layout="column" />
</div>
</div>
<div className="flex-1 min-h-0 overflow-hidden px-4 py-3 bg-slate-100">
<TransitionHistory history={history} fillHeight />
</div>
</div>
{/* Desktop layout */}
<div className="hidden md:block max-w-5xl mx-auto py-8 px-4 space-y-6">
{/* Top row: scoreboard + big score β single orange block */}
<section className="rounded-xl shadow-lg overflow-hidden" style={{ ...clayCourtStyle, background: "rgba(0,0,0,0.2)" }}>
<ClayNoiseOverlay />
<div className="flex relative" style={{ zIndex: 2 }}>
{/* Left 2/3: scoreboard + controls */}
<div className="w-2/3 p-6 border-r border-white/10">
<h2 className="text-lg font-semibold text-white mb-4">πΎ Tennis Scoreboard</h2>
<Scoreboard phase={phase} winner={winner} statusLabel={statusLabel} transition={transition} />
<hr className="border-white/20 my-4" />
<p className="text-white/80 text-sm text-center mb-3">Tap to score a point Β· Watch the state machine transitions</p>
<div className="flex flex-col gap-3">
<GameControls phase={phase} onPoint={handlePoint} />
<ResetButton onClick={handleReset} showLabel />
</div>
</div>
{/* Right 1/3: big score */}
<div className="w-1/3 flex flex-col items-center justify-center px-10 py-6" style={{ background: "#1a3829" }}>
<div className="text-sm text-white/50 uppercase tracking-wider mb-4">Current Game</div>
<div className="grid grid-cols-[1fr_auto_1fr] items-center gap-x-6">
<div className="w-24 h-20 overflow-hidden">
<FlipDigit
from={phase === "gameOver" ? transition.from.p1 : transition.from.p1}
to={phase === "gameOver" ? "β" : transition.to.p1}
direction={transition.directions.p1}
textClass="font-bold text-5xl tabular-nums"
colorFn={bigScoreColor}
/>
</div>
<span className="text-3xl text-white/30 font-light">β</span>
<div className="w-24 h-20 overflow-hidden">
<FlipDigit
from={phase === "gameOver" ? transition.from.p2 : transition.from.p2}
to={phase === "gameOver" ? "β" : transition.to.p2}
direction={transition.directions.p2}
textClass="font-bold text-5xl tabular-nums"
colorFn={bigScoreColor}
/>
</div>
<div className="text-sm text-white/50 text-center mt-2">Nadal</div>
<div />
<div className="text-sm text-white/50 text-center mt-2">Djokovic</div>
</div>
<hr className="border-white/20 w-full my-4" />
{/* Match clock β LED display */}
<div className="mt-6 text-center">
<LedClock value={clock} />
<div className="text-sm text-white/50 uppercase tracking-wider mt-1">Match Time</div>
</div>
</div>
</div>
</section>
{/* Bottom: State Machine (horizontal) */}
<section className="bg-white/70 rounded-xl shadow-sm border border-slate-200 p-6">
<div className="flex items-start gap-8">
<div className="shrink-0">
<h2 className="text-lg font-semibold text-slate-900 mb-2">State Machine</h2>
<div className="p-3 bg-slate-50 rounded-lg">
<div className="text-xs text-slate-500 mb-1">Current State</div>
<div className="text-xl font-mono font-bold" style={{ color: "#c75b12" }}>{currentState}</div>
</div>
</div>
<div className="flex-1 min-w-0">
<TransitionHistory history={history} />
</div>
</div>
</section>
{/* Key insight */}
<section className="bg-amber-50 rounded-xl shadow-sm border border-amber-200 p-5">
<p className="text-sm text-amber-800">
<strong>Key insight:</strong> No if/else, just a lookup in STATE_METADATA[currentState].
The Deuce β Advantage loop is encoded in transitions, not conditionals.
</p>
</section>
</div>
</>
);
}
function ResetButton({ onClick, showLabel }: { onClick: () => void; showLabel?: boolean }) {
return (
<button
onClick={onClick}
className={`${showLabel ? "w-full py-3 px-4" : "py-2 px-3"} bg-slate-700 hover:bg-slate-600 text-white rounded-lg transition-colors flex items-center justify-center gap-2`}
aria-label="Reset game"
>
<RotateCcw className={showLabel ? "w-5 h-5" : "w-4 h-4"} />
{showLabel && "Reset"}
</button>
);
}
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