# TypeScript protocol library

`@boardgamers/protocol` provides TypeScript types, event validation, viewer
registration, and chat helpers for BGS games. Your game supplies its own UI and rules.

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

Use `/viewer`, `/chat`, `/testing`, or `/engine` imports for those helpers. The root
export supplies protocol types and schemas. It is an ESM package with declarations;
bundle it into the same browser asset as your viewer. No CDN script or new BGS
option is needed to load the library itself.

## Launch and lifecycle

Here is the integration code for tic-tac-toe, with rendering omitted. The
[tic-tac-toe tutorial](./tictactoe.md#viewer) has the full board and local playtest.

```ts
import { registerViewer } from "@boardgamers/protocol/viewer";
import type { GameState, Coord } from "./engine";

registerViewer<GameState, Coord>("tictactoe", ({ target, move }) => {
	// Mount the board in target; cell clicks call move({ x, y }).

	return {
		onState(state) {
			// Render the board and result; enable empty cells only on your turn.
		},
		onPlayer({ index }) {
			// Store this seat (undefined for spectators) and refresh input controls.
		},
	};
});
```

Configure `tictactoe` as the viewer's global name in BGS. The library handles
registration, state refreshes, readiness, and cleanup. See the
[bundling note](./viewer-api.md#registerviewer) if using Vite.

With a framework, await rendering in `onState` and unmount in `destroy`.
See [optional callbacks](./viewer-api.md#callbacks) and
[incremental logs](./viewer-api.md#incremental-logs) for other features.

## Validation and coverage

Protocol events are validated automatically. For game-specific validation, pass
schemas in the third argument (`mount` is your registration callback):

```ts
registerViewer<GameState, Coord>("tictactoe", mount, {
	stateSchema,
	moveSchema,
	onInvalid: ({ event, message }) => console.warn(event, message),
});
```

Each schema needs a `parse(value: unknown)` method; Zod schemas work directly.
Invalid events are rejected before delivery. Your engine must still validate game
rules. See [validation details](./viewer-api.md#event-validation) for custom adapters.

To inspect handler coverage in a browser test, use the exported viewer from the
[tic-tac-toe tutorial](./tictactoe.md#testing-locally):

```ts
import { viewer } from "./viewer";

viewer.launch("#app");
console.log(viewer.diagnostics());
viewer.destroy();
```

The report separates core and feature support from recommended callbacks. Pass `["chat"]`,
`["replay"]`, or both to `diagnostics` for features you want to check.
Chat and replay are detected from the supplied controller and callbacks by default.
Coverage measures installed handlers, not UI or rule correctness.

## Chat state and rendering

Enable **Chat** in the BGS admin viewer options. Use one `ChatController` per game,
shared by desktop and mobile. Choose a complete panel or bind the behaviors to your own UI.

### Ready-made panel

```ts
import { registerViewer } from "@boardgamers/protocol/viewer";
import { ChatController } from "@boardgamers/protocol/chat";
import { mountChat } from "@boardgamers/protocol/chat/dom";

registerViewer<GameState, Move>("myGame", ({ target, openPlayer }) => {
	// Mount your board and a dedicated chat container in target.
	const chat = new ChatController();
	const panel = mountChat(target.querySelector<HTMLElement>(".chat-host")!, {
		chat,
		openPlayer,
	});

	return {
		chat,
		onState(state) {
			/* Render your board. */
		},
		destroy() {
			panel.destroy(); /* Unmount your board. */
		},
	};
});
```

The panel handles rich text, mention suggestions, sending, errors, scrolling and read
receipts. It starts expanded. Old history does not count as unread; new messages
scroll into view only while the reader is following the latest messages.

Use `panel.open()` from your own chat button, and `chat.subscribe()` to update its
unread badge. Your game chooses where the panel, mobile tabs and shortcuts appear.

From version 0.4.0, `mountChat` also renders **Translate** / **Show original** when BGS
enables translation for the signed-in reader. Language detection and translated text
come from BGS, with the original preserved. Rebuild your viewer with the updated
package; no extra API client or model key is needed. Custom UIs read
`snapshot.translations[messageId]` and call `chat.toggleTranslation(messageId)`.

From version 0.5.0, the shared panel supports editing your own recent messages.
Press ↑ with an empty input, or use the pencil beside a message. Enter saves and
Escape cancels. Cancelling or saving restores any draft you had before editing.
BGS supplies edit availability and enforces the same 15-minute window as the site chat.

Customize colors with `--chat-background`, `--chat-color`, `--chat-border`,
`--chat-input-background`, `--chat-button-background`, `--chat-link-color` and
`--chat-accent`; `--chat-height` controls the feed's maximum height. Set `styles: false`
for your own stylesheet. The DOM uses `.bgs-game-chat`, `.chat-messages`,
`.chat-composer`, `.chat-suggestions`, `.chat-status`, `.chat-day`, `.chat-mention`, `.chat-translate`, `.chat-edit`, `.chat-editing` and `.chat-edit-cancel` classes.
Date dividers separate local calendar days; `labels.date(date)` can customize their text.

Optional `labels` translate the controls. `renderAuthor(message)` returns a DOM node
for player colors or portraits; call `panel.refresh()` when those change. Messages
are rendered as text and validated link/mention segments, never injected HTML.

For a mobile action bar covering the bottom of the screen, supply:

```ts
viewport: () => ({ bottom: window.innerHeight - actionBar.getBoundingClientRect().height });
```

Call `panel.refresh()` when the bar changes height. Covered messages are not marked
read. `onVisibilityChange(visible)` lets you hide a shortcut while the feed is visible.

### Your own Svelte, Vue or other UI

Keep your markup and styles, and use the same input and scrolling behaviors:

```ts
import { bindChatComposer, bindChatViewport } from "@boardgamers/protocol/chat/dom";

// Run after mounting your elements.
const composer = bindChatComposer(input, {
	chat,
	onSuggestions({ candidates, selected }) {
		// Render suggestion buttons; clicking one calls composer.choose(index).
	},
});
const feed = bindChatViewport(messageList, { chat, contents: messageContents });

// On unmount:
composer.destroy();
feed.destroy();
```

Render snapshots from `chat.subscribe()`. Put `data-message-id={message._id}` on each
message row. The viewport binding observes rendering and size changes; it follows
new messages and reports reads when message endings are visible in the browser tab,
including through the BGS iframe. Clicking inside the game is not required.
Use `isVisible` for a custom visibility condition and `viewport` for overlays.

For date dividers, `chatDateSeparators(messages)` from `/chat` returns one entry per
message: `{ dateTime, label }` at each new day, otherwise `undefined`. Render the
label before that message. Missing timestamps are skipped.

The composer owns the input value and events, including Enter, keyboard mention
selection and IME composition. Render suggestions and the Send button yourself;
Send calls `chat.submit()`. Do not also bind the input's value or keyboard handlers.

For editing in a custom UI, show a pencil only when `chat.canEdit(message)` returns
true and call `composer.edit(message._id)` to enter editing and focus the input.
When `snapshot.editingId` is nonempty, label your submit button with
`chat.editLabels.save`, show a cancel control calling `chat.cancelEditing()`, and
disable it while `snapshot.pending` is nonempty. `chat.submit()` saves edits through
the same acknowledgement flow as sending. Use `chat.editLabels.edit` and
`chat.editLabels.cancel` for localized controls. `bindChatComposer` already handles
↑, Escape, original text, mentions and the pending input state.

Bindings and panels clean up their own listeners and observers. They do not destroy
the controller: the viewer lifecycle owns it. See the [chat API reference](./viewer-api.md#chatcontroller-reference)
for the lower-level controller.

## Local host and tests

```ts
import assert from "node:assert/strict";
import { ChatController } from "@boardgamers/protocol/chat";
import { createViewer } from "@boardgamers/protocol/viewer";
import { createTestHost } from "@boardgamers/protocol/testing";

const chat = new ChatController();
const viewer = createViewer({ onState() {}, chat });
const host = createTestHost(viewer.emitter);
host.receive("chat:state", { canSend: true });
host.receive("chat:messages", []);
chat.setDraft("Hello");
chat.submit();
assert.equal(host.events.at(-1)?.event, "chat:send");
host.receive("chat:result", { requestId: chat.snapshot.pending, ok: false, error: "Rate limited" });
assert.equal(chat.snapshot.draft, "Hello");
viewer.destroy();
host.destroy();
```

This runs in Node without a browser. `host.receive` sends test inputs to the viewer;
`host.events` records its responses. The helper simulates event delivery; your tests
supply state, permissions, and acknowledgements.

## Engine contract

`defineEngine<State, Move>` type-checks an engine object and returns it unchanged.
BGS calls named engine exports directly: no emitter, browser global, or registration
is needed. Export the methods from your engine package's entry point, as shown below.
See the [engine API](./engine-api.md) for required and optional methods.

In this two-player example, players alternate adding 1 or 2 points to their own
score. The first to reach 5 points wins. If a player leaves, the game is cancelled.

```ts
import { defineEngine, inspectEngine } from "@boardgamers/protocol/engine";

type GameState = {
	players: { score: number }[];
	turn: number;
	history: string[];
	cancelled: boolean;
};
type Move = { add: 1 | 2 };

const isFinished = (state: GameState) => state.cancelled || state.players.some((player) => player.score >= 5);

const engine = defineEngine<GameState, Move>({
	init: (playerCount) => {
		if (playerCount !== 2) throw new Error("This game needs two players");
		return { players: [{ score: 0 }, { score: 0 }], turn: 0, history: [], cancelled: false };
	},
	move: (state, move, playerIndex) => {
		if (isFinished(state) || playerIndex !== state.turn) throw new Error("Not your turn");
		if (move.add !== 1 && move.add !== 2) throw new Error("Add 1 or 2 points");
		return {
			...state,
			players: state.players.map((player, index) =>
				index === playerIndex ? { score: player.score + move.add } : player,
			),
			turn: 1 - playerIndex,
			history: [...state.history, `Player ${playerIndex + 1} adds ${move.add} points`],
		};
	},
	ended: isFinished,
	scores: (state) => state.players.map((player) => player.score),
	dropPlayer: (state) => ({ ...state, cancelled: true }),
	cancelled: (state) => state.cancelled,
	currentPlayer: (state) => (isFinished(state) ? undefined : state.turn),
	logLength: (state) => state.history.length,
	logSlice: (state, { start = 0, end = state.history.length - 1 } = {}) => state.history.slice(start, end + 1),
});
console.log(inspectEngine(engine));
export const { init, move, ended, scores, dropPlayer, cancelled, currentPlayer, logLength, logSlice } = engine;
```

`init` and `move` return the full `GameState` object. `scores` only reads that state
and returns one score per player in seat order: for example, `[5, 3]` means player
0 has 5 points and player 1 has 3. `currentPlayer` returns the seat whose turn it
is, or `undefined` once the game ends.

`inspectEngine` reports required and optional functions separately. Optional coverage
is informational: implement only hooks your game needs. Test their behavior separately.

## Tutorials

`/tutorial` runs guided local chapters with engine-validated actions, checkpoints and saved progress. `/tutorial/dom` supplies an optional guide that you can style to match your game. See [Playable tutorials](./tutorials.md).
