Chapters The game page and the SDK

Chapter 6 of 13

The game page and the SDK

The page players see, with @camtable/sdk: state and actions, hit regions, video slots, tile overlays, Whispers, Call Mute and Call Cover.

On this page
npm install @camtable/sdk

It is the only package you install. A game page imports from @camtable/sdk; an engine, a build script or a test of either imports the DOM-free @camtable/sdk/addon; a test that signs engine requests imports @camtable/sdk/testing. @camtable/sdk/internal carries the messages between the SDK and CamTable’s extension and the rest of the wire contract CamTable’s own server and extension read, and is outside the version promise. Your game and engine code never needs it.

import { CamTableSDK } from "@camtable/sdk";

const sdk = new CamTableSDK({ autoTrackHitRegions: true, autoTrackVideoSlots: true });
const { room, localPlayer, players } = await sdk.init();

sdk.onStateChange((state) => render(state));
sdk.onActionRejected((rejection) => showMessage(rejection.reason, rejection.code));

const result = await sdk.sendAction("TAP", { anything: "JSON" });

room.gameData.gameState is the game state; room.lifecycle (lobby, in_progress, finished, …), room.hostId, room.currentTurnPlayerId and room.players (keyed by Match Player Id) are the table.

Asking for the state early. CamTable never answers with an empty or made-up room or player. If your page calls sdk.init() or sdk.getState() before the room’s first state naming the local player has reached the player’s browser (for example right after the page loads), the request waits and resolves with that state as soon as it arrives. If it never arrives, the promise rejects with the SDK’s request timeout (RPC request "getState" timed out after 10000ms, tunable with the rpcTimeoutMs option), so handle the rejection like any failed request. sdk.onStateChange fires for every state after that.

Opened outside a room. A game page opened directly in a browser tab has no CamTable room around it. sdk.init() then rejects with a NotInRoomError (exported by @camtable/sdk): at once when the page has no parent window, and after five seconds when its parent has sent nothing at all (a room’s host pings its game every two seconds, so a room is never mistaken for one). Its message names the Inspector and its inspectorUrl is the Inspector’s address, so the page can say where to try the game:

sdk.init().catch((error: unknown) => {
  if (error instanceof NotInRoomError) showLink(error.message, error.inspectorUrl);
});

Starting a match is an action: the host sends START_GAME, and your rules seed the state from the player list; RESET_GAME and FINISH_MATCH are host-only too. A game whose Play Option declares Game Settings is different: its match starts from the Lobby and the page must not send START_GAME (see Game Settings and the Lobby). An action sent while the match is not in_progress is refused with GAME_NOT_IN_PROGRESS. sendAction sends an intent: its payload is an argument to your rules, never a state the room would keep.

Full-viewport overlay and pointer pass-through

CamTable mounts your page as a full-viewport transparent layer over the Discord call. For the call controls, the video grid and the chat under it to stay usable, your page follows a transparent-root model: the document ignores the pointer, and only your widgets capture it.

html, body {
  margin: 0;
  width: 100vw;
  height: 100vh;
  background: transparent;
  overflow: hidden;
  pointer-events: none;   /* empty areas pass clicks through to Discord */
}
.game-card, button, input, [data-camtable-interactive] {
  pointer-events: auto;   /* your widgets opt in */
}

The SDK’s HitRegionTracker measures your interactive elements and tells the host where they are. It picks up [data-camtable-interactive], buttons, links, inputs, selects, textareas, canvases and elements with an interactive ARIA role automatically (data-camtable-interactive="false" opts an element out). For a canvas game, register regions yourself:

sdk.registerHitRegions([{ id: "board", x: 200, y: 150, width: 400, height: 400, cursor: "pointer" }]);
Region What receives a click
Empty area of your page Discord underneath (controls, video grid, chat).
Your interactive widget Your element.
CamTable’s video tile HUD (badges, turn rings, the tile’s action buttons) The HUD, outside your regions. Keep your regions off it.
CamTable’s game top bar and its custom tab drawer The bar and the open drawer: your page never takes the pointer over them.

Your page is shown only inside your hit regions. Once it reports regions, CamTable clips the frame to them, so nothing you draw can sit over a spot where the player’s click goes to Discord. Regions are cut to the visible game area, and one thinner than 8 px is dropped. Decor outside your regions is not shown: for a full-screen background, report a region covering the whole frame, which then takes the pointer everywhere. Until your page reports regions, or while it reports none, it is shown whole and takes every click.

Because your page is one frame deeper than the extension’s sandbox page, CamTable translates hit regions and pointer coordinates through its shell for you. Nothing to do on your side.

