# Viewer API

For TypeScript types, validation, and reusable integration helpers, see the [protocol library](./protocol-library.md).

BGS loads your viewer in an iframe and calls `window[name].launch(selector)` to get
its event emitter. [`registerViewer`](#registerviewer) creates this entry point for
you. You supply the UI and handlers for the events below.

For bundle hosting, see [Adding a game](./adding-a-game.md).

Use plain data for event payloads. The iframe bridge uses `postMessage`; functions,
DOM nodes, and reactive proxies cannot be cloned. Although structured cloning can
copy `Map` and `Set`, BGS payloads should use JSON-compatible objects and arrays.
For a Svelte 5 `$state` value, send `$state.snapshot(value)`.

[[toc]]

## Viewer registration

### registerViewer

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

registerViewer<State, Move>(name, mount, {
	stateSchema,
	moveSchema,
	onInvalid: ({ event, message }) => console.warn(event, message),
});
```

The optional third argument validates game-specific payloads. `name` must be an
unused JavaScript global identifier, such as `tictactoe`; configure that name in BGS.
The library registers the global itself. If your bundler requires an IIFE name
(Vite's `build.lib.name`), use a different one, such as `tictactoeBundle`.

`mount({ target, move, openPlayer, ... })` runs on each launch. It receives the
selected DOM container and the commands below. Return `onState` and whichever
optional callbacks your UI needs. No emitter is exposed to the mount callback.

Create components and chat controllers inside `mount` so each launch gets fresh
instances. Mounting is synchronous; `onState` and `onLog` may await rendering.
Failures in `onState`, `onUpdate`, or `onLog` call `onError` (or log the error);
later callbacks can still run.

The return value of `registerViewer` is only needed for local tests or explicit cleanup:

| Method                       | Behavior                                                                                                    |
| ---------------------------- | ----------------------------------------------------------------------------------------------------------- |
| `launch(selector)`           | Replace the previous UI and return the transport emitter. BGS calls this; a local host can too.             |
| `diagnostics(capabilities?)` | Report the current instance's handler coverage. Before launch or after disposal, no handlers are installed. |
| `destroy()`                  | Dispose the instance; safe to repeat. The registration remains available for another launch.                |

The library sends `ready` after the first successful state render. By default it
requests full state on `state:updated` and `gamelog`; `onUpdate` and `onLog`
respectively replace those defaults. On disposal it removes listeners, cancels timers,
destroys chat, calls your `destroy` hook, and empties the mount container.
See the [short example](./protocol-library.md#launch-and-lifecycle) or
[complete tic-tac-toe viewer](./tictactoe.md#viewer).

### Commands

Destructure these from the mount callback's argument. They are also methods on the
object returned by `createViewer`.

| Command                                        | Purpose                                                        |
| ---------------------------------------------- | -------------------------------------------------------------- |
| `move(payload)`                                | Submit your game's typed move, such as `move({ x: 1, y: 2 })`. |
| `openPlayer(index)`                            | Open the profile for a zero-based player seat.                 |
| `hoverPlayer(index, anchor)` / `leavePlayer()` | Show or dismiss the host player card.                          |
| `openBoardgame()`                              | Open the current boardgame page.                               |
| `updatePreference(name, value)`                | Save a preference; values are strings, booleans, or `null`.    |
| `fetchState()`                                 | Request the full state. Updates trigger this by default.       |
| `fetchLog({ start, end? })`                    | Request a log range, inclusive of both endpoints.              |
| `addLog(lines)` / `replaceLog(lines)`          | Append or replace string entries in BGS's journal.             |
| `setReplayInfo({ start, current, end })`       | Update BGS's replay controls.                                  |
| `undo()`                                       | Take back the player's last move (games against bots, 0.10.0). |

Commands return `false` for invalid payloads or after disposal. `true` means the
payload passed local validation, **not** that BGS accepted the action. The engine
still validates moves. Use `ChatController` for chat sends and read reports;
readiness is handled internally.

### Callbacks

Return these from `mount`, or pass them as options to `createViewer`:

| Callback                                                  | Purpose                                                                    |
| --------------------------------------------------------- | -------------------------------------------------------------------------- |
| `onState(state)`                                          | **Required.** Render the game state; may return a promise.                 |
| `onUpdate()`                                              | Request an update, replacing the default `fetchState()`.                   |
| `onPlayer({ index })`                                     | Store the viewer's seat; `undefined` means spectator.                      |
| `onPreferences(preferences)`                              | Apply game preferences, including shared sound and color-blind settings.   |
| `onAvatars(urls)`                                         | Apply player avatars in seat order.                                        |
| `onTheme({ dark })`                                       | Apply theme changes.                                                       |
| `onLog({ start, end?, data })`                            | Apply a log slice; may return a promise. Replaces the default state fetch. |
| `onReplayStart()` / `onReplayTo(index)` / `onReplayEnd()` | Implement replay, and call `setReplayInfo` as the position changes.        |
| `onUndoAvailable(available)`                              | Show or hide your undo control (games against bots, 0.10.0).               |
| `onError(error)`                                          | Handle a failed state, update, or log callback.                            |

Only `onState` is required to construct the helper. Interactive games should also
handle `onPlayer`; the core coverage check flags missing player handling. Omit
unused callbacks instead of adding empty ones.
Return a `chat` controller to connect chat, and `destroy()` to unmount your framework.

### createViewer and ViewerEmitter

`createViewer({ onState, onPlayer?, chat?, ... })` supplies the same commands,
callbacks, validation, state refreshes, and readiness handling without registering
a global or managing DOM. It returns the commands plus `diagnostics`, `destroy`,
and an `emitter` for connecting BGS or a local test host.

Use `new ViewerEmitter<State, Move>(validation?)` only when you need to own the
transport and lifecycle yourself. Incremental logs work with the helpers above. It provides typed,
validated events without automatic handlers. `on` and `once` return cleanup
functions. `destroy` permanently disconnects it; later events return `false`.
`attachChat(emitter, chat)` installs chat handlers and returns cleanup that also
destroys the controller.

### Incremental logs

BGS sends an initial state, update notifications, and log slices (including the
result of your own move). Return `onUpdate` and `onLog` to apply slices instead of
fetching the full state after each change. The same callbacks work with
`registerViewer` and `createViewer`.

This example expects `state.log` and `logSlice` to contain arrays of strings:

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

type LogState = { log: string[] };

registerViewer<LogState, never>("logDemo", ({ target, fetchLog }) => {
	let entries: string[] = [];
	const render = () => {
		target.textContent = entries.join("\n");
	};

	return {
		onState(state) {
			entries = [...state.log];
			render();
		},
		onUpdate() {
			fetchLog({ start: entries.length });
		},
		onLog({ start, data }) {
			if (!Array.isArray(data) || !data.every((entry) => typeof entry === "string")) {
				throw new Error("Invalid log slice");
			}
			if (start > entries.length) {
				fetchLog({ start: entries.length });
				return;
			}
			entries.splice(start, data.length, ...data);
			render();
		},
	};
});
```

`onState`, `onUpdate`, and `onLog` run in arrival order, awaiting each callback.
Issue requests from `onUpdate` and handle their replies in `onLog`; do not wait
inside one callback for a later queued callback to run.

The viewer owns the cursor because `logSlice` can return any structure. Merge by
`start` to handle overlapping slices, and request missing entries when there is a
gap. If you also need fresh private state or other data absent from the log, call
`fetchState()` explicitly. Without `onUpdate` or `onLog`, that event keeps its
default full-state refresh.

### Event validation

The emitter checks event payloads before delivery. Unknown event names return
`false`; invalid known events call `onInvalid` (or `console.warn`) and return
`false`. The library validates game-specific state and moves only when you supply
`stateSchema` and `moveSchema`. Each validator implements `parse(value: unknown)`.

Custom adapters can use `validateEvent(name, payload)` or `downlinkSchemas` and
`uplinkSchemas` from `@boardgamers/protocol`. `validateEvent` returns parsed data or
throws. These describe emitter payloads, not the bridge's raw window messages.
The game engine remains responsible for authorization-sensitive game rules.

### Handler coverage

Use `registration.diagnostics()` after launch, or `viewer.diagnostics()` with
`createViewer`. For a custom emitter, use `inspectViewer(emitter, capabilities?)`.

| Group       | Checked handlers                                                                                               |
| ----------- | -------------------------------------------------------------------------------------------------------------- |
| Core        | `state`, `state:updated`, `player`                                                                             |
| Chat        | `chat:messages`, `chat:appended`, `chat:updated`, `chat:deleted`, `chat:state`, `chat:disabled`, `chat:result` |
| Replay      | `replay:start`, `replay:to`, `replay:end`                                                                      |
| Recommended | `preferences`, `avatars`                                                                                       |

Theme handling appears separately under `optional`; a single-theme viewer need
not implement it.

Each group has `completed`, `total`, `percent`, and `missing` fields. `compatible`
means all checked required groups are complete. A connected chat controller enables
the chat checks; any replay callback enables all three replay checks. Pass an
explicit array, such as `["chat", "replay"]`, to choose the capabilities to check.
`inspectViewer` checks only core unless capabilities are supplied.

Only installed handlers count, including those implemented by the library. An
omitted callback does not install a placeholder listener. Coverage measures wiring,
not UI behavior or rule correctness; optional features need no empty handlers.

## Downlink

This section describes the underlying BGS events for custom adapters. With
`registerViewer` or `createViewer`, use the [callbacks](#callbacks) above; chat
events are connected through `ChatController`. These events flow from BGS to the viewer:

```ts
emitter.on("state", (state) => {
	// Render the new state
});
```

### state

```ts
emitter.on("state", (state: GameData) => {
	// ...
});
```

Receive game data filtered by [stripSecret](./engine-api.md#stripsecret) for this player or spectator.

Replace the previous viewer state with this snapshot.

### state:updated

```ts
emitter.on("state:updated", () => {
	// ...
});
```

Notification that new state is available.

You can request the full state by emitting [fetchState](#fetchstate) or request the new log elements by emitting [fetchLog](#fetchlog).

### gamelog

```ts
emitter.on("gamelog", (logData: { start: number; end?: number; data: unknown }) => {
	//...
});
```

Receive log data.

`data` is the return value of the backend's [logSlice](./engine-api.md#logslice).

### preferences

```ts
emitter.on("preferences", (preferences: Record<string, unknown>) => {
	//...
});
```

Get the user's UI preferences for this game, including the shared `sound` and `colorBlind` booleans.

`preferences.chatNotifications` tells the viewer whether to show floating unread-chat reminders.
Hide those reminders when it is `false`: a BGS rail or floating shortcut already provides them.
BGS sends `true` when neither shortcut is visible. Keep the embedded chat accessible either way.
Treat a missing value as `true`; this is a live layout hint, not a saved user preference.

`preferences.bgs` (typed as `PlayerAppearance` in protocol 0.8.1) carries public appearance in **seat order**:

```ts
{
  players: [{ pro: true }, { pro: false }],
  supporterBadge: { url: "https://boardgamers.space/assets/supporter.svg", label: "Supporter" },
  playerColors: ["#4595d1", "#e68b32"]
}
```

For seats with `pro: true`, display the host's `supporterBadge.url` beside the player's avatar or name, with `supporterBadge.label` as its accessible label. Do not embed badge artwork in viewers; hide it when the seat flag or badge is absent. `playerColors` contains the final cosmetic colours resolved centrally by BGS for every seat; viewers must use this array without resolving conflicts again. An empty array restores the game's defaults. The host preserves unaffected seats and only replaces colours that are identical or almost identical; choosing a similar colour name alone does not cause reassignment. Apply updates without mutating engine state or move identities. Keep faction colours and accessibility palettes authoritative. The host derives this data from membership; ignore persisted game preferences under the reserved `bgs` key. Older hosts can omit it.

Games with player-chosen cosmetic colours can instead use `preferences.preferredPlayerColors`:

```ts
{
	preferredPlayerColors: ["#da73aa", null];
}
```

This separate, BGS-owned field contains only actual custom preferences, normalized to six-digit hex in seat order. `null` means keep that player's game colour. Unlike `bgs.playerColors`, it is not a complete, conflict-resolved palette. Apply custom preferences to the game's chosen palette, preserving unaffected seats. Resolve identical or almost identical colours with the viewing player's custom preference first, then seat order (spectators use seat order); use a distinct fallback for conflicts. BGS uses squared RGB distance below `24²` as its collision threshold. Keep accessibility palettes authoritative and update displayed colours without changing game state. The host overwrites persisted values for this field. It can be absent on older hosts; do not assume that every entry in the legacy `bgs.playerColors` array is a custom preference.

For example, a game may expose a preference for flat building graphics.

#### devMode

On top of the game's own preferences, the platform injects a `devMode: true` key when the user has developer
settings enabled on their device. When developer settings are off, the key is entirely absent — check
`preferences.devMode === true`.

### player

```ts
emitter.on("player", (playerInfo: { index?: number }) => {
	// ...
});
```

Receive the connected player's zero-based seat index. For spectators, `index` is
`undefined`; start with no selected seat until this event arrives.

### Sound (in-game audio)

If your game has a `sound` entry in its preferences, the site reserves that key: `preferences.sound` in the
[preferences](#preferences) message is the user's **global, site-wide** in-game sound setting, re-sent live
whenever it changes. Your viewer must honor it: mute when it is `false`. Games without a `sound` preference get
no sound control on the site.

A viewer-side sound toggle emits `update:preference {name: "sound", value}` as usual; the site routes it to the
global setting (broadcast back to all the user's games). There is no per-game override.

### Color-blind support

`preferences.colorBlind` is a reserved, **site-wide** boolean, false by default. Users can change it in
Account → Game preferences or any supporting game's preferences. Logged-in users keep the setting across
games and devices; anonymous visitors keep it in their browser. The global value overrides any old per-game
value. Existing enabled settings from Gaia Project, Fuji and Primordial Soup are carried over to the account.

Declare support in your game info so BGS displays the shared control:

```json
{ "name": "colorBlind", "label": "Color-blind mode", "type": "checkbox", "default": false }
```

Apply `preferences.colorBlind === true` whenever `onPreferences` runs, including updates while the viewer
is open. Each game chooses appropriate visual aids: distinctive borders, symbols or color labels. It must
not change game rules. Analysis viewers receive the same setting.

A viewer's own toggle calls `ctx.updatePreference("colorBlind", enabled)` (or emits
`update:preference` with `{ name: "colorBlind", value: enabled }`). BGS saves it globally and sends the new
preferences back. Do not save a separate per-game override or emit an update while applying an incoming
preference. Standalone previews may use their own local fallback.

### Interface language

`preferences.locale` is the current BGS interface language (a BCP-47 tag such as `nl`, `fr`,
`pt-BR` or `zh-TW`). BGS sends it to normal games, replays and analyses, and updates it when
the player changes language. It is presentation context, not a saved per-game preference.
Apply it to controls, tooltips and game explanations; keep player names, chat messages,
move identifiers and game state unchanged. Fall back to English for an unsupported locale.

Tutorials receive the same locale in `launchTutorial(selector, { chapter, locale, ... })`.

### avatars

```ts
emitter.on("avatars", (avatars: string[]) => {
	// ...
});
```

Receive the avatars of each player, in order.

### replay:start

```ts
emitter.on("replay:start", () => {
	// ...
});
```

Start replay mode. Only for compatible viewers.

When entering replay mode, you should emit the [replay:info](#replay-info) event with the necessary info.

### replay:to

```ts
emitter.on("replay:to", (logIndex: number) => {
	// ...
});
```

Replay up to that point in the log.

### replay:end

```ts
emitter.on("replay:end", () => {
	//...
});
```

Leave replay mode

### undo:available

```ts
emitter.on("undo:available", (available: boolean) => {
	//...
});
```

Sent when a single human plays against bots: `true` while they can take back their last
saved move. BGS replays the engine to the position before it, so the engine must implement
`replay(data, { to })`. Show your undo control while it is `true` and emit `undo` from it.
BGS then sends the earlier position through `state`. BGS has no undo button of its own:
the option exists only where the viewer offers it.

### theme

```ts
emitter.on("theme", ({ dark }: { dark: boolean }) => {
	// ...
});
```

Receive the site's current color theme. It is sent when the game is ready and every time the theme changes
afterwards, including when the OS theme flips while the user's setting is "system".

The theme also arrives as a raw `postMessage` (`{ type: "theme", dark: boolean }`) before any viewer script runs —
that's how the wrapper page toggles its own `dark` class without waiting for `launch`. The emitter event is the
preferred API; only fall back to a `window` `message` listener if you must apply the theme before `launch` returns.

Theme support is optional. If your viewer switches themes, apply changes live
without reloading the iframe.

See [Dark mode](#dark-mode).

### chat:messages

```ts
emitter.on("chat:messages", (messages: ViewerChatMessage[]) => {
	// ...
});
```

**Only sent to viewers that declare the chat capability** (`viewer.chat: true` in the game metadata — see
[Chat](#chat)). Hands the viewer the **full recent chat history** as a replace: render this list wholesale. It is
sent on mount and re-sent on every iframe remount (e.g. when the user toggles the alternate UI), so always treat it
as the complete current history, not an append.

`ViewerChatMessage` contains a plain-text fallback and structured rich segments:

```ts
type ViewerChatMessage = {
	_id?: string; // message id, for reconciling chat:updated / chat:deleted
	author?: string; // author display name; absent on system messages
	authorId?: string; // author's stable user id — match players/colors on THIS, never on the name
	playerIndex?: number; // author's seat index, set when the author is a player in this game
	createdAt?: string; // ISO date — when the message was posted
	text: string; // plain-text fallback
	segments: (
		| { kind: "text"; text: string }
		| { kind: "link"; text: string; url: string }
		| { kind: "mention"; name: string; id: string }
	)[];
	type: "text" | "system";
	editedAt?: string; // ISO date, set when the message was edited
	editText?: string; // original input, preserving quoted mentions
	editableUntil?: string; // ISO deadline; supplied only for your own eligible messages
};
```

Use `playerIndex` to apply board seat colors and `authorId` for stable identity.
System messages may omit both. The library also accepts messages without `segments`;
render `text` as the fallback.

### chat:appended

```ts
emitter.on("chat:appended", (messages: ViewerChatMessage[]) => {
	// ...
});
```

New messages to append to the list. The sender's own message arrives back through this event too (the platform
rebroadcasts every accepted post), so don't add an optimistic echo on send.

### chat:updated

```ts
emitter.on("chat:updated", (messages: ViewerChatMessage[]) => {
	// ...
});
```

Edited messages. Replace each in place by `_id` (ignore updates for messages you don't have).

### chat:deleted

```ts
emitter.on("chat:deleted", (ids: string[]) => {
	// ...
});
```

Moderation deletes. Drop these message ids from the list.

### chat:disabled

```ts
emitter.on("chat:disabled", (disabled: boolean) => {
	// ...
});
```

The room-wide chat kill switch (moderation). When `true`, gate your chat input — the api rejects posts to a
disabled room regardless, this is only so you can show the chat as read-only. Note it's only re-pushed when the
flag changes (or on mount), not streamed continuously.

### chat:state

```ts
emitter.on(
	"chat:state",
	(state: { canSend: boolean; reason?: string; mentions: { id: string; name: string; playerIndex?: number }[] }) => {
		// ...
	},
);
```

The viewer's write-permission state, pushed on mount and whenever it changes (login, kill switch).
Use it to hide/disable your composer — the api stays the authority on writes (a refused [chat:send](#chat-send)
still gets its [chat:result](#chat-result) either way), this only reflects what the api would accept. `reason` is
a machine-stable code, present when `canSend` is `false`: `"not-logged-in"` | `"not-confirmed"` |
`"chat-disabled"` | `"not-a-player"` | `"no-game"`. Localize its display on your side.

`canEdit?: boolean` advertises editing support. Optional `editLabels` contains the
localized `edit`, `save` and `cancel` strings. Edit controls also require the
message's `editableUntil` to be in the future; BGS rechecks permissions on every save.

`readState?: { userId: string; lastReadAt: number } | null` supplies the signed-in
reader's saved position as a Unix-millisecond timestamp. Protocol 0.6.1 uses it to
restore unread badges after a reload. It can arrive before or after chat history.
`null` clears account-specific tracking on logout; omitting it preserves the
controller's current tracking.

### chat:translation

The result of `chat:translate`: `{ messageId, requestId, target, ok, text?, segments?, language? }`.
`language` is the detected source language; `und` means uncertain and `mul` means mixed.
Keep the original message unchanged. `ChatController` handles results, discards responses
for edited/deleted messages or an old target language, and offers retry on failure.

BGS advertises availability via `chat:state.translationTarget` and sends translated UI
labels in `chat:state.translationLabels`. Absence of a target means translation is unavailable.
Detection is a hint: manual translation remains available regardless of the detected language.

### chat:result

```ts
emitter.on("chat:result", (result: { requestId: string; ok: boolean; error?: string }) => {
	// ...
});
```

The acknowledgement for a [chat:send](#chat-send) or [chat:edit](#chat-edit) carrying a `requestId` — one result per
request, by `requestId`. `ok: true`: the message was accepted (it arrives via [chat:appended](#chat-appended)
like any other; edited messages arrive through [chat:updated](#chat-updated)). `ok: false`: the post was refused (`error` is the api's message — not logged in, rate-limited,
chat disabled, validation) — **keep the draft** and show what happened.

## Uplink

The [commands](#commands) above send these events for you. The raw examples below
are for `ViewerEmitter` integrations. These events flow from the viewer to BGS:

```ts
emitter.emit("fetchState");
```

### ready

```ts
emitter.emit("ready");
```

The game has rendered and can be shown to the player. The app keeps the iframe hidden (with a loading
spinner) until it receives this, then reveals it.

**Emit `ready` only after the first [state](#state) has been received and rendered** — not on mount. The
app sends the initial `state` in response to the shim's `gameReady`, so by the time your viewer has state,
the app's message listener is guaranteed to be attached; a `ready` fired on mount (before any state) can
race ahead of that listener on a hard refresh and be dropped, leaving the spinner up forever.

Emit it on a macrotask (e.g. in a `setTimeout(0)` after applying the first state), **not** inside
`requestAnimationFrame`: the app keeps the iframe hidden until `ready`, and in a hidden/backgrounded iframe
`requestAnimationFrame` may never fire — so the game would never signal ready and stay hidden forever.

### move

```ts
emitter.emit("move", { x: 1, y: 2 });
```

Send a move to the backend.

`move` is passed as is to the backend's [move](./engine-api.md#move) exported method: the emitted payload is relayed
**verbatim** as the backend move's `move` field. Emit the raw move object:

```ts
// ✅ the engine receives move.action === "take"
emitter.emit("move", { action: "take", gems: ["ruby", "onyx"] });

// ❌ double-wrapped: the engine receives move.move.action
emitter.emit("move", { move: { action: "take", gems: ["ruby", "onyx"] } });
```

### player:clicked

```ts
emitter.emit("player:clicked", { index: 0 });
```

Signals that a player's name was clicked, so that the application can go to the player's profile.

Optional: emit it when the player clicks a participant name or avatar.

### fetchState

```ts
emitter.emit("fetchState");
```

Requests the current game state passed to us, in full.

The application will fetch the current game state, and pass it to the viewer with a [state](#state) event.

### fetchLog

```ts
emitter.emit("fetchLog", { start: 0, end: 9 });
```

Requests the log between `start` and `end` included.

The application will fetch the data and will pass it to the viewer with a [gamelog](#gamelog) event.

### addLog

```ts
emitter.emit("addLog", ["Player 1 built a factory."]);
```

Transmits new log elements to the application, to be displayed in the sidebar.

### replaceLog

```ts
emitter.emit("replaceLog", ["Game started."]);
```

Erases current log and transmits new log elements to the application, to be displayed in the sidebar.

### replay:info

```ts
emitter.emit("replay:info", { start: 0, current: 3, end: 9 });
```

Emit it when replay starts and whenever the replay position changes.

Used for the replay controls in the sidebar.

### undo

```ts
emitter.emit("undo");
```

Take back the player's last move, while `undo:available` is `true`.

### update:preference

Update a preference from your viewer. Values can be strings, booleans, or `null`.

```ts
emitter.emit("update:preference", { name: "flatBuildings", value: true });
```

The preference is stored per game and re-sent in the [preferences](#preferences) message. Two names are reserved:
`sound` and `colorBlind` write the user's global settings instead — see [Sound](#sound-in-game-audio) and
[Color-blind support](#color-blind-support). Both take boolean values.

### chat:send

```ts
emitter.emit("chat:send", { text: "Hello @Alice", requestId: "message-1" });
```

Send plain text to the game's chat. Include a unique `requestId` to receive
[chat:result](#chat-result) and show errors; `ChatController` does this automatically.
After acceptance, the message arrives through [chat:appended](#chat-appended),
including for the sender. Avoid adding a local echo. BGS enforces posting permissions,
rate limits, and the room's disabled state.

### chat:edit

```ts
emitter.emit("chat:edit", {
	messageId: "650000000000000000000001",
	text: 'Corrected message @"Full Name"',
	requestId: "edit-1",
});
```

Edit your own text message in this room within 15 minutes of posting. Start from
`message.editText` (falling back to `message.text`), never from a displayed
translation. BGS enforces ownership, the time window and room write permissions.
Listen for [chat:result](#chat-result); preserve the editor on failure. The accepted
change arrives through [chat:updated](#chat-updated), which also invalidates cached
translations. No local echo is needed. `ChatController` manages this flow for you.

### chat:translate

```ts
emitter.emit("chat:translate", {
	messageId: "650000000000000000000001",
	requestId: "translation-1",
	target: "fr",
});
```

Use the target supplied by BGS in `chat:state`. The platform resolves the message
from the current room, checks access and limits, then returns `chat:translation`.
Viewers never receive model credentials and cannot submit arbitrary text for translation.
`mountChat` includes these controls from protocol 0.4.0; custom UIs call
`chat.toggleTranslation(messageId)` to translate or toggle the original.

### chat:read

```ts
emitter.emit("chat:read", { messageId: "650000000000000000000001" });
```

Report the latest message the user actually saw, using its `_id`. BGS marks the
room read through that message and clears corresponding mention notifications.
Send only after checking chat visibility and browser-tab visibility. Invalid or
unknown IDs are ignored.

## Chat

Tick **Chat** in the admin options for each viewer that renders chat. BGS uses its
own chat in the lobby; once the game starts, chat is also available in that viewer.
The desktop rail shortcut opens the same conversation, with shared unread state.
It is not added to the full drawer.

Under **In-game controls**, choose when chat is within reach:

- **Always accessible**: `integratedControls: ["chat"]` hides duplicate BGS shortcuts.
- **Wide game layouts**: `integratedControls: [{ control: "chat", minWidth: 1100 }]`
  hides them from that viewer width. Below it, BGS provides the rail shortcut or a
  floating button on mobile and when the rail is closed.
- **Default**: keep the rail shortcut; the viewer handles its own mobile reminders.

Width means the game iframe, so resizing the window or sidebar updates the choice.
Configure alternate viewers separately. The same setting accepts `"preferences"`
for a future game-preferences shortcut. Global BGS controls stay available.

A custom integration handles `chat:messages`, `chat:appended`, `chat:updated`,
`chat:deleted`, `chat:state`, `chat:disabled`, and `chat:result`. `ChatController`
and `registerViewer` wire these handlers for you.

Render rich segments as text, links, and mentions. Never inject messages as HTML
or re-linkify the plain fallback; BGS supplies links according to the author's
permissions. Use the roster in `chat:state` for mention suggestions. Send mentions
as `@username` or `@"Full Name"`; BGS resolves recipients and excludes self-notifications.

There are no viewer uplinks for editing messages or adding reactions. Viewers
receive edit and deletion notifications so their displayed history stays current.

## ChatController reference

`ChatController` from `@boardgamers/protocol/chat` manages the events above. Return
it as `chat` from your registration callback, pass it to `createViewer`, or use
`attachChat(emitter, chat)` with a custom emitter. See the [panel and custom UI examples](./protocol-library.md#chat-state-and-rendering).

| Method                        | Purpose                                                                                                |
| ----------------------------- | ------------------------------------------------------------------------------------------------------ |
| `subscribe(listener)`         | Immediately supplies the current snapshot, then each update; returns an unsubscribe function.          |
| `setDraft(text)` / `submit()` | Update the draft / send it if permitted, nonempty, and no request is pending.                          |
| `setOpen(open)`               | Track panel visibility; does not mark messages read.                                                   |
| `markRead(messageId)`         | Clear local unread IDs through this message and report it to BGS. Call only after checking visibility. |
| `suggestions(query)`          | Filter the latest mention roster, excluding your seat.                                                 |
| `destroy()`                   | Remove subscriptions and cancel timers. `viewer.destroy()` calls this for its attached controller.     |

Treat `snapshot` as read-only. Its fields are:

| Fields                          | Meaning                                                                               |
| ------------------------------- | ------------------------------------------------------------------------------------- |
| `messages`, `unreadIds`         | Current messages and IDs of locally unread messages. `chat.unread` returns the count. |
| `enabled`, `open`               | Chat has been supplied by BGS / the UI panel is open.                                 |
| `canSend`, `disabled`, `reason` | Write permission, room kill switch, and a [permission code](#chat-state) to localize. |
| `draft`, `pending`, `error`     | Composer text, pending request ID (empty when idle), and send error.                  |
| `mentions`, `playerIndex`       | Mention roster and the viewer's seat, if any.                                         |

**Translation (0.4.0+):** `toggleTranslation(messageId)` requests a translation or toggles
back to the original. `translationTarget` and `translationLabels` describe availability
and localized controls. `translations[messageId]` contains pending/error/shown state,
translated text and segments. The original `messages` array is unchanged. Edits, deletions
and target-language changes invalidate the relevant translations.

**Editing (0.5.0+):** `canEdit(message)` checks host permission and the edit deadline.
`startEditing(messageId)` loads its original input; `editLast()` selects the latest
eligible message. Both return whether editing started. `snapshot.editingId` identifies
the active editor. `cancelEditing()` restores the previous draft, while `submit()`
saves the edit. Pending edits lock the input and cancellation until an acknowledgement
or timeout; failures preserve the draft. `chat.editLabels` returns host-localized
labels or English defaults. The shared composer handles ↑ on an empty input and Escape.

**History and unread:** with a saved `readState`, initial and replacement history
count messages newer than the saved position, excluding your own messages and
system events. Without one, history starts as a read baseline and replacements
retain known unread IDs still present. Duplicate IDs, edits and deletions are
reconciled in place. Neither receiving history nor opening a hidden panel marks
messages read; read reports require visibility.

**Read reports:** `mountChat` and `bindChatViewport` handle scrolling and visibility
checks, including clipping by the embedding page; iframe focus is not required.
With your own implementation, check visibility before calling `markRead`. `markRead` accepts
an ID present in history, batches reports for 500 ms, and never moves the reported
watermark backwards during the controller's lifetime.

**Send results:** `submit()` trims text and assigns a request ID. A matching
`chat:result` settles the request; success clears the submitted draft only if it
has not since changed. Refusals and timeouts preserve it. Stale acknowledgements
cannot settle a newer send. Accepted messages arrive through `chat:appended`.

Override timing when constructing the controller, for example in tests:

```ts
import { ChatController } from "@boardgamers/protocol/chat";

const chat = new ChatController({
	readDelayMs: 500,
	sendTimeoutMs: 20_000,
});
```

## Optional chat UI

Import these helpers from `@boardgamers/protocol/chat/dom`. All bindings expose
`destroy()` for unmounting; the controller remains owned by the viewer.

| Helper                             | Purpose                                                                                                          |
| ---------------------------------- | ---------------------------------------------------------------------------------------------------------------- |
| `mountChat(container, options)`    | Mount a complete panel. Returns `element`, `open()`, `refresh()` and `destroy()`.                                |
| `bindChatComposer(input, options)` | Bind draft, permissions, keyboard and mention handling to a text input. Returns `choose(index)` and `destroy()`. |
| `bindChatViewport(list, options)`  | Follow messages and report visible reads. Returns `open()`, `refresh()`, `bounds()` and `destroy()`.             |

All require `options.chat`. Composer options require `onSuggestions`, receiving
`{ candidates, selected }`; `maxSuggestions` defaults to 6. Mark message rows with
`data-message-id` and pass their wrapper as `contents` to the viewport binding.

Date dividers use local calendar days. Override `labels.date(date)` to change their
text, or use `chatDateSeparators(messages, locales?)` from `/chat` in your own UI.
It returns `{ dateTime, label }` at the first dated message and each day change,
and `undefined` elsewhere.

Panel options include `openPlayer`, `renderAuthor`, `labels`, `styles`, `viewport`
and `onVisibilityChange`. Viewport bindings accept `contents`, `isVisible`,
`viewport` and `onVisibilityChange`. `viewport()` returns any of `top`, `right`,
`bottom`, `left` in CSS pixels within the game window; it can narrow the browser's
visible area to exclude overlays. `refresh()` rechecks layout after external changes.

The panel's `open()` reveals chat and scrolls its document to it. The lower-level
viewport's `open()` only positions its message list. Neither scrolls the parent
BGS page. Your UI must select its chat tab before calling `open()`.

See [examples and styling](./protocol-library.md#chat-state-and-rendering).

## Dark mode

Dark-mode support is entirely optional. A single, readable theme is fine;
there is no general recommendation to maintain both light and dark versions.

The site supports a light/dark theme, switchable by the user or following the OS ("system"). The wrapper page does
two things for dark mode:

- It toggles a `dark` class on `<html>` — read from the `?dark=1` URL parameter for the initial paint (before any
  viewer script runs, so no flash of light theme), then kept in sync via the [theme](#theme) message.
- It sets a dark page **background** (`html.dark`) so there's no white flash while your viewer loads. That rule
  lives in a cascade layer (`@layer bgs-fallback`), and unlayered styles always beat layered ones — so if your
  viewer styles its own background, it wins, no extra specificity or `!important` needed.

If you choose to follow the site theme, read `?dark=1` (or the wrapper's
`<html class="dark">`) for the first paint, then use `onTheme` for live changes.
Style your components accordingly; a viewer with a single theme can ignore this.

### settings / update:setting

The host sends `settings` with the current player's per-game engine settings, or `null` when unavailable.
Use `createViewer({ onSettings(settings) { … }, onState(state) { … } })` to receive updates and
`viewer.updateSetting("autoCharge", "3")` to request a change. The host validates the declared setting,
merges it with the player's other settings, saves through the gameplay settings API, and sends back the
canonical settings. Sidebar changes use the same settings. These are game settings, separate from
presentation preferences sent through `update:preference`. Only active-game players can edit them.

## Player hover cards and boardgame navigation

### player:hovered

```ts
emitter.emit("player:hovered", {
	index: 0,
	anchor: { x: 100, y: 40, width: 120, height: 24 },
});
emitter.emit("player:hovered", null); // pointer leave / focus loss
```

The anchor is the player-name element's bounding rectangle in iframe-viewport CSS
pixels. Emit on hover or keyboard focus; send `null` when leaving. BGS resolves the
seat to an account, delays opening briefly, and allows the pointer to move onto the
card. Invalid coordinates, unknown seats and bots are ignored. The same protocol
works in game and analysis viewers.

### boardgame:clicked

```ts
emitter.emit("boardgame:clicked");
```

Navigates to the current game's boardgame page. No name, URL or game ID is accepted:
the host supplies the destination. The SDK equivalents are `hoverPlayer(index,
anchor)`, `leavePlayer()`, and `openBoardgame()`.

## Board thumbnail capture

### thumbnail:render (BGS → viewer)

```ts
{ width: 1200, height: 630 }
```

Asks an opted-in viewer to prepare a still image of its current public board. With
`createViewer` or `registerViewer`, implement `onThumbnail(size)` and return the
board element after layout and image loading finish. The SDK waits for pending
state renders and sends the result automatically. Return `null` to decline.

### thumbnail:ready (viewer → BGS)

```ts
emitter.emit("thumbnail:ready", {
	supported: true,
	bounds: { x: 20, y: 30, width: 900, height: 600 },
});
// Or:
emitter.emit("thumbnail:ready", { supported: false });
```

Bounds use iframe-viewport CSS pixels, have positive dimensions, and must fit
inside the capture iframe (1200 × 900). This event does not upload an image or
arbitrary URL. The platform crops and scales the rendered board into the game
share image, using the normal background if capture fails. Enable it with
`viewer.thumbnail: true` only after implementing the callback. See the
[thumbnail lifecycle and example](./protocol-library#board-thumbnails-for-individual-games).

## Publishing a viewer with multiple files

Keep language files, code chunks, images, fonts and WASM next to the entry point instead of inlining them into one large JavaScript file. Use relative URLs so the whole build can live in its own immutable directory.

In the admin viewer editor, choose **Upload folder**, select the build directory and pick the entry point. Extra JavaScript files are hosted as chunks; they are not automatically executed as dependencies. Save after the upload completes.

For scripted releases, `scripts/publish-viewer.mjs` uploads a directory, verifies every public file and saves the previous viewer for rollback. It prints a dry run unless `--apply` is given; `--activate` switches the configured viewer only after all files pass verification.

```sh
node scripts/publish-viewer.mjs --game powergrid --version 13 \
  --dir ./release --entry powergrid-viewer.umd.min.js --style powergrid-viewer.css \
  --token /path/to/admin-token --record /tmp/powergrid-release.json --apply --activate
```

The upload API preserves relative paths with a shared `bundle` identifier. JavaScript, CSS, JSON, SVG, sourcemaps and WASM are gzip-compressed when smaller; browsers receive the original contents through `Content-Encoding: gzip`. Already-compressed media stay unchanged. Use a new bundle identifier when any file changes. The script skips sourcemaps by default; add `--source-maps` only if you want them publicly accessible.

Upload the **complete viewer build**, including any language JSON, JS chunks, images, fonts and WASM it references. Use a clean release folder: leave out engines, preview apps, test fixtures and private data. Replacing only the main JS file will break relative asset URLs. Each release needs its own bundle directory, and the viewer URL should change only after every file is uploaded.

- **Webpack:** dynamic `import()` can create language or code chunks. Upload those chunks alongside the library entry. Keep the generated public path relative to the entry script.
- **Vite libraries:** library mode normally inlines assets. Use `?url&no-inline` for separate files and `base: "./"` for URLs relative to the viewer. Keep the host entry as UMD/IIFE; JSON and media files can be fetched independently without changing the host contract.
- **esbuild:** use the `file` loader for separate assets. CSS URLs resolve relative to the CSS file; URLs used by JavaScript must resolve against the viewer script URL, not the game page. Capture that URL while the script executes.

Send ordinary file bytes to the upload API—do not gzip them yourself or upload `.gz` URLs. BGS chooses compression and sets the HTTP headers. The optional `viewer.scriptBytes` is the **uncompressed entry size**, used by the download progress bar; the admin uploader and release script set it for you. `--alternate` updates the alternate viewer while preserving the main one.

If a browser cannot reach our asset storage, BGS retries viewer scripts and stylesheets through its own server. Relative chunks keep working on that route; use relative URLs within your build, rather than hardcoding the storage host.

### Cleaning up old uploads

Run `apps/api/scripts/cleanup-viewer-assets.ts` on the API host. It reads every game version, including private and archived versions, and the viewer snapshots saved with analyses. A reference to any file protects its **whole bundle directory**, including language chunks, images and WASM. Engines, avatars and boardgame artwork outside viewer bundles are excluded.

```sh
cd apps/api
export previewDbUrl=mongodb://10.90.0.3:27017/admin
NODE_ENV=production node scripts/cleanup-viewer-assets.ts \
  --extra-mongo-env previewDbUrl \
  --state /home/bgs/viewer-cleanup/state.json \
  --report /home/bgs/viewer-cleanup/report.json
```

This is a dry run: it writes a local report and retention state, but changes nothing in Mongo or S3. Include every Mongo server whose previews use this bucket; `--extra-mongo-env` can be repeated and scans all non-system databases, including preview templates. An unavailable reference source aborts the run. `--keep-url` can pin additional rollback builds; `--public-base` adds a custom public URL alias for the same bucket.

A bundle becomes eligible only when its newest file is at least **seven days old** and it has been observed unused for **seven days**. Reuse, changed files or changed reference-source settings restart the grace period. Keep the same state file between runs; losing it delays cleanup rather than making files eligible immediately.

Analyses already store their viewer and game version. New analyses also record `viewerPinnedAt`, and authorized reads record `lastViewedAt` without changing the list's edit-activity order. During cleanup, analyses with no creation, edit, view or viewer change in **30 days** can switch to the viewer configured for their **exact same game version**. The engine, positions and other analysis data stay unchanged. The report saves the previous viewer for rollback. Recent activity cancels the update. Extra preview databases are read-only reference sources.

Review the report, then repeat the command with `--apply` to refresh eligible analyses and delete eligible bundles. Old analysis viewers stay protected for that run; their seven-day unused grace period starts on a later scan. `--min-age-days` and `--analysis-idle-days` can lengthen the retention periods. Reference checks and object fingerprints are repeated before deletion, and each object deletion uses its ETag as a condition.

Pause publishing, rollbacks and preview restores while applying cleanup: the scans are not a transaction across Mongo and S3. The script installs no schedule. Keep reports outside `/tmp` if you want a durable deletion log and viewer rollback history.

### Move acknowledgements

With protocol 0.7, `onMoveResult` receives `{ move, ok, error? }` after the host submits a move. `move` is the original payload, so a viewer can match its sequence number. A rejection clears a pending action immediately; request a fresh state before retrying. A successful acknowledgement is not a state snapshot: continue rendering the normal state/log updates.

### Open the platform chat

Emit `chat:open` with no payload to open the platform's standard game chat. The SDK
command is `ctx.openChat()`. Check `preferences.platformChat === true` before using
it; hosts without this capability need the viewer’s existing chat UI. This works on desktop and mobile, including when the
viewer declares its own chat control. It does not send a message or mark unseen
messages read; the platform chat retains its normal read and permission handling.
A viewer using only this shortcut does not need to render a chat panel or attach a
`ChatController`.
