learn / examples
Rock-Paper-Scissors
Game state machine managing complete game flow from player selection through winner determination.
import { createMachine, effect, eventApi, setup } from "matchina";import { randomMove } from "./game-utils";import { states } from "./states";import { createStore } from "./store";
export function createRPSMachine() { const store = createStore();
const baseMachine = createMachine( states, { WaitingForPlayer: { selectMove: "PlayerChose", }, PlayerChose: { computerSelectMove: "Judging", }, Judging: { judge: "RoundComplete", }, RoundComplete: { nextRound: "WaitingForPlayer", gameOver: "GameOver", }, GameOver: { newGame: "WaitingForPlayer", }, }, "WaitingForPlayer" );
const machine = Object.assign(baseMachine, { store }, eventApi(baseMachine));
// bind machine to store setup(machine)( effect((ev) => { ev.match( { selectMove: store.setPlayerMove, computerSelectMove: () => { store.setComputerMove(randomMove()); }, judge: () => { store.judgeRound(); store.checkGameOver(); if (store.getState().gameWinner) { machine.gameOver(); } }, nextRound: store.clearRound, newGame: store.reset, }, false ); }) ); return machine;}
export type RPSMachine = ReturnType<typeof createRPSMachine>;import { defineStates } from "matchina";
import type { Move } from "./store";
export const states = defineStates({ WaitingForPlayer: undefined, PlayerChose: (move: Move) => ({ move }), Judging: undefined, RoundComplete: undefined, GameOver: undefined,});import { createStoreMachine, storeApi } from "@lib/src";import { determineWinner } from "./game-utils";
export type Move = "rock" | "paper" | "scissors";
interface GameState { playerMove: Move | null; computerMove: Move | null; playerScore: number; computerScore: number; roundWinner: "player" | "computer" | "tie" | null; gameWinner: "player" | "computer" | null;}const initialState: GameState = { playerMove: null, computerMove: null, playerScore: 0, computerScore: 0, roundWinner: null, gameWinner: null,};export function createStore() { const baseStore = createStoreMachine<GameState>(initialState, { setPlayerMove: (move: Move) => (change) => ({ ...change.from, playerMove: move, }), setComputerMove: (move: Move) => (change) => ({ ...change.from, computerMove: move, }), judgeRound: () => (change) => { const { playerMove, computerMove, playerScore, computerScore } = change.from; if (!playerMove || !computerMove) return change.from; const winner = determineWinner(playerMove, computerMove); return { ...change.from, roundWinner: winner, playerScore: playerScore + (winner === "player" ? 1 : 0), computerScore: computerScore + (winner === "computer" ? 1 : 0), }; }, checkGameOver: () => (change) => { const { playerScore, computerScore } = change.from; if (playerScore >= 5) return { ...change.from, gameWinner: "player" as const }; if (computerScore >= 5) return { ...change.from, gameWinner: "computer" as const }; return change.from; }, reset: () => () => initialState, clearRound: () => (change) => ({ ...change.from, playerMove: null, computerMove: null, roundWinner: null, }), });
return Object.assign(baseStore, storeApi(baseStore));}import { useEffect } from "react";import { useMachine } from "matchina/react";import type { RPSMachine } from "./machine";import type { Move } from "./store";
const moveIcons: Record<Move, string> = { rock: "β", paper: "ποΈ", scissors: "βοΈ",};
const moveLabel: Record<Move, string> = { rock: "Rock", paper: "Paper", scissors: "Scissors",};
const moveAccentBg: Record<Move, string> = { rock: "hover:bg-[oklch(0.24_0.08_38)] hover:border-[oklch(0.40_0.12_38)]", paper: "hover:bg-[oklch(0.22_0.06_240)] hover:border-[oklch(0.38_0.10_240)]", scissors: "hover:bg-[oklch(0.24_0.08_12)] hover:border-[oklch(0.40_0.12_12)]",};
function ThinkingDots() { return ( <span className="inline-flex items-center gap-1"> {[0, 1, 2].map((i) => ( <span key={i} className="w-1 h-1 rounded-full bg-current opacity-50 animate-bounce" style={{ animationDelay: `${i * 0.15}s`, animationDuration: "0.9s" }} /> ))} </span> );}
function MoveButton({ move, onClick }: { move: Move; onClick: () => void }) { return ( <button type="button" onClick={onClick} className={`flex flex-col items-center gap-1.5 flex-1 py-4 rounded-xl border border-[oklch(0.28_0.02_240)] bg-[oklch(0.16_0.01_240)] transition-all duration-150 cursor-pointer select-none active:scale-95 group ${moveAccentBg[move]}`} > <span className="text-5xl leading-none">{moveIcons[move]}</span> <span className="text-[8px] font-mono uppercase tracking-widest text-[oklch(0.50_0.02_240)] group-hover:text-[oklch(0.72_0.02_240)] transition-colors"> {moveLabel[move]} </span> </button> );}
function MoveDisplay({ move, label, dim }: { move: Move | null; label: string; dim?: boolean }) { return ( <div className="flex flex-col items-center gap-1.5 w-20"> <span className={`text-5xl leading-none transition-all duration-300 ${dim ? "opacity-25 scale-90" : ""}`}> {move ? moveIcons[move] : "β"} </span> <span className="text-[8px] font-mono uppercase tracking-widest text-[oklch(0.42_0.02_240)]">{label}</span> </div> );}
export function RPSAppView({ machine }: { machine: RPSMachine }) { useMachine(machine); useMachine(machine.store); const currentState = machine.getState(); const storeData = machine.store.getState();
useEffect(() => { if (!currentState.is("PlayerChose")) return; const t = setTimeout(() => machine.computerSelectMove(), 600); return () => clearTimeout(t); }, [currentState.key]);
useEffect(() => { if (!currentState.is("Judging")) return; const t = setTimeout(() => machine.judge(), 500); return () => clearTimeout(t); }, [currentState.key]);
return ( <div className="max-w-xs mx-auto flex flex-col gap-3 py-3"> {/* Unified game card */} <div className="bg-[oklch(0.13_0.01_240)] rounded-2xl border border-[oklch(0.22_0.01_240)] overflow-hidden">
{/* Scoreboard β inset header */} <div className="flex items-center border-b border-[oklch(0.20_0.01_240)]"> <div className="flex flex-col items-center gap-0.5 flex-1 py-4"> <span className="text-[8px] font-mono uppercase tracking-widest text-[oklch(0.42_0.02_240)]">You</span> <span className="text-3xl font-black tabular-nums text-[oklch(0.88_0.01_240)]">{storeData.playerScore}</span> </div> <div className="w-px h-10 bg-[oklch(0.24_0.01_240)]" /> <div className="flex flex-col items-center gap-0.5 flex-1 py-4"> <span className="text-[8px] font-mono uppercase tracking-widest text-[oklch(0.42_0.02_240)]">CPU</span> <span className="text-3xl font-black tabular-nums text-[oklch(0.88_0.01_240)]">{storeData.computerScore}</span> </div> </div>
{/* Arena β fixed height so card doesn't jump */} <div className="flex flex-col items-center justify-center min-h-[220px] px-4 py-5"> {currentState.match({ WaitingForPlayer: () => ( <div className="flex flex-col items-center gap-4 w-full"> <span className="text-[8px] font-mono uppercase tracking-widest text-[oklch(0.42_0.02_240)]"> Choose your move </span> <div className="flex gap-2 w-full"> {(["rock", "paper", "scissors"] as Move[]).map((move) => ( <MoveButton key={move} move={move} onClick={() => machine.selectMove(move)} /> ))} </div> </div> ),
PlayerChose: () => ( <div className="flex flex-col items-center gap-4 w-full"> <span className="text-[8px] font-mono uppercase tracking-widest text-[oklch(0.42_0.02_240)] flex items-center gap-1.5"> CPU thinking <ThinkingDots /> </span> <div className="flex items-center justify-around w-full px-4"> <MoveDisplay move={storeData.playerMove} label="You" /> <span className="text-xs font-mono text-[oklch(0.30_0.01_240)]">vs</span> <div className="flex flex-col items-center gap-1.5 w-20"> <span className="text-5xl leading-none animate-pulse">π€</span> <span className="text-[8px] font-mono uppercase tracking-widest text-[oklch(0.42_0.02_240)]">CPU</span> </div> </div> </div> ),
Judging: () => ( <div className="flex flex-col items-center gap-4 w-full"> <span className="text-[8px] font-mono uppercase tracking-widest text-[oklch(0.42_0.02_240)] flex items-center gap-1.5"> Judging <ThinkingDots /> </span> <div className="flex items-center justify-around w-full px-4"> <MoveDisplay move={storeData.playerMove} label="You" /> <span className="text-sm font-black text-[oklch(0.30_0.01_240)]">VS</span> <MoveDisplay move={storeData.computerMove} label="CPU" /> </div> </div> ),
RoundComplete: () => { const winner = storeData.roundWinner; const resultLabel = !winner ? "Tie" : winner === "player" ? "You win" : "CPU wins"; const resultColor = !winner ? "text-[oklch(0.52_0.02_240)]" : winner === "player" ? "text-[oklch(0.72_0.18_142)]" : "text-[oklch(0.65_0.18_15)]"; return ( <div className="flex flex-col items-center gap-4 w-full"> <span className={`text-2xl font-black tracking-tight ${resultColor}`}>{resultLabel}</span> <div className="flex items-center justify-around w-full px-4"> <MoveDisplay move={storeData.playerMove} label="You" dim={!!(winner && winner !== "player")} /> <span className="text-sm font-black text-[oklch(0.26_0.01_240)]">VS</span> <MoveDisplay move={storeData.computerMove} label="CPU" dim={!!(winner && winner !== "computer")} /> </div> <button type="button" onClick={() => machine.nextRound()} className="btn btn-primary btn-sm"> Next Round </button> </div> ); },
GameOver: () => { const playerWon = storeData.playerScore > storeData.computerScore; return ( <div className="flex flex-col items-center gap-4"> <span className="text-5xl leading-none">{playerWon ? "π" : "π"}</span> <div className="text-center"> <p className="text-lg font-black text-[oklch(0.88_0.01_240)]"> {playerWon ? "You win" : "CPU wins"} </p> <p className="text-xs text-[oklch(0.42_0.02_240)] mt-0.5 font-mono tabular-nums"> {storeData.playerScore} β {storeData.computerScore} </p> </div> <button type="button" onClick={() => machine.newGame()} className="btn btn-outline btn-sm"> Play Again </button> </div> ); }, })} </div> </div>
{/* State badge */} <div className="text-center"> <span className="badge badge-outline text-[10px]">{currentState.key}</span> </div> </div> );}import { useState } from "react";import { RPSAppView } from "./RPSAppView";import { createRPSMachine } from "./machine";
export function RockPaperScissors() { const [game] = useState(() => createRPSMachine()); return <RPSAppView machine={game} />;}When to use this pattern
Section titled βWhen to use this patternβTurn-based games with distinct phases and rules. Interactive applications that need to enforce business logic. Scenarios where actions are only valid in certain states, or where complex conditional logic depends on current state.
State machines enforce game rules through transitions β preventing invalid actions by restricting available events per state. Each phase is explicit and independently testable.
import { createMachine, effect, eventApi, setup } from "matchina";import { randomMove } from "./game-utils";import { states } from "./states";import { createStore } from "./store";
export function createRPSMachine() { const store = createStore();
const baseMachine = createMachine( states, { WaitingForPlayer: { selectMove: "PlayerChose", }, PlayerChose: { computerSelectMove: "Judging", }, Judging: { judge: "RoundComplete", }, RoundComplete: { nextRound: "WaitingForPlayer", gameOver: "GameOver", }, GameOver: { newGame: "WaitingForPlayer", }, }, "WaitingForPlayer" );
const machine = Object.assign(baseMachine, { store }, eventApi(baseMachine));
// bind machine to store setup(machine)( effect((ev) => { ev.match( { selectMove: store.setPlayerMove, computerSelectMove: () => { store.setComputerMove(randomMove()); }, judge: () => { store.judgeRound(); store.checkGameOver(); if (store.getState().gameWinner) { machine.gameOver(); } }, nextRound: store.clearRound, newGame: store.reset, }, false ); }) ); return machine;}
export type RPSMachine = ReturnType<typeof createRPSMachine>;