Video slots

To place a player’s camera inside your layout, mark an element with the player’s Match Player Id. CamTable draws that player’s video over your page, in that element’s box; you never handle the pixels. CamTable’s top bar, its custom tab drawer and the Dock always stay above the video and hide the part of it under them.

<div class="seat" data-camtable-video="mp_3f9a">
  <!-- video appears here -->
</div>
sdk.registerVideoSlots([{ userId: "mp_3f9a", x: 50, y: 50, width: 240, height: 180, borderRadius: "12px" }]);

Layers

Layer 4 (top):   CamTable's Join Consent prompt
Layer 3:         Video tile HUD layer (z-index 95000)
Layer 2:         Your game page with the players' videos over it, under CamTable's top bar,
                 its custom tab drawer and the Dock (z-index 90000)
Layer 1:         Discord call video grid and controls

Tile overlays (tiles.overlay)

await sdk.setTileOverlay(playerId, {
  badge: { text: "SHERIFF", color: "gold" },
  counter: { label: "Score", value: 120 },
  status: "Accusing...",
  glowing: true,
  actions: [{ id: "vote", label: "Vote" }],
});
sdk.onTileAction(({ playerId, actionId }) => { /* a HUD button was clicked */ });
await sdk.clearTileOverlay(playerId);

In the Inspector each seat shows a placeholder Video Tile for every player in place of their camera, under the seat’s game frame as the call’s tiles are under it in Discord. Your overlays, your hit regions and your video slots work on them as on a call: an overlay appears on that player’s tile in the view of the seat whose page set it, a pointer outside your hit regions reaches the tiles (and their action buttons) instead of your page, and a video slot shows the player’s placeholder in its box. No camera or microphone is asked for.

Whispers (whisper)

A Whisper is a private conversation among players your game puts together: they talk in it by voice, peer to peer between their browsers, and write in it from the panel. Only your game starts one, in one of two ways, and your state says which are open:

  • Your rules name groups under whispers.groups, and the room connects each group with nobody asking or accepting (the vampires at night).
  • Your page makes and answers Whisper Requests for its player while your state says whispers.requests is "open" (a private chat by day).

A state that says neither has no Whispers. A player never starts, answers or ends a Whisper from a tile or the panel, and cannot leave one your game put them in other than by leaving the room. A player is in one Whisper at a time.

What CamTable does for every game, with no permission. While a player is in a Whisper their voice no longer goes to the Call, whether or not they are speaking, and they no longer hear the Call; both come back when it ends or they leave the room. Only their own page does this: Discord and the other players are told nothing. Their own tile says whom they whisper with; no other tile shows anything. They talk with push-to-talk (V) by default or an open microphone, as they chose, and can always close their own whisper microphone and turn the Whisper’s sound down. A player whose microphone is refused or missing stays in the Whisper, listening and writing. Writing goes through the room to the panel of the players in that Whisper only, at most 500 characters a message, and is forgotten when the Whisper ends.

What your game can never do, in either authority mode:

  • hear or read what is said or written in a Whisper: it reaches no game frame, no engine and no host page;
  • know who in a Whisper is speaking, or whether anyone is;
  • open a microphone the player or a Whisper closed, or lift the player’s own whisper-mute;
  • write in a Whisper for its player.

You learn who whispers with whom, never what is said.

Declare whisper if your game uses Whispers at all. At Join Consent the player reads “Bu oyun seni fısıltıya alabilir ve senin adına fısıltı isteyip kabul edebilir.”, and that line is what tells them your game can put them in a Whisper, so without it nobody is put in one: the room ignores your state’s whispers.groups and keeps Whisper Requests closed, whatever your state says. Your page is told of no Whisper either (not in its room state, not as an event) and each call below is refused with PERMISSION_DENIED. A Test Room tells every seat when a state names groups for a Play Option that does not declare whisper (WHISPER_GROUPS_NOT_PERMITTED, in the Inspector’s event log).

Groups your rules name

