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.
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 has the full board and local playtest.
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 if using Vite.
With a framework, await rendering in onState and unmount in destroy.
See optional callbacks and
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):
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 for custom adapters.
To inspect handler coverage in a browser test, use the exported viewer from the tic-tac-toe tutorial:
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
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. From 0.6.1, the host supplies readState in
chat:state: { userId, lastReadAt }, with a Unix-millisecond timestamp. The
controller restores unread history after a reload, excluding your own messages
and system events. Hosts without saved read state keep history as a read baseline.
New messages scroll into view only while the reader follows the latest messages.
Use chat.setOpen(false) when another tab hides the chat; only visible messages
in an open panel generate read receipts. readState: null clears account-specific
tracking on logout.
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:
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:
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 for the lower-level controller.
Local host and tests
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 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.
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.
Player cards and boardgame links
Since 0.6.0, viewers can ask BGS to show its player hover card or open the current boardgame's page. These commands are optional; existing viewers keep working.
registerViewer("myGame", ({ target, hoverPlayer, leavePlayer, openPlayer, openBoardgame }) => {
const name = target.querySelector<HTMLButtonElement>(".player-name")!;
name.onmouseenter = name.onfocus = () => hoverPlayer(0, name.getBoundingClientRect());
name.onmouseleave = name.onblur = () => leavePlayer();
name.onclick = () => openPlayer(0);
target.querySelector<HTMLButtonElement>(".game-title")!.onclick = () => openBoardgame();
return {
onState(state) {
/* render */
},
};
});
Use the zero-based game seat, not a username or user-supplied URL. The anchor is in CSS pixels relative to the iframe viewport. BGS resolves the player from the game, positions the card outside the iframe, and ignores bot seats. Leave also closes a focused name's card. The iframe bridge clears hovers on scroll, when leaving the iframe, and on Escape. Dispose your DOM handlers with the viewer.
Board thumbnails for individual games
Since 0.6.0, add onThumbnail to your viewer callbacks to select its interesting
board area. This is a crop of the specific game's public position, not a generic
boardgame image. The platform's game share image keeps its title and game details
over the board background.
return {
async onState(state) {
await render(state);
},
async onThumbnail({ width, height }) {
// Fit the map/board into the available space, disable animations and tooltips,
// then wait for your renderer, fonts and images to finish.
await fitBoardForThumbnail(width, height);
return target.querySelector(".board");
},
};
Set viewer.thumbnail: true in the game's published viewer metadata once the hook
is implemented and tested. The callback runs after pending state renders, with a
preferred size of 1200 × 630 CSS pixels in a 1200 × 900 iframe. Return the connected
DOM element enclosing the board, entirely inside the iframe viewport, or null
if this position has nothing useful to show. SVG, canvas and HTML are all supported:
BGS captures the rendered element's rectangle, so you don't need canvas export or
cross-origin image serialization. Content outside that rectangle is cropped out.
The capture runs in an isolated, read-only spectator viewer with game sound and
chat disabled. It receives only anonymous spectator state, never the requester's
seat or credentials, and does not submit moves. preferences.thumbnail is true
before the first state render, so you can suppress animations from the start.
Finish within five seconds including initialization; missing support, errors and
timeouts retain the normal background. No engine changes are needed. Existing
viewers are not opted in automatically.