# Plugin SDK The `@lobbyforge/plugin-sdk` package is the contract between the web app (plugin host) and the plugins in `plugins/*`. It exports: - The `GamePlugin` / `RegisteredGamePlugin` / `GamePluginContext` / `PluginManifest` / `PluginPermission` types every plugin or host registry uses. - The `registerGamePlugin` helper that erases a strongly typed plugin into the host-side registry shape without exposing `any` at every call site. - The `createTestHarness` test helper (subpath export `@lobbyforge/plugin-sdk/testing`) that gives a plugin test a fully-mocked context so the plugin's reducer can be exercised in isolation — effects and timers included. - Host effects: `withEffects` and the rules the host applies to what a reducer asks for — timers, storage, chat ([Effects](#effects-timers-storage-and-chat)). This document covers the SDK's surface, the `GamePlugin` contract, and how to author a new plugin. The HTTP host side (the routes that dispatch actions to the plugin) is documented in [`docs/ACTIVITIES.md`](./ACTIVITIES.md). ## Exports ```ts import { PluginPermission, type PluginManifest, type PlayersSubContext, type MessagesSubContext, type StateSubContext, type CacheSubContext, type PubSubSubContext, type TimerSubContext, type VotesSubContext, type ScoresSubContext, type VoiceSubContext, type GamePlugin, type RegisteredGamePlugin, type GamePluginContext, type GamePluginHostChange, registerGamePlugin, // Host effects (see "Effects: timers, storage and chat"): withEffects, EFFECT_PERMISSIONS, EFFECT_LIMITS, type PluginEffect, type ReducerResult, type StorageSnapshot, CATALOG_SUMMARY_KEY, CATALOG_NAME_KEY, // M19 shared locale helper (also exported from the // @lobbyforge/plugin-sdk/locale subpath for callers who want // the import path to scream "this is locale code"): tFor, loadPluginLocale, registerPluginLocale, replacePluginLocale, // the host, for a marketplace bundle's locales/ listPluginLocales, detectLocale, pickBestLocale, } from '@lobbyforge/plugin-sdk'; // Test helper (subpath export): import { createTestHarness, type TestHarnessOptions } from '@lobbyforge/plugin-sdk/testing'; // Marketplace plugin UIs in the sandboxed iframe (subpath export, no React, // no dependencies) — see "Marketplace plugin UI (sandboxed iframe)": import { connect, type FrameInitMessage } from '@lobbyforge/plugin-sdk/frame'; ``` The `PluginPermission` constant is an enum-like object whose values are short lowercase strings. Four of them are enforced: `timers`, `storage`, `chat` and `voice.mute` (reserved) are the permissions the host checks before it applies an effect ([Effects](#effects-timers-storage-and-chat)). The others (`read_room_participants`, `send_room_message`, `manage_game_session`, …) describe what a plugin does and grant nothing. ## Manifest fields for app catalog The SDK manifest is also the source for the official App Catalog. `manifest.catalog` exposes enough product metadata for lobby and registry UI without letting the registry read private instance state: ```ts type PluginCatalogMetadata = { category?: "game" | "bot" | "integration" | "utility"; summary?: string; publisher?: string; trustLevel?: "official" | "verified-community" | "unverified"; playerConfig?: { minPlayers?: number; maxPlayers?: number; defaultMaxPlayers?: number; supportsSpectators?: boolean; supportsQueue?: boolean; overflowPolicy?: "spectator" | "queue" | "split" | "reject"; }; requiresVoiceRoom?: boolean; externalAccountRequired?: boolean; externalAccountProvider?: string; compatibleAppVersion?: string; tags?: string[]; }; ``` `requiresVoiceRoom` is enforced, not just shown: a game that declares it is played over voice, so the host refuses a `member` / `player` action from anyone who is not in the activity's voice room (403, `code: 'voice_required'`) and hands hosting over when the host leaves the room — see [Voice, hosting and play again](#voice-hosting-and-play-again). Poll and Dice Bot declare `false`: anyone who can see the channel takes part. `externalAccountRequired` means the app needs an explicit account-linking flow for a third-party service. It must not silently replace instance auth. The host still authorizes the user through the local instance session and then lets the app request a scoped external connection when needed. Official plugins should fill `publisher`, `trustLevel`, `playerConfig`, `requiresVoiceRoom`, and `tags`. The `/api/plugins` listing returns this catalog metadata alongside the stable `id`, `name`, `version`, and `type`. ## The `GamePlugin` contract ```ts interface GamePlugin { manifest: PluginManifest; actionPolicies?: Record; createInitialState: (ctx: GamePluginContext) => TReturn; handleAction: (ctx: GamePluginContext, state: TState, action: TAction) => TReturn; validateAction?: (action: unknown) => string | null; restartActions?: readonly string[]; onHostChange?: (state: TState, change: GamePluginHostChange) => TState; migrateState?: (raw: unknown) => TState; renderClient: (props: TProps) => ReactNode; } ``` - `manifest.id` is the stable string the registry, the database, and the audit log all key on. It must match `[a-z0-9-]{1,64}` and be unique across `PLUGINS`. - `createInitialState(ctx)` is called by the host when an activity starts. The returned value is the row's initial `state` JSONB. - `handleAction(ctx, state, action)` is a pure reducer. The host calls it from the `actions` route; the return value is what `setGameSessionState` writes. Most plugins (quiz included) treat this as a `switch (action.type)` and never touch `ctx` — the SDK shape is the same for HTTP and voice-room hosts. - `TReturn` is what both reducers return: the state by default. A plugin that asks the host for effects declares `ReducerResult` and returns `withEffects(next, effects)` where it needs them ([Effects](#effects-timers-storage-and-chat)). - `restartActions` lists a game's "play again" actions (Hushle `start-game`, Quiz `play-again`). Declaring it turns on the host's ended-phase guard: once `state.phase === 'ended'`, only these action types reach the reducer; every other one is refused with 409 (`code: 'session_ended'`). Leave it undefined when the reducer should decide everything after the end (Vampire Village keeps its post-game chat and its own `play-again`). See [Voice, hosting and play again](#voice-hosting-and-play-again). - `onHostChange(state, change)` is called when the host hands the session to someone else because its host left the voice room. Pure, like the reducer: return the state with the plugin's own notion of "host" updated, or the same object. Most plugins need nothing here — the panel's `hostUserId` and the `host` policy follow the session host on their own. Watch Party, which keeps a party host in its state, uses it. - `migrateState(raw)` (M19+) is the migration seam. The host runs it on every read against `game_sessions.state`; whatever the function returns is what the reducer + `renderClient` see. The function must be **idempotent** — the same blob may be re-read many times. When the plugin's state shape evolves, the plugin author adds a new step to the migrator chain and bumps the internal `version` field; the next read automatically upgrades old sessions in the database. The Hushle reducer is the worked example — see `migrateHushleState` in `plugins/hushle/src/state.ts`. - `renderClient(props)` is the React component for the activity panel. M17 wires this from the voice room's `ActivityPanel` — see [Per-plugin renderClient](#per-plugin-renderclient) below for the props contract and the worked example (`plugins/hushle/src/renderClient.tsx`). ## Registry adapter Plugin packages should export a strongly typed `GamePlugin`. A host catalog, however, stores many different plugins in one list. Use `registerGamePlugin()` at the catalog boundary: ```ts import { registerGamePlugin, type RegisteredGamePlugin } from '@lobbyforge/plugin-sdk'; import { quizPlugin } from '@lobbyforge/quiz'; export const PLUGINS: readonly RegisteredGamePlugin[] = [ registerGamePlugin(quizPlugin), ] as const; ``` `RegisteredGamePlugin` keeps `manifest` and `actionPolicies` visible while its runtime calls accept `unknown` state/action payloads. The host owns validation, authorization, and persistence at that boundary; plugin authors still get their specific reducer types inside their own package and tests. ## Action policies The host authorizes every action before it calls `handleAction`. Unknown action types default to `host`, so new actions are safe by default. ```ts type GamePluginActionPolicy = { role: "host" | "member" | "player" | "system"; actorFields?: string[]; joinsRoster?: boolean; audit?: boolean; allowOutsideVoice?: boolean; }; ``` - `host`: only the session creator can perform the action. A user with `START_ACTIVITY` can also perform it for moderation/admin control. - `member`: any server member can perform the action. - `system`: no client may send it (403, `code: 'system_action'`); the host runs it when a timer the plugin set falls due ([System actions](#system-actions)). - `player`: the user must be an active player in `game_session_players`. An action joins the actor to that roster when its policy says `joinsRoster: true` AND the action changes state (a refused join — the reducer returned the same state object — adds no one). Opt in for join/ready actions and for public actions whose author is shown anyway (a dice roll); never for an anonymous one — the roster is visible to every viewer, so a poll vote that joined it would name the voter. While a joining action runs, `ctx.players` already includes the actor. The roster fills `ctx.players` (named by character name, then display name) and the panel's `players` prop; in the lobby, the people in the voice channel are added to `players` as well, so a host can seat them by name. - `actorFields`: fields overwritten by the host with `ctx.actorUserId`. Use this for `playerId`, `voterId`, `hostId`, and similar identity fields. - `audit`: whether an action that changes state writes an `activity.action` row (actor, plugin id, action type) to the server's audit log. Defaults to `true` for `host` actions (configure, start, kick, reveal — running the table) and `false` for `member` / `player` actions (gameplay). Every `VIEW_AUDIT_LOG` holder reads that log, so "who sent which action type, when" is public to them: a night action would name a hidden role and a vote row lined up with the poll counts would name the voter. Set `audit: true` only on an action whose author and type are public anyway; set `audit: false` on a host action whose type alone gives a secret away. A refused action (the reducer returned the same state) is never audited. `shouldAuditAction(policy)` in the SDK is the rule the host applies. - `allowOutsideVoice`: for a plugin with `catalog.requiresVoiceRoom`, the host refuses `member` / `player` actions from anyone who is not in the activity's voice room. Set this on an action that must work from outside the room — leaving the game is the usual one (Quiz, Vampire Village and Watch Party mark `leave`). `host` actions are never voice-checked. It also marks the one thing a member who is **timed out** in the community may still do: a timed-out member starts no activity and sends no other action, host actions included (`403`, `code: "timed_out"`, with `until`) — as they cannot chat, vote in a poll or run a slash command. Mark leaving, so they can always get out of a game. Do not trust actor identity fields sent by the browser. ## Voice, hosting and play again The host route (`apps/web/app/api/servers/[id]/activities/…`) applies three rules on top of the action policies. Every refusal keeps its English `error` and carries a machine `code` the lobby translates: `{ "error": "…", "code": "…" }`. | Code | Status | When | |---|---|---| | `session_ended` | 409 | an action on an ended session (its row), or on a finished game (`phase: 'ended'`) that is not one of its `restartActions` | | `not_host` | 403 | a `host` action, or ending the activity, by someone who is neither the host nor holds Start Activities | | `voice_required` | 403 | a `member` / `player` action (or ending an abandoned session) from outside the activity's voice room, for a plugin that requires voice | | `not_player` | 403 | a `player` action from someone who is not on the roster | | `wrong_phase` | 409 | the host's own phase table refuses the action (Hushle/Quiz: not started yet, answers closed) | | `activity_exists` | 409 | starting an activity in a channel that already has one open (`sessionId` names it) — also when two starts race | | `system_action` | 403 | a client sent a `role: "system"` action (only the host runs those, when a timer falls due) | | `effect_refused` | 422 | the plugin's reducer asked for an effect that is invalid or that it has no permission for: nothing of the action (or the start) was applied | | `rate_limited` | 429 | too many requests (every 429 of the app carries it) | **Voice.** "In the voice room" means connected to the activity channel's LiveKit room, as LiveKit reports it (`RoomServiceClient.listParticipants`, cached for 2 s per web process; `apps/web/lib/activity-voice.ts`) — not the browser's presence heartbeat, which lags a departure by up to 90 s. Reading the state never needs voice, so spectators keep watching. When LiveKit cannot be asked the check is skipped and logged (a game rule, not a security boundary: an outage must not freeze every game). The plugin's `ctx.voice.getParticipants()` returns the room's user ids, oldest first, for the action being handled. **Host transfer.** For a plugin that requires voice, the host is someone in the voice room (`apps/web/lib/activity-host.ts`): - after the host has been **out of the room for 60 s**, hosting (`game_sessions.created_by`, which the `host` policy and the panel's `hostUserId` read) moves to the **longest-present participant in the room** — players on the activity's roster first, then anyone else in the room, each by how long they have been connected. Watch Party's own hand-over when its host leaves uses the same order, and its party host follows the session host through `onHostChange`; - after **3 minutes**, or past the 60 s when nobody in the room can take over, the session is **abandoned**: any voice participant may end it. It is lazy and deterministic — no timer runs anywhere. The rule is applied whenever someone touches the session: an action, a state read (`GET`) or an end request. "Out of the room since" is a small Redis ledger: the LiveKit webhook records `participant_left` (ignoring a second connection of the same user) and clears it on `participant_joined`; a reader that finds the host gone with no entry starts the clock then, so it never starts early. The move itself is a compare-and-swap on the old host under the session's write lock (two requests move it once), audited as `activity.host_transfer` (actor: none, metadata: from, to, seconds away), and announced on the activity bus as `rosterChanged` so open panels re-read the session. When the host or a player (anyone on the session's roster) leaves the voice room or comes back, the webhook sends `presenceChanged`, and panels re-read the same way. **Dropped connections.** The bus keeps no backlog: an event published while a panel's socket was down is gone. The lobby therefore re-reads the session whenever its realtime socket authenticates again, when the browser reports the network back (`online`, which also replaces a socket that only looks open), and when the tab is shown after at least 5 s in the background. Becoming visible or coming online also lifts the realtime client's 20-attempt circuit breaker. An action whose answer is lost (no response at all) or refused with a retryable `503` is sent again with the **same** `actionId` for up to 8 s; if the first attempt had committed, the retry comes back as a duplicate and the panel re-reads instead of applying it twice. A plugin needs no code for any of this. `GET …/activities/{sessionId}` adds, for these plugins, `host: { userId, inVoice, awaySince, transferAt, abandonAt, abandoned }` (ISO times): a panel shows "the host left — hosting moves at …" and reads again at `transferAt` / `abandonAt`, which is what applies the rule if nobody acted in between. The end route answers a participant who may not end it yet with `not_host` and the same `host` object. Poll and Dice Bot do not require voice: their host keeps the session wherever they are; Start Activities holders can always end it. **Play again.** A game that declares `restartActions` gets its "play again" from the final screen through the normal actions route, with its policy (both official ones are `host`): Hushle's "Start new game" sends `start-game` (back to team setup with the same settings), Quiz's "Play again" sends `play-again` (back to the lobby: the players who stayed keep their seats with a clean score, the last game's deck, answers and reveal are dropped, anyone may join before the host starts). ## Effects: timers, storage and chat A reducer stays pure and synchronous: it cannot await a database, schedule anything or post a message. What it wants the host to do beyond the next state, it **returns** as effects, wrapped around the state: ```ts import { withEffects, type GamePlugin, type ReducerResult } from '@lobbyforge/plugin-sdk'; export const buzzerPlugin: GamePlugin> = { manifest: { /* … */ permissions: ['timers', 'storage', 'chat'] }, actionPolicies: { 'open-round': { role: 'host' }, 'auto-reveal': { role: 'system' } }, handleAction(ctx, state, action) { if (action.type === 'open-round') { const next = { ...state, phase: 'open', round: state.round + 1, revealAt: ctx.now! + 30_000 }; return withEffects(next, [ { type: 'timer.set', id: 'reveal', afterMs: 30_000, action: { type: 'auto-reveal', round: next.round } }, ]); } // … return state; }, }; ``` `withEffects(state, effects)` returns `{ __lf: 1, state, effects }`; a marketplace plugin's `server.js` has no imports and writes that object itself. `__lf` is reserved: a plain state must not have it. `createInitialState` may return effects too (a first timer, say). **All or nothing.** The host unwraps the envelope wherever it consumes a reducer result and checks every effect — its shape, its limits, and that the plugin has the permission it needs. One invalid or unpermitted effect refuses the whole call: an action answers `422` with `code: 'effect_refused'` and nothing of it is applied (no state, no timer, no storage, no chat line); a start creates no activity; a timer's action is dropped and logged. The reason goes to the server log, not to the client. Returning the **same** state object with no effects (or `withEffects(state, [])`) still means "no change" — nothing is audited and no one joins the roster; the same state *with* effects counts as a change. **Permissions.** A compiled-in plugin declares them in `manifest.permissions` (`PluginPermission.TIMERS`, `.STORAGE`, `.CHAT`): it is trusted code, so there is no approval dialog. A marketplace plugin asks for them in manifest.json `permissions`, and gets only what it asks for **and** the instance owner approved at install ([PLUGIN_PUBLISHING.md → Asking for more than the sandbox](PLUGIN_PUBLISHING.md#asking-for-more-than-the-sandbox)). The older `PluginPermission` names (`manage_timer`, `send_room_message`, …) describe a plugin and grant nothing. | Effect | Permission | Fields | Limits | |---|---|---|---| | `timer.set` | `timers` | `id`, `afterMs` **or** `at` (epoch ms), `action` | at most **8** pending per session; due **1 s – 24 h** after `ctx.now`; `id` is 1–64 of `A-Z a-z 0-9 . _ : -`, and the same id replaces the pending timer; `action` must be one of the plugin's `role: "system"` actions, at most 4 KiB of JSON | | `timer.clear` | `timers` | `id` | clearing a timer that is not pending is fine | | `storage.set` | `storage` | `key`, `value` | `key` is 1–128 of `a-z A-Z 0-9 . _ : -` (not `__proto__`); `value` any JSON; the plugin's storage in a community holds at most **64 KiB** (every key plus its value's JSON, in UTF-8) | | `storage.delete` | `storage` | `key` | | | `chat.post` | `chat` | `text` | 1–**500** characters after trimming, no control characters but tab and newline; at most **one per 5 s and 30 per hour** per session, and **60 per hour** per community and plugin; checked by the community's Moderation Bot | At most 32 effects per call; an unknown key on an effect is refused (a typo never silently does something else). `voice.mute` is a known permission name a manifest may ask for, but the effect does not exist yet (phase 3b of [PLUGIN_UNLOCK_PLAN.md](PLUGIN_UNLOCK_PLAN.md)): it is refused. ### System actions An action policy may say `role: "system"`. **No client can send it** — not the host, not an administrator: the actions route answers `403` with `code: 'system_action'`. Only the host runs it, when a timer that names it falls due. The reducer then sees `ctx.actorUserId === ''` (a marketplace plugin's `ctx.actorId` is `null`) and `ctx.now` = the timer's due time. A system action takes no `actorFields`, never joins the roster and has no voice rule (a sandbox manifest refuses those keys on it); `audit` defaults to `false`. The host's phase checks and the ended-phase guard (`restartActions`) are for clients: a system action always reaches the reducer, so make it check that it still applies — the example's `auto-reveal` carries the round it was set for and changes nothing in a later round. ### Timers fire lazily Timers follow the design of the host hand-over ([ACTIVITY_INTEGRITY_AND_RESILIENCE.md §3.4](ACTIVITY_INTEGRITY_AND_RESILIENCE.md)): nothing runs on a clock. - The pending timers are stored with the session (`game_sessions.timers`) in the **same** compare-and-swap as the state that set them. - Whenever somebody touches the session — a state read (`GET`), an action, an end request — every timer whose due time has passed fires under the session's write lock, **earliest first** (ties: the one set first), each as its system action through your reducer with `ctx.now` = its due time. Its effects apply too, so a timer may set the next one, and a chain that is already due keeps firing in the same touch. - **Catch-up is bounded.** A session nobody touched for a while can have a backlog: a 1 s timer that re-arms itself and sat idle for an hour is 3 600 firings behind. The host does not replay it: - **Missed occurrences collapse.** When a timer's firing sets a timer with the **same id** (it re-arms itself) at a time that is already due, the host moves that timer to the **latest** occurrence of the same period that is due. The idle 1 s ticker fires twice — for its first due time and for the last second that passed — and its next firing is in the future again. Do not count ticks to measure time; read `ctx.now` (the due time of the firing) and keep deadlines in your state. A timer set under a **new** id is never moved. - **A touch fires at most 8 timers**, and starts none after 500 ms of wall time (the first always runs). What is still due fires on the next touch: the session GET reports it in `timersDueAt`, so an open panel re-reads for it at once. - Before an action, the timers due by then fire first and the action runs on the state they left — in one write. If the action is then refused, the timers still commit. If more timers were due than one touch fires, the action does **not** run ahead of them: what fired commits and the action answers `409` with `code: "timers_pending"` and `retryable: true`. The lobby retries it by itself (same `actionId`), each retry continuing the catch-up. - **Exactly once**: the lock serializes touches and a fired timer is removed in the same write as the state it produced, so of two concurrent reads the second finds nothing due. - A timer whose action throws or whose effects are refused is dropped and logged, and the next one runs. So is a timer whose action is no longer a system action (a new version of the plugin changed it). - `GET …/activities/{sessionId}` returns `timersDueAt` — the pending due times, ISO, earliest first; **never** the actions — and `now`, the server's clock. An open panel re-reads the session once at the earliest due time, counted on the server's clock (`nextActivityCheck` in `apps/web/lib/activity-next-check.ts`, which also covers the host hand-over); setting, clearing or firing a timer tells every open panel to re-read (`publicSummary.timersChanged` on the activity bus), and an action's answer carries the new `timersDueAt`. A due time that is still pending after the panel re-read for it (the server could not fire it yet) is re-read for again after 2 s, then 4 s, 8 s … at most once a minute — never in a loop. - Ending the activity fires what was already due (within the per-touch bound above), then drops every other pending timer. - **Only while the app is installed.** If the community uninstalls or disables the app (Server settings → Apps), its open sessions end there and then (audited as `activity.end` with the reason `app_uninstalled` / `app_disabled`; nothing fires on the way out). A session that is still open by a race takes no more actions (`403`, `code: "app_not_installed"`), its due timers are dropped without running, and storage writes and chat lines asked for after the removal are dropped. So a timer is **never early, and late only when nobody is looking** — which is exactly when nobody would notice. Keep the deadline in your state (`revealAt`) and let the UI count down to it. ### Storage `ctx.storage` is a read-only snapshot of your plugin's storage in this community, taken under the session's lock before the call: `ctx.storage.get(key)` returns a copy of the value (or `undefined`), `ctx.storage.keys()` the sorted keys. Without the `storage` permission it is empty. A marketplace plugin gets the same two functions; the host sends the snapshot with the call as JSON. Writes are `storage.set` / `storage.delete` effects, and the order matters: 1. **Before the commit** the host applies them to the snapshot and checks the 64 KiB limit — over it, the call is refused like an invalid effect. 2. The state (and timers) commit. 3. **After the commit** the writes go to the database as one batch (the last effect on a key wins), re-measured under a lock on (community, plugin): another session may have written in between. A batch that no longer fits, or a write that fails, is **dropped and logged**; it is not retried and the state stays committed. Treat the state as the source of truth and storage as a side record. Storage is shared by every session of the plugin in the community and holds whole values: two sessions that read, change and write the same key at the same moment can overwrite each other (last write wins). Merge with what the snapshot holds when you write, as the example's leaderboard does, and keep values small. ### Chat An activity runs in a voice or stage channel, and those have no text chat in the lobby. So a `chat.post` goes to the text or announcement channel the community chose for the app — Server settings → Apps → **App messages go to** (shown for apps with the `chat` permission). With no channel chosen, the default, the post is dropped: a game never writes into a channel nobody picked (which could also carry a private voice room's game into a public channel). The choice is checked again on every post. Choosing a **role-gated** (private) channel takes what granting a bot one takes: Manage Channels (owners and administrators have it), and seeing the channel — Manage Server alone is refused with `403`, `code: "chat_channel_not_allowed"`. Saving other settings of the app keeps a channel someone else chose. - The message has **no author** (no user, no bot) and `metadata.app = { pluginId, name }`; the lobby shows it with the plugin's name, an app avatar and an **APP** badge ("UYGULAMA" in Turkish), and a desktop notification says "(APP)". Members cannot set `metadata.app`. - Mentions are inert: every `@` that could start one gets a zero-width space after it, so `@everyone`, `@here` and `@` ping nobody. - Over the rate (one per 5 s and 30 per hour per session, and 60 per hour per community and plugin, so restarting the activity does not reset it) a post is **dropped** and logged; the action that asked for it still applies. Chat is a side channel, never part of your game's state. - **Moderated like everyone's messages.** When the community runs the Moderation Bot, its blocked-word and link rules apply to the line as you wrote it (the mention rule does not: your mentions are inert anyway). A line the rules block is **dropped** and logged, and audited as `bot.moderation.block` naming your plugin (without the line's words when the channel is role-gated); the action still applies. No notice is posted and there is no exemption. - It is posted after the commit (after the storage batch), published on the chat bus like any message and audited as `message.create` (actor: none, the plugin in the metadata). Everyone in that channel reads the line: never post hidden information. `ctx.locale` is the caller's language (for a system action, the language of whoever's touch fired it), so prefer short, language-light lines — names and numbers. ### Testing effects `createTestHarness` applies effects with the same rules as the host ([below](#the-createtestharness-test-helper)): `performAction` throws an `EffectRefusedError` where the host would answer `effect_refused` (and applies nothing), refuses a system action from a client, and `advanceTime(ms)` fires due timers in order with `ctx.now` = each due time — every occurrence, as for a session somebody watches the whole time (the host's idle catch-up collapse and per-touch bound above do not apply). `getTimers()`, `getStorage()` and `getChatPosts()` show what the host would have stored and posted; `permissions` and `storage` options set the plugin's permissions and its storage before the game. ### Deprecated: `ctx.timer` and `ctx.messages` `ctx.timer.start/stop` and `ctx.messages.sendGameMessage` never did anything on the HTTP host (the latter only logged). They stay as no-op stubs for compatibility; use `timer.set` / `timer.clear` and `chat.post`. `ctx.storage` used to be an async `get/set/delete/list/clear` object that no plugin used — a synchronous reducer cannot await it — and is now the snapshot above. ## The `GamePluginContext` sub-contexts The context has `actorUserId`, `now` and ten sub-contexts, all part of the SDK contract: | Sub-context | Sync / async | Used for | |---|---|---| | `actorUserId` | sync | Local user id that triggered the current action (`''` for a system action) | | `now` | sync | The server clock of the call, ms; a timer's due time for a system action. Prefer it to `Date.now()` | | `players` | sync (M16) | `list()` returns active player ids, `get(id)` returns `{ id, name }` | | `storage` | sync | `get(key)` / `keys()` — a read-only snapshot of the plugin's storage in the community; writes are effects ([Storage](#storage)) | | `messages` | async | **deprecated**: `sendGameMessage(text)` only logs — return a `chat.post` effect | | `state` | async | `save(state)` — the HTTP host treats this as a no-op (it persists state itself) | | `cache` | async | `get` / `set` with TTL — HTTP host returns `undefined` / no-op | | `pubsub` | async | `publish` / `subscribe` — HTTP host is a no-op | | `timer` | async | **deprecated** no-op: return `timer.set` / `timer.clear` effects | | `votes` | async | `create(question, options)` — HTTP host is a no-op | | `scores` | async | `add(playerId, score)` — HTTP host is a no-op | | `voice` | sync | `getParticipants()` — the user ids in the activity's voice room, oldest first, for plugins that require voice (`[]` otherwise) | The HTTP host (`apps/web/lib/plugin-context.ts:buildHttpPluginContext`) implements the contract with the sub-contexts a plugin would expect, but most of them are intentionally inert. A plugin that calls `state.save` doesn't get an error — it just doesn't get persistence, because the host persists the post-`handleAction` state itself. This keeps the plugin's `handleAction` honest (it can call `state.save` if it wants) while the host stays in control of the read-modify-write cycle. ## Authoring a new plugin 1. Create a package under `plugins/{id}/` with the standard layout: ``` plugins/{id}/ ├── package.json # name: "@lobbyforge/{id}", workspace dep on @lobbyforge/plugin-sdk ├── src/ │ ├── index.ts # exports the GamePlugin + any action / state types │ └── __tests__/ │ └── {id}.test.ts # uses createTestHarness └── tsconfig.json ``` 2. Define `TState` and `TAction` types in `src/index.ts`. Keep the action shape small and add `actionPolicies` for public action types. Any actor identity field must be listed in `actorFields`. 3. Implement `createInitialState` and `handleAction` as pure reducers. Use the `createTestHarness` from `@lobbyforge/plugin-sdk/testing` to test them in isolation. 4. Add the plugin to the registry in `apps/web/lib/plugin-registry.ts`: ```ts import { yourPlugin } from '@lobbyforge/your-id'; import { registerGamePlugin } from '@lobbyforge/plugin-sdk'; export const PLUGINS = [ registerGamePlugin(quizPlugin), registerGamePlugin(yourPlugin), ] as const; ``` 5. If the plugin needs a workspace dep in `apps/web` (it does, for the registry import to resolve), add it to `apps/web/package.json`'s `dependencies` and re-run `pnpm install`. ## The `createTestHarness` test helper ```ts import { createTestHarness } from '@lobbyforge/plugin-sdk/testing'; import { quizPlugin, type QuizState, type QuizAction } from '@lobbyforge/quiz'; const harness = createTestHarness({ plugin: quizPlugin, players: ['p1', 'p2'], }); await harness.startGame(); const initial = harness.getState(); // { questions: [], currentIndex: 0, ... } await harness.performAction('p1', { type: 'set-questions', questions: [] }); await harness.performAction('p1', { type: 'answer', index: 0 }); const after = harness.getState(); // { ..., totalAnswered: 1 } ``` The harness exposes: - `context` — the full `GamePluginContext` if your reducer needs to assert on sub-context calls. - `startGame()` — calls `plugin.createInitialState(ctx)`. - `performAction(playerId, action)` — calls `plugin.handleAction(ctx, state, action)` and applies its effects the way the host does: an invalid or unpermitted effect throws `EffectRefusedError` and nothing is applied; a `role: "system"` action is refused (only a timer fires it). - `getState()` — the current state. Throws until there is one. - `advanceTime(ms)` — moves the harness clock and fires every timer that falls due on the way, in due order, each as its system action with `ctx.now` = its due time (and its effects). `advanceTimer(seconds)` is the old name. - `now()`, `getTimers()`, `getStorage()`, `getChatPosts()` — the clock, the pending timers, the plugin's storage and the chat lines posted so far. Options besides `plugin` and `players`: `now` (the clock's start), `permissions` (default: the effect permissions in `manifest.permissions`) and `storage` (the plugin's storage before the game, what `ctx.storage` reads). ```ts const h = createTestHarness({ plugin: buzzerPlugin, players: ['host'], now: 0, storage: { best: [] } }); await h.startGame(); await h.performAction('host', { type: 'open-round' }); expect(h.getTimers()).toEqual([{ id: 'reveal', at: 30_000, action: { type: 'auto-reveal', round: 1 } }]); await h.advanceTime(30_000); expect(h.getState().phase).toBe('revealed'); ``` The mock sub-contexts are documented in `packages/plugin-sdk/src/testing.ts`. The `cache.get` / `set` are in-memory `Map`s, the `scores.add` updates an in-memory score table, and the `pubsub.publish` / `subscribe` are no-ops. The `players.list` returns whatever ids the test passed in. ## What M16 doesn't do - **No community sandbox yet.** Official plugins now have host-side action policies, but community plugins still require package signing, capability review, sandboxing, install approval, and rollback before they can be enabled. - **No plugin-specific UI.** The `ActivityPanel` in `apps/web/app/room/[roomName]/page.tsx` is generic; the plugin's `renderClient` is unused. M17+ will dynamic-import each plugin's `renderClient` based on the session's `pluginId`. - **Limited plugin settings.** The host now reads `plugins_enabled` and exposes `/api/servers/{id}/apps` plus a basic Server Apps tab for install, enable/disable, `defaultMaxPlayers`, and `overflowPolicy`. Per-channel, per-role, and plugin-specific settings screens are still M17+. - **No real-time updates.** The activity panel polls `GET /api/servers/{id}/activities/{sessionId}` every 2s. Server-Sent Events or WebSocket are M17+. ## Shared locale helper (M19) Adding a new language to a plugin (or to a bot — the bot SDK has the same helper) is intentionally a single-place change: ```ts // plugins/{id}/src/renderClient.tsx import { loadPluginLocale, pickBestLocale, detectLocale, tFor } from '@lobbyforge/plugin-sdk'; import en from '../locales/en.json'; import tr from '../locales/tr.json'; loadPluginLocale('hushle', { en, tr }); const locale = pickBestLocale('hushle', detectLocale('en')); const text = tFor('hushle', locale, 'lobby.title', undefined, 'en'); ``` The whole pattern is: 1. The plugin ships `locales/{lang}.json` bundles. 2. At module load, `loadPluginLocale(pluginId, { en, tr })` registers each table against the shared registry keyed by `pluginId`. 3. `tFor(pluginId, locale, key, params?, fallback?)` resolves a string for the active locale and fills its arguments — `{name}`, and plurals such as `{count, plural, one {# point} other {# points}}`. It is the same message format as the app's own catalogues (`formatMessage` in `src/message-format.ts`; see `docs/TRANSLATING.md` → "Plurals"), so pass counts as numbers and let each language write its own plural forms. Put your catalogue description under the key `catalog.summary` (`CATALOG_SUMMARY_KEY`) and your activity's name under `catalog.name` (`CATALOG_NAME_KEY`): the host shows both in the activity picker, the activity header, the admin and community app lists, the hub's landing and marketplace pages in the viewer's language (falling back to `manifest.name` when a language has no name), and the manifest can read its English from the same file — `summary: LOCALE_TABLES.en[CATALOG_SUMMARY_KEY]`. A plugin's locale test treats both keys as rendered (by the host). Marketplace (`sandbox-v1`) plugins use the same two keys: they ship the same tables as `locales/.json` in their bundle, and the host validates them and registers them under the plugin's id when the plugin loads (`replacePluginLocale`, which also drops what an older version shipped). See [PLUGIN_PUBLISHING.md → `locales/`](PLUGIN_PUBLISHING.md#locales-optional-your-name-and-description-in-every-language). 4. `listPluginLocales(pluginId)` returns the locales the plugin actually supports in registration order (so the first registered is the primary fallback when the user's preference isn't shipped). 5. `pickBestLocale(pluginId, preferred, fallback)` matches a region-tagged preference against the plugin's set (`tr-TR` → `tr`), then falls back to `fallback`, then to the first registered. The bot SDK ships the same surface (`@lobbyforge/bot-sdk/locale`): ```ts import { loadBotLocale, tFor } from '@lobbyforge/bot-sdk'; import en from './locales/en.json'; loadBotLocale('music-bot', { en }); ``` Why a per-plugin registry and not a single shared JSON bundle? - The plugin is the source of truth for what strings it needs. - Community plugins ship their own loaders without touching the SDK or the host. - Adding a language to one plugin doesn't require touching every other plugin or every other bot. - The host's UI language switcher (when it ships) reads `listPluginLocales(pluginId)` to discover what's available. The plugin-sdk subpath `@lobbyforge/plugin-sdk/locale` exports the same surface for callers who prefer the dedicated import path. The registry is one per process: it lives on `globalThis`, not in the module. Next evaluates the SDK once per server chunk (instrumentation, route handlers, pages), and the host registers a marketplace plugin's tables from whichever chunk loaded it; every copy of `tFor` sees them. `replacePluginLocale(pluginId, tables)` swaps all of a plugin's tables at once (`{}` forgets them); compiled-in plugins keep `loadPluginLocale`. ## Per-plugin renderClient M17 wires the plugin's `renderClient` into the voice room's `ActivityPanel`. The contract between the host and the plugin is: ```ts interface HushlePanelClientProps { state: TState; // server-authoritative reducer output dispatch: (action: TAction) => void | Promise; // POSTs to /actions, no return value actorUserId: string; // current user (the panel reads actor/host for gating) hostUserId: string | null; // session creator, may be null if creator left players: Array<{ userId: string; name?: string | null }>; // for name lookups } ``` Plugins should declare a typed props interface (e.g. `HushlePanelClientProps`) and have the SDK-bound `renderClient` cast on the way in: ```ts export const hushlePlugin: GamePlugin = { // … renderClient: (props: unknown) => HushlePanel(props as HushlePanelClientProps), }; ``` The host's `ActivityPanel` resolves the plugin from the registry, then: ```tsx const plugin = getPlugin(activity.pluginId); const ui = plugin ? plugin.client.renderClient({ state: activity.state, dispatch: (action) => fetch(`/api/servers/${serverId}/activities/${sessionId}/actions`, { method: 'POST', headers: { 'content-type': 'application/json' }, body: JSON.stringify(action), }), actorUserId, hostUserId, players: activity.players, }) : null; return ui ?? ; ``` A few conventions plugins should follow: 1. **Mark the file with `"use client"`.** The renderClient is React client code — it uses hooks, accesses `document.documentElement.lang`, etc. Without the directive the Next.js bundler refuses to import it from a server component route. 2. **Build the UI with the kit** (`@lobbyforge/plugin-sdk/ui`, below). The host doesn't ship a CSS framework to the plugin — Tailwind never sees plugin files — so the kit carries its own theme-aware styles. If you style something by hand, colour it with the host's theme variables and a fallback — `var(--lf-surface, #0e1218)`, `var(--lf-text-primary, #e6e8eb)` — never a bare dark hex. 3. **Bundle your own locales.** One `locales/.json` per language, loaded with `loadPluginLocale` and read with `tFor`; the plugin stays self-contained. `pnpm i18n:add` scaffolds a new language for every plugin at once — see [TRANSLATING.md](TRANSLATING.md). 4. **Gate every action through `dispatch`.** No `fetch`, no DB calls, no side effects in the panel. Every state transition is a reducer call that the host persists. 5. **Return `null` if you have no UI.** The host falls back to the generic JSON panel. This is what `quizPlugin.renderClient` does today and is the right answer for plugins whose state is best inspected raw. The full worked example is Hushle's panel (`plugins/hushle/src/renderClient.tsx` and `src/ui/`). It demonstrates the four-phase machine pattern (`lobby → team_setup → playing → ended`), per-seat views (host, explainer, guessers, the opposing team, spectators) and the locale loader. ## The activity UI kit (`@lobbyforge/plugin-sdk/ui`) Every official panel is built from the same pieces, so games feel like one product and follow the viewer's theme (dark, dim, light) with no work from the plugin. The design reference is the "Plugin UI" page of the LobbyForge design canvas. ```tsx 'use client'; import { ActivityShell, ActivityHeader, PhasePill, TimerRing, Panel, Grid, Button, PlayerChip, Scoreboard, Callout, EmptyState, useSecondsLeft, } from '@lobbyforge/plugin-sdk/ui'; export function QuizPanel({ state, dispatch }: QuizPanelProps) { const left = useSecondsLeft(state.deadline); // counts down to a shared deadline return ( {/* required: theme variables + stylesheet */} {t('quiz.question', { n: 4, total: 10 })}} timer={left == null ? null : } /> … ); } ``` | Piece | Use | |---|---| | `ActivityShell` | The panel root. Defines the `--lfui-*` variables and brings the kit's stylesheet (hover, focus, motion, reduced motion). Required. | | `ActivityHeader`, `PhasePill`, `Badge`, `TimerRing`, `ProgressBar` | Title row, phase, timers. Timers take a translated `label`. | | `Button` | `primary`, `game`, `success`, `danger`, `secondary`, `ghost`; `sm`/`md`/`lg`. | | `SegmentedControl` | Mutually exclusive settings (mode, difficulty). | | `Panel`, `Stack`, `Row`, `Grid`, `SectionLabel`, `Stat` | Surfaces and layout. `Grid min={…}` wraps responsively — panels run from narrow to full width. | | `Avatar`, `PlayerChip`, `Scoreboard` | People and scores. | | `Callout`, `EmptyState` | Messages and "waiting for players". | | `useNow`, `useSecondsLeft`, `secondsUntil`, `formatClock` | Countdowns. Store DEADLINES in state, not "seconds left". | | `tone(name)`, `lf` | Raw colour tokens for anything custom. | Tones: `accent` (the user's accent — primary actions), `game` (amber — games and live state), `success`, `danger`, `info`, `neutral`. Colour is never the only signal: pair it with text, an icon or a count. ## Marketplace plugin UI (sandboxed iframe) Official plugins are trusted code and render React panels in the app. A **marketplace** plugin (`sdk: "sandbox-v1"`, ADR-007) is not trusted, so it brings its own UI as plain web files and the lobby runs them in a sandbox — or, with the instance owner's approval, its UI is the author's own site, framed by the lobby ([Your own site as the UI](#your-own-site-as-the-ui-ui--url-)): ``` my-plugin/ ├── manifest.json # … "sdk": "sandbox-v1", "ui": true ├── server.js # globalThis.plugin = { createInitialState, handleAction, projectState?, … } ├── locales/ # optional: en.json, tr.json … — catalog.name / catalog.summary for the host's screens └── ui/ ├── index.html # the entry; everything under ui/ is served ├── app.js ├── style.css └── lobbyforge-frame.js # the frame client, copied in (no CDN in the sandbox) ``` `locales/` is read by the host, not served to the frame: it names and describes the plugin in each viewer's language in the lobby, the app lists and the marketplace. The frame translates its own UI from strings it ships under `ui/`, in the language `init.locale` gives it (the example's `ui/app.js` does). See [PLUGIN_PUBLISHING.md → `locales/`](PLUGIN_PUBLISHING.md#locales-optional-your-name-and-description-in-every-language). ### What the sandbox is The lobby renders `