BGS Docs

Viewer API

The viewer is integrated in an iframe on the site.

To host the viewer — a CDN/npm URL or an uploaded pre-built bundle — see Adding a game.

It needs to export a object with a launch function in the global namespace, which returns an EventEmitter used to communicate with our application.

window.viewer = {
	// Place the viewer inside the element designated by the selector
	launch(selector) {
		const emitter = new EventEmitter();

		// ....

		return emitter;
	},
};

This is a two-way communication. The emitter can emit events to be consumed by our applications, and can receive events to process.

Payloads must be plain JSON-serializable values — both ways. The app relays every event between the iframe and itself with window.postMessage, whose structured-clone algorithm throws on anything it can't clone (class instances, functions, Map/Set, and reactive proxies). In particular, don't emit a Svelte 5 $state proxy directly — it fails with Proxy object could not be cloned. Snapshot reactive state first: $state.snapshot(value) or JSON.parse(JSON.stringify(value)).

The application can also load additional javascript and css files if needed.

This is the events the application passes to the viewer. You can receive them this way:

emitter.on("event", (arg) => {
	// Handle downlink event
});

state

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

Receive game data as processed by the backend.

This replaces the current game state with a new one, you should completely overwrite the previous game state.

state:updated

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

Notification that new state is available.

You can request the full state by emitting fetchState or request the new log elements by emitting fetchLog.

gamelog

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

Receive log data.

data is the return value of the backend's logSlice.

preferences

emitter.on("preferences", (preferences: { [key: string]: any }) => {
	//...
});

Get the user's specific UI preferences for this game.

For example, for Gaia Project, there are two UI preferences: whether to use flat buildings, and whether to keep the original color for the planets.

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

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

Receive the player id of the currently connected player.

This event is not triggered when the user is just a spectator.

Sound (in-game audio)

If your game has a sound entry in its preferences, the site reserves that key: preferences.sound in the 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.

avatars

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

Receive the avatars of each player, in order.

replay:start

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

Start replay mode. Only for compatible viewers.

When entering replay mode, you should emit the replay:info event with the necessary info.

replay:to

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

Replay up to that point in the log.

replay:end

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

Leave replay mode

theme

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.

The viewer must apply the theme live, without reloading the iframe — never swap the iframe src to change the theme, a src change reloads it and loses the game state.

See Dark mode.

chat:messages

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

Only sent to viewers that declare the chat capability (viewer.chat: true in the game metadata — see 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 is plain — no links, no markup:

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 — mentions/links already flattened
	type: "text" | "system";
	editedAt?: string; // ISO date, set when the message was edited
};

authorId, playerIndex and createdAt are additive: a viewer written against the original contract (author name + text only) keeps working unchanged. With playerIndex you can color messages with the same seat colors as the board (players[playerIndex]) — no username matching.

chat:appended

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

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

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

chat:deleted

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

Moderation deletes. Drop these message ids from the list.

chat:disabled

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

emitter.on("chat:state", (state: { canSend: boolean; reason?: string }) => {
	// ...
});

Additive. 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 still gets its 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.

chat:result

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

Additive. The acknowledgement for a chat:send that carried a requestId — one result per request, by requestId. ok: true: the message was accepted (it arrives via chat:appended like any other). 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.

This is all the info that your viewer gives to the app.

You can send them this way:

emitter.emit("event", data);

ready

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 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

emitter.emit('move', move: any);

Send a move to the backend.

move is passed as is to the backend's move exported method: the emitted payload is relayed verbatim as the backend move's move field. Emit the raw move object:

// ✅ 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

emitter.emit("player:clicked", { index: number });

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

This even is completely optional.

fetchState

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 event.

fetchLog

emitter.emit('fetchLog', options: {start: number, end?: number});

Requests the log between start and end included.

The application will fetch the data and will pass it to the viewer with a gamelog event.

addLog

emitter.emit('addLog', log: string[]);

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

replaceLog

emitter.emit('replaceLog', log: string[]);

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

replay:info

emitter.emit('replay:info', data: {start: number, current: number, end: number})

Emitted when the replay starts and everytime we move in the replay.

Used for the replay controls in the sidebar.

update:preference

When you want to edit preferences within the game itself and not BGS' sidebar

emitter.emit('update:preference', data: {name: string, value: string | boolean | null})

The preference is stored per game and re-sent in the preferences message. One name is reserved: sound writes the user's global site-wide sound setting instead — see Sound.

chat:send

emitter.emit("chat:send", { text: string; requestId?: string });

Post a chat message. Only meaningful for viewers that declare the chat capability (viewer.chat: true — see Chat). text is plain text. The platform relays it to the chat api; once accepted it's rebroadcast and comes back to you through chat:appended — so don't echo it optimistically. The platform enforces auth, rate limits and the chat kill switch server-side: a rejected post never comes back unless you attach a requestId (any viewer-chosen string) — then every send is answered with a chat:result ({ requestId, ok, error? }), so you can settle the draft and surface the failure. requestId is additive: a send without one is accepted exactly as before, just not acknowledged.

chat:read

emitter.emit("chat:read", { messageId: string });

Additive. Tell the platform the user actually saw messageId: the host marks the room read up to that message (same endpoint as the platform chat's read tracking), so the platform's unread badge clears for messages read inside the viewer — while the viewer is the chat surface, the platform's own chat window is unmounted and would otherwise leave them unread. Emit it for the latest visible message (no need to emit for every message: reading is a watermark). Ignored for unknown/invalid ids.

Chat

A viewer can render the game chat itself — the chat interface lives inside the iframe instead of the platform's chat window. The platform stays the transport and the store; the viewer is a display + input surface.

Opt in with the chat: true flag in the viewer metadata (the admin panel's "Chat" checkbox, on the main or alternate viewer). This is a static metadata flag, not a launch-time handshake, so the platform knows whether to hide its own chat widget before the viewer loads.

When it applies: the platform renders its own chat room only while the game is open (the lobby, before the game starts). Once the game is active and the served viewer has chat: true, chat belongs to the viewer — and it stays in the viewer after the game ends (post-game discussion happens in the same surface the game was played in; players can still post). Both surfaces share the same underlying chat, so a message sent in one mode appears in the other — the viewer gets the recent history on (re)mount via chat:messages.

Receiving: handle chat:messages (full history, on mount/remount), chat:appended, chat:updated (edits), chat:deleted (moderation) and chat:disabled (kill switch). Optionally also chat:state (write-permission, for gating your composer) and chat:result (send acknowledgements, for sends that carry a requestId).

Sending: emit chat:send with plain text — with a requestId if you want the outcome back. Emit chat:read when a message becomes visible so the platform's unread badge stays in sync.

Plain-text rule: viewer chat is plain text. The platform flattens @-mentions to their display name (@"Full Name"@Full Name) and does not linkify URLs — render text as text. What you send with chat:send is likewise plain text.

The viewer is display-only; the platform keeps enforcing all writes. Reactions, message edits, the @-mention autocomplete and the emoji picker stay platform-side (available in the platform chat before/after the game). The platform always enforces server-side — regardless of which surface renders: participant-only posting, rate limits, the trusted-account link gate, the edit window, admin moderation, and the chat kill switch. Never trust the viewer to police itself.

Dark mode

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:

Everything else is the viewer's responsibility. To support dark mode: read the initial state from ?dark=1 (or the <html class="dark"> the wrapper already set) for the first paint, then listen for the theme event for live changes, and style your components under a .dark root class so they follow the toggle.