return { state: { ...state, phase: "night", whispers: { groups: [["mp_3f9a", "mp_81c2"]] } } };
  • Each group is a list of two to six Match Player Ids. The room starts a Whisper for each group (origin: "rules") and ends the ones your state stops naming (rules). A group that keeps a player and gains or loses another is the same Whisper, with the same id.
  • The room reads whispers.groups from your full state, before views, so a view need not carry it and should not, if the groups are secret.
  • An id that is not on the room’s roster is dropped from its group. A Whisper holds only the group’s connected players: a group with fewer than two connected has no Whisper until a second one comes back, and a pair Whisper whose player drops ends (player_left). When they return while your state still names them, a new Whisper starts, with a new id.
  • A declaration the room cannot apply applies nothing: a groups that is not a list of lists of ids, a player in two groups, or a group of fewer than two or more than six players on the roster. The room then keeps the groups it had: it starts none and ends none. A Test Room tells every seat why (WHISPER_GROUPS_REFUSED, in the Inspector’s event log); an ordinary room tells nobody. So leave a group out of the list rather than name one player alone.
  • A group wins over a Whisper made by request: when your rules name one of its players, that Whisper ends, rules for the player your rules took and ended for the other, as if their partner had ended it, so a secret group stays secret. A page cannot end a Whisper your rules made.

Who whispers with whom

Your page reads the Whispers its player may know of, and hears when that list changes (a Whisper starting, its players changing, or it ending). Each player’s whisperActive (from getPlayers) follows the same list.

const whispers = await sdk.getWhispers(); // [{ id, playerIds, origin, startedAt }]
sdk.onWhisperChange((whispers, ended) => drawWhispers(whispers)); // the whole list each time

ended names each Whisper the player knew of that just ended, with why: ended (a page ended it with endWhisper, or your rules took the other player of a Whisper by request into a group), moved (one of its players accepted another request), rules (your rules stopped naming it, or now hold this player), requests_closed, player_left or match_over.

Secret unless you say otherwise. By default a player may know of their own Whisper only: the room tells nobody else, so the villagers’ pages never learn who the vampires are. Set whispers.visibility to "public" in your state and the room tells every player of every Whisper, including a player who joins or rejoins while one runs, so each page can draw who went aside with whom (CamTable’s tiles still show only each player’s own); set it back to "secret" (or drop it) and the others are told they know of none again.

Whisper Requests

While your state says whispers: { requests: "open" }, your page may ask for a Whisper on its player’s behalf, and answer one asked of its player, with or without asking them first (say so in your game; the player agreed to it at Join Consent):

const request = await sdk.requestWhisper(targetPlayerId); // { id, fromPlayerId, toPlayerId }
sdk.onWhisperRequest(({ request, status }) => {
  // status: pending | accepted | declined | withdrawn
  if (status === "pending" && request.toPlayerId === me) void sdk.acceptWhisperRequest(request.id);
});
await sdk.declineWhisperRequest(requestId);
await sdk.endWhisper(); // the local player's Whisper made by request, for every player in it
  • A request is for one other player. Only the player asked can accept or decline. Accepting starts a Whisper of the two (origin: "request"), and ends a Whisper by request either of them was in (moved).
  • A player your rules hold in a group cannot ask for a Whisper, nor accept one. A request lasts until it is answered or the room withdraws it: when requests close, the match ends, one of the two leaves, or your rules put the asking player in a group. The player asked is told only that it was withdrawn, never why, and a player is never refused for another player’s group. A Whisper made by request ends when requests close (requests_closed), when one of its players leaves, and when your rules put one of them in a group.
  • A refused call rejects with a CamTableCallError whose code says why: WHISPER_REQUESTS_CLOSED, TARGET_NOT_FOUND (not connected), INVALID_TARGET (the player themselves), WHISPER_REQUEST_NOT_FOUND (answered, withdrawn, or asked of someone else), WHISPER_SET_BY_RULES (your rules hold the player in a group, or the Whisper to end is theirs) or WHISPER_NOT_FOUND (the player is in no Whisper).
  • A request’s steps are told to its two players only; while Whispers are public a decline reaches every page too. Your engine is never told of requests.

Cameras in a Whisper (callCover)

A Whisper hides what its players say and write. Their cameras stay in the Call unless your rules close them with a Call Cover (permission call.cover): callCover in your state, read from the full state before views as whispers is, with two settings that do not depend on each other and the picture the table sees in place of a covered camera.

callCover: {
  whispering: "covered", // the players in a Whisper; absent means "open"
  others: "covered",     // the players in none; absent means "open"
  shows: "dark",         // "dark" | "blur" | "still"; absent means "dark"
}
callCover.whispering callCover.others What the table sees
open open Every camera. The default, and the day.
covered covered The same cover on every tile, so nobody can tell who whispers. The night.
covered open A hidden Whisper in plain sight: the table sees which tiles are covered.
open covered Only the whispering players are seen, not heard; possible, though no game we know needs it.

The room works out each player’s cover from those settings and whether they are in a Whisper, and tells each player only their own; your page is told nothing of it (draw from your own state). A watcher is never covered, and the cover lifts by itself when the match ends.

