# Tic-tac-toe example

A complete two-player engine and native TypeScript viewer. Player 0 is X; player 1
is O. The local test host below lets you play both seats in one browser.

## Engine

Save this as `engine.ts`. It exports every [required engine method](./engine-api.md#required-methods).
The engine validates moves even though the viewer also disables illegal choices.

```ts
export type Player = 0 | 1;
export type Coord = { x: 0 | 1 | 2; y: 0 | 1 | 2 };
type Row = [Player | null, Player | null, Player | null];
type Board = [Row, Row, Row];
export type GameState = {
	board: Board;
	moves: { player: Player; coord: Coord }[];
	winner?: Player;
};
type LogItem =
	| { kind: "event"; event: "start" }
	| { kind: "event"; event: "end"; winner?: Player }
	| { kind: "move"; move: Coord; player: Player };

export function init(players: number): GameState {
	if (players !== 2) throw new Error("Tic-tac-toe needs two players");
	return {
		board: [
			[null, null, null],
			[null, null, null],
			[null, null, null],
		],
		moves: [],
	};
}

function winner(board: Board): Player | undefined {
	const lines = [
		...board,
		...[0, 1, 2].map((y) => board.map((row) => row[y])),
		[board[0][0], board[1][1], board[2][2]],
		[board[0][2], board[1][1], board[2][0]],
	];
	for (const [a, b, c] of lines) {
		if (a !== null && a === b && a === c) return a;
	}
}

export function ended(state: GameState): boolean {
	return state.winner !== undefined || state.moves.length === 9;
}

export function currentPlayer(state: GameState): Player | undefined {
	return ended(state) ? undefined : state.moves.length % 2 === 0 ? 0 : 1;
}

export function move(state: GameState, coord: Coord, player: number): GameState {
	if (ended(state)) throw new Error("The game has ended");
	if ((player !== 0 && player !== 1) || player !== currentPlayer(state)) throw new Error("Not your turn");
	if (!coord || ![coord.x, coord.y].every((n) => Number.isInteger(n) && n >= 0 && n <= 2)) {
		throw new Error("Choose a cell inside the board");
	}
	if (state.board[coord.x][coord.y] !== null) throw new Error("That cell is occupied");
	const next = structuredClone(state);
	next.board[coord.x][coord.y] = player;
	next.moves.push({ player, coord: { x: coord.x, y: coord.y } });
	next.winner = winner(next.board);
	return next;
}

export function scores(state: GameState): number[] {
	return [state.winner === 0 ? 100 : 0, state.winner === 1 ? 100 : 0];
}

export function dropPlayer(state: GameState, player: number): GameState {
	if (player !== 0 && player !== 1) throw new Error("Unknown player");
	return ended(state) ? state : { ...state, winner: player === 0 ? 1 : 0 };
}

export function logLength(state: GameState): number {
	return 1 + state.moves.length + (ended(state) ? 1 : 0);
}

export function logSlice(
	state: GameState,
	{ start = 0, end = logLength(state) - 1 }: { player?: number; start?: number; end?: number } = {},
): LogItem[] {
	const log: LogItem[] = [{ kind: "event", event: "start" }];
	log.push(...state.moves.map(({ player, coord }): LogItem => ({ kind: "move", player, move: coord })));
	if (ended(state)) log.push({ kind: "event", event: "end", winner: state.winner });
	return log.slice(start, end + 1);
}
```

`move` returns the updated game state; `scores` returns the two scores. A win is
worth 100 and a draw gives both players 0. Dropping a player awards the opponent
the win. No players remain active after the game ends.

The log contains a start event, each move, and an end event for a win or draw.
`logSlice` includes both requested endpoints, as BGS requires.

## Viewer

Install the protocol package:

```sh
pnpm add @boardgamers/protocol
```

This viewer uses the engine's shared types, draws X and O with buttons, and sends
cell coordinates to BGS.

Save as `viewer.ts`:

```ts
import { registerViewer } from "@boardgamers/protocol/viewer";

import type { GameState as State, Coord as Move, Player } from "./engine";

const mark = (player: Player) => (player === 0 ? "X" : "O");

// Exported for local.ts below; BGS uses the registered global and needs no export.
export const viewer = registerViewer<State, Move>("tictactoe", ({ target, move: sendMove }) => {
	const root = document.createElement("section");
	const status = document.createElement("p");
	status.setAttribute("role", "status");
	const board = document.createElement("div");
	board.setAttribute("role", "group");
	board.setAttribute("aria-label", "Tic-tac-toe board");
	board.style.cssText = "display:grid;grid-template-columns:repeat(3,1fr);gap:0.4rem;max-width:18rem";
	root.append(status, board);
	target.append(root);

	let state: State | undefined;
	let seat: Player | undefined;
	const finished = () => state !== undefined && (state.winner !== undefined || state.moves.length === 9);
	const canPlay = ({ x, y }: Move) =>
		state !== undefined &&
		!finished() &&
		seat !== undefined &&
		state.moves.length % 2 === seat &&
		state.board[x][y] === null;

	const coordinates = [0, 1, 2] as const;
	const cells = coordinates.flatMap((x) =>
		coordinates.map((y) => {
			const move = { x, y };
			const button = document.createElement("button");
			button.type = "button";
			button.style.cssText = "aspect-ratio:1;font-size:2rem";
			button.addEventListener("click", () => {
				if (canPlay(move)) sendMove(move);
			});
			board.append(button);
			return { button, move };
		}),
	);

	function render() {
		if (!state) {
			status.textContent = "Loading game…";
		} else if (state.winner !== undefined) {
			status.textContent = `${mark(state.winner)} wins!`;
		} else if (finished()) {
			status.textContent = "Draw!";
		} else {
			const next = state.moves.length % 2 === 0 ? "X" : "O";
			status.textContent =
				seat === undefined
					? `Spectating — ${next} to play`
					: state.moves.length % 2 === seat
						? `Your turn (${mark(seat)})`
						: `You are ${mark(seat)} — waiting for ${next}`;
		}
		for (const { button, move } of cells) {
			const occupant = state?.board[move.x][move.y];
			button.textContent = occupant === 0 || occupant === 1 ? mark(occupant) : "·";
			button.disabled = !canPlay(move);
			button.setAttribute(
				"aria-label",
				`Row ${move.x + 1}, column ${move.y + 1}: ${occupant === 0 || occupant === 1 ? mark(occupant) : "empty"}`,
			);
		}
	}

	render();
	return {
		onState(next) {
			state = next;
			render();
		},
		onPlayer({ index }) {
			seat = index === 0 || index === 1 ? index : undefined;
			render();
		},
	};
});
```

BGS supplies the state and the player's seat. The viewer enables empty cells on
your turn and shows the engine's result when the game ends. `registerViewer` handles
launch, state refreshes, readiness, and cleanup between launches.

### Testing locally

Install a development server:

```sh
pnpm add -D vite typescript
```

Create `index.html`:

```html
<!doctype html>
<html lang="en">
	<head>
		<meta charset="utf-8" />
		<meta name="viewport" content="width=device-width, initial-scale=1" />
		<title>Tic-tac-toe</title>
	</head>
	<body>
		<main id="app"></main>
		<script type="module" src="/local.ts"></script>
	</body>
</html>
```

Create `local.ts`. It runs the engine locally and switches seats after each move
so you can play both sides:

```ts
import { viewer } from "./viewer";
import { init, move, currentPlayer } from "./engine";

const events = viewer.launch("#app");
let state = init(2);

function sync() {
	events.emit("player", { index: currentPlayer(state) });
	events.emit("state", structuredClone(state));
}

events.on("fetchState", sync);
events.on("move", (coord) => {
	const player = currentPlayer(state);
	if (player === undefined) return;
	state = move(state, coord, player);
	sync();
});
sync();
```

Run `pnpm exec vite` and open the printed URL.

For BGS, bundle `viewer.ts` as a browser script and configure `tictactoe` as the
viewer's global name in BGS. With Vite, set `build.lib.name` to `tictactoeBundle`
to keep its bundle name separate from the registered viewer.
`local.ts` is only the local test host. See [Adding a game](./adding-a-game.md) for
hosting and the [protocol library](./protocol-library.md) for chat and other events.
