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.
Downlink
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.
Uplink
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:
- It toggles a
darkclass on<html>— read from the?dark=1URL parameter for the initial paint (before any viewer script runs, so no flash of light theme), then kept in sync via the 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!importantneeded.
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.