A covered Whisper carries its players’ cameras. While the players of a Whisper are covered, each one’s live camera goes to the other players of that Whisper, browser to browser, and only there: they see it in your video slots, nowhere else. A game that gives a player’s camera no slot has no camera in its Whispers, only voice and writing. CamTable draws a line over each slot of the viewer’s own Whisper, “Masa görmüyor” while its players are covered and “Masa da görüyor” while they are open, and your page cannot draw over it. A Discord tile always shows what Discord carries: the cover. Each player’s own tile says who sees their camera (“Kameranı yalnızca fısıltıdakiler görüyor”, “Kameranı kimse görmüyor”).

  • The player keeps their switches. They can turn their camera off in Discord at any time, for everyone, and in a Whisper that carries cameras the panel gives them “Kameramı fısıltıya kapat”, which closes it to that Whisper alone (the others see “Kamera kapalı” in its slot) and lasts for the room. CamTable never turns a camera on, and nothing lets a player lift a Call Cover while the match runs.
  • Quality follows the Whisper’s size: at most 640×360 for two players, 480×270 for three or four, 320×180 for five or six, at 15 frames a second; below that the browser adapts.
  • Groups and failures. The cameras follow who is in the Whisper, up to its six players. There is no relay server, so on some networks two players cannot reach each other: that camera shows “Kamera kapalı”, the Whisper’s voice and writing go on, and it is tried again when the Whisper’s players change.
  • Your game never receives a picture, a track, or word of who looks at whom; a slot is still only a rectangle for a player’s camera.

A callCover that is not of this shape changes nothing: the cover before it stays (an earlier night stays covered), and the match keeps running; a Test Room says so in the Inspector’s event log. To lift a cover, leave the key out or write it with both settings open. Without call.cover the room ignores the key and tells you once.

Whispers last only while a match runs (in_progress, or paused while the host is away). When it ends, every Whisper ends (match_over) and every request is withdrawn.

In host mode the host’s page holds your full state, groups included, whatever your views hide, so the host can know who whispers with whom, as with any hidden information there. A game whose secret groups must stay hidden from the host runs in engine mode. Even then the host never hears or reads a Whisper they are not in.

In the Inspector, which has no media, the calls behave as in a call: a Whisper Request is made, answered and ended in the Test Room under the same rules, and each seat’s page is told of it. A Whisper your rules name reaches the seats in it as in a room, each of them shows it on its own player’s tile, and a seat outside a secret one learns nothing of it. No voice is carried, since there is none.

Camera filters (video.effects)

await sdk.setVideoEffect("nightvision"); // none | freeze_badge | grayscale | sepia | cyberpunk | matrix | invert | comic | nightvision | blur | mirror
await sdk.resetVideoEffect();

An effect changes only the local player’s own outgoing camera, and their own tile shows it too. Camera Effects are your game’s alone: the player has no switch in the panel to change or lift one, so an effect you set stays until your page changes or resets it, or the player leaves the room (the match ending does not lift it). At Join Consent the player reads “Bu oyun kamerandan giden görüntüyü değiştirebilir.” An effect never hides who a player is: to close cameras to the Call, put a callCover in your rules’ state (Call Cover, permission call.cover; see the night phase below). An unknown effect name is refused with INVALID_PARAMS.

In the Inspector both calls resolve with { success: true }. There is no camera to filter, so the effect is shown on the seat’s own placeholder tile instead (“Camera effect: nightvision”).

Call Mute (call.mute)

await sdk.setCallMuted(true);  // the local player's voice no longer reaches the Call
await sdk.setCallMuted(false); // lifts this game's Call Mute

Your page closes its own player’s voice to the Call, as it sets their camera effect, so a night phase is quiet for everyone and silence gives nobody away (pair it with a Call Cover). Ask it from each player’s page; the room plays no part in it and no other player is told. At Join Consent the player reads “Bu oyun aramaya giden sesini kapatabilir.”

  • It only closes. setCallMuted(false) opens nothing the player (their own Mikrofon switch) or a Whisper still closes; a game can never open a microphone.
  • It closes the Call only. A Whisper’s voice is not the Call’s, so a player under Call Mute who is in a Whisper is still heard in it.
  • It holds only while the match runs (lifecycle in_progress, or paused while the host is away). Asked outside a match it closes nothing, and it lifts by itself when the match ends, when your frame closes (a frame that opens again starts without one) and when the player leaves the room.
  • The player cannot lift it while it holds: the panel’s Mikrofon switch leaves it in place. Only they are told, on their own tile and in the panel: “Oyun mikrofonunu kapattı”. Discord’s own microphone icon does not change.
  • It keeps honest players in step; it is not a security boundary. It is applied in the player’s own browser, so a player who edits their browser can still talk. A rule that must hold against such a player cannot rest on it.

In the Inspector the call resolves with { success: true } and, while the match runs, the seat’s own placeholder tile says so (“Call Mute: the game closed this player’s microphone”) until the match ends.

A night phase, worked through: vampires and villagers

A game of vampires and villagers (Vampir Köylü). At night the vampires talk among themselves, and nobody else may learn who they are: not from a tile, not from who falls silent, not from whose lips move. By day the table talks openly, and any two players may go aside.

The Play Option declares the three permissions it uses:

"permissions": ["whisper", "call.mute", "call.cover"]

The rules (one reducer, either mode) put the vampires in a secret group and cover every camera at nightfall, and open Whisper Requests and every camera at daybreak. The room reads whispers and callCover from the full state, so the views leave them out, along with the roles:

function nightfall(state: VampireState): VampireState {
  const vampires = state.alive.filter((id) => state.roles[id] === "vampire");
  // A group names two to six players. A lone vampire gets no group: a group of one would make the
  // room apply none of the groups.
  return {
    ...state,
    phase: "night",
    whispers: { groups: vampires.length >= 2 ? [vampires] : [] },
    // Every camera covered, in a Whisper or not, so a covered camera gives nobody away.
    callCover: { whispering: "covered", others: "covered", shows: "dark" },
  };
}

function daybreak(state: VampireState): VampireState {
  // No groups: the vampires' Whisper ends (`rules`). Requests open for the day; visibility stays
  // secret. No `callCover`: every camera is open to the Call again.
  const { callCover: _night, ...day } = state;
  return { ...day, phase: "day", whispers: { requests: "open" } };
}

const viewOf = (state: VampireState, playerId: string) => ({
  phase: state.phase,
  alive: state.alive,
  myRole: state.roles[playerId],
});

The room covers the cameras itself, and lifts the cover when the match ends: no page asks for it. Every player’s page follows the phase only for the voice. At night it closes its player’s voice to the Call, and by day (or once the match is over) it gives it back:

sdk.onStateChange((room) => {
  const view = room.gameData?.["gameState"] as { phase?: "night" | "day" } | undefined;
  const night = room.lifecycle === "in_progress" && view?.phase === "night";
  void sdk.setCallMuted(night);
});

sdk.onWhisperChange((whispers) => {
  // Only a vampire's page hears of the night's Whisper; a villager's list stays empty.
  showNightPartners(whispers.flatMap((whisper) => whisper.playerIds).filter((id) => id !== me));
});

Every page mutes, the vampires’ included. A vampire in the Whisper is out of the Call already and is still heard in the Whisper, so muting them changes nothing for them, and the same call on every page means no page needs to know a role to decide it. It also silences a vampire left without a Whisper (the only one alive, or whose partner dropped).

What the table sees at night: the Call goes quiet and every camera goes dark at once. Each player’s own tile says the game closed their microphone and their camera (“Oyun kameranı masaya kapattı”); a vampire’s own tile also says whom they whisper with, and they hear and write to each other. No tile says anything about anyone else. Because both settings are covered, a dark tile tells nobody who is a vampire, and the vampires’ Whisper carries their cameras: where the page gives each player’s camera a video slot, a vampire sees the other vampires live under “Masa görmüyor”, while a villager’s slots show what the table sees, the dark frame.

The design accepts two limits: Call Mute and Call Cover are applied in each player’s own browser and keep honest players in step, not a player who edits their browser (see Call Mute above); and in host mode the host’s page holds the roles and the groups, so the host can know who the vampires are. A game that must hide its vampires from the host runs in engine mode.

A day of private chats, worked through: a social deduction game

By day in Blood on the Clocktower anyone may take anyone aside, everyone sees who went with whom, and nobody hears what they said. The cameras stay open: a player who steps aside is still seen at the table, so the day needs no callCover (and no call.cover if the game covers nothing else).

function dawn(state: ClocktowerState): ClocktowerState {
  // Whispers by request, told to every page; every camera open to the Call.
  const { callCover: _night, ...day } = state;
  return { ...day, phase: "day", whispers: { requests: "open", visibility: "public" } };
}

Each page offers its player the others to whisper with (requestWhisper, acceptWhisperRequest) and draws who is with whom from onWhisperChange, which tells every page of every Whisper while they are public. The table goes on seeing every camera; a whisperer’s own slots carry “Masa da görüyor”, so they know they are still watched. At dusk the rules close requests, which ends every Whisper made by request (requests_closed), and the night’s storyteller can then name groups and cover cameras as the night phase above does.