Extension SDK

Extensions are user-hosted iframes that communicate with the game through the Darqie SDK. Host your extension anywhere — a public URL, a Vercel/Netlify deploy, or just your local dev server — drop in a darqie-ext.json manifest and paste the URL into Add Custom Extension. Public URLs are fetched through the host's proxy; localhost / private-IP URLs are fetched directly by your browser so no tunneling is required.

Required

Manifest file

Every extension must serve a darqie-ext.json file at its root URL. Darqie reads it when adding an extension and again when the user hits Refresh from manifest.

  1. 1Create darqie-ext.json in the root of your extension (same directory as index.html).
  2. 2Open Extensions → Add Custom, paste your URL and click Fetch. Darqie shows a preview populated from the manifest.
  3. 3Click Add Extension. Hit Refresh from manifest any time to pull in changes.
{
  "$schema": "https://darqie-dungeon.com/sdk/v1/manifest.schema.json",

  // Required
  "name":        "My Initiative Tracker",
  "description": "Tracks turn order and HP for every creature in the scene.",
  "version":     "1.0.0",
  "category":    "Initiative",   // Dice | Overlay | Sound | Initiative | Lore | Utility

  // Optional — recommended
  "updatedAt":   "2026-05-09",   // ISO 8601 date string
  "price":       0,              // USD; 0 = free
  "icon":        "https://example.com/icon.png",        // inactive icon (128×128 PNG/SVG)
  "iconActive":  "https://example.com/icon-active.png", // active/selected icon (128×128)
  "cover":       "https://example.com/cover.jpg",       // marketplace banner (1200×400)
  "author": {
    "name": "Your Name",
    "url":  "https://yoursite.com"
  },

  // Panel dimensions — controls the size of the extension window in the game UI
  "panel": {
    "width":  480,   // px; defaults to 480 if omitted
    "height": 640    // px; omit to allow full-height (capped at viewport − 16 px)
  },

  // Declare that your extension handles the settings page callback
  "hasSettings": true,

  // Informational — helps users understand what the extension accesses
  "permissions": ["sendRoll", "broadcast", "notify"],
  "sdkVersion":  "1"
}
FieldTypeReqNotes
namestringShown in marketplace and game sidebar. Max 80 chars.
descriptionstringShort summary. Max 500 chars.
versionstringSemver string, e.g. "1.0.0".
categorystringOne of: Dice, Overlay, Sound, Initiative, Lore, Utility.
updatedAtstringISO 8601 date of last meaningful update.
pricenumberUSD amount. 0 or omit for free.
iconstringAbsolute URL to inactive icon (PNG/SVG). 128×128 recommended.
iconActivestringAbsolute URL to active/selected icon. Falls back to icon.
coverstringAbsolute URL to marketplace banner (PNG/JPG). 1200×400 recommended.
panel.widthnumberExtension panel width in px. Defaults to 480.
panel.heightnumberExtension panel height in px. Omit to allow full viewport height.
author.namestringAuthor display name.
author.urlstringAuthor website URL.
hasSettingsbooleanSet to true if your extension implements a settings page via Darqie.onSettings().
permissionsstring[]SDK methods the extension uses — informational only.
sdkVersionstringRequired SDK major version, e.g. "1".
obsWidgetsobject[]Array of OBS-overlay widget descriptors (see OBS Overlay widgets section). Optional; only needed if the extension ships overlay widgets.

Step 1

Quickstart

Drop the SDK script into a static HTML file and call Darqie.ready():

<!doctype html>
<html>
  <head>
    <script src="https://darqie-dungeon.com/sdk/v1/darqie-sdk.js"></script>
  </head>
  <body>
    <div id="app">Loading…</div>
    <script>
      Darqie.ready().then(ctx => {
        document.getElementById("app").textContent =
          `Hello, ${ctx.me.displayName}! isDM: ${ctx.isDm}`;
      });
    </script>
  </body>
</html>

Host alongside a darqie-ext.json manifest on any static host (Vercel, Netlify, your own server). Then open /extensions and paste the URL into Add Custom Extension. The SDK handles handshake and auth automatically.

Dev workflow

Local development

You don't need to deploy your extension to test it. Run your dev server locally and add the localhost URL through the same Add Custom Extension form. The webapp recognises localhost and private-IP URLs and lets your browser fetch the manifest directly, bypassing the server-side proxy (which can't reach your machine).

Dev server requirements

  • Serve a darqie-ext.json at the extension root (alongside index.html).
  • Send Access-Control-Allow-Origin: * (or echo the host origin) so the browser fetch isn't blocked by CORS.
  • Serve over HTTPS. Browsers refuse mixed content — a public HTTPS host cannot embed an iframe from an HTTP origin. A self-signed cert works fine for local dev (see below).

Vite example

Add the basic-ssl plugin and a couple of server options:

import { defineConfig } from "vite";
import basicSsl from "@vitejs/plugin-basic-ssl";

export default defineConfig({
  plugins: [basicSsl()],
  server: {
    host: true,        // listen on 0.0.0.0 (LAN-reachable)
    https: true,       // self-signed via plugin
    port: 5173,
    cors: true,        // Access-Control-Allow-Origin: *
  },
});

Adding the local manifest

  1. 1Start your dev server. With the config above: https://localhost:5173/darqie-ext.json.
  2. 2Open that URL in a new browser tab. The browser will warn about the self-signed cert — click AdvancedProceed. You should see the JSON. The cert is now trusted for the rest of your browser session.
  3. 3Open /extensions Add Custom, paste the same URL and click Fetch. The preview populates from your local manifest.
  4. 4Click Add Extension. Edit your source freely — refreshing the host page reloads the iframe and your latest code.

URL ranges treated as "local"

These hostnames trigger the client-side fetch path. Anything else uses the server-side proxy.

  • localhost, 127.0.0.1, ::1
  • 10.0.0.0/8, 172.16.0.0/12, 192.168.0.0/16 (RFC1918)
  • Any hostname ending in .local (mDNS)

Common gotchas

  • ERR_CONNECTION_REFUSED — wrong protocol. With HTTPS dev, the URL must be https://localhost:…, not http://….
  • CORS error in the console — your dev server isn't sending Access-Control-Allow-Origin. Vite sets it automatically with server.cors: true.
  • "Could not load darqie-ext.json" after a fresh browser session — you need to re-accept the self-signed cert. Open the manifest URL in a new tab first.
  • Images fail to load from the manifest — the webapp proxies images through the host. Localhost image URLs are auto-redirected back to your browser, but only if they match the local-URL ranges above.

Reference

Context object

Returned by Darqie.ready() and Darqie.getContext():

// Returned by Darqie.ready() and Darqie.getContext()
{
  game: {
    id:     string;
    slug:   string;
    name:   string;
    system: string;
  };
  scene: { id: string; name: string | null } | null;
  me: {
    id:          string;
    username:    string;
    displayName: string;
  };
  isDm:    boolean;
  dm:      { userId, username, displayName } | null;
  members: Array<{
    userId:      string;
    username:    string;
    displayName: string;
    role:        "dm" | "player" | "observer";
    status:      "active" | "invited" | "left";
  }>;
}

API

Methods reference

Read

Available to all participants.

  • Darqie.getContext()() => Promise<Context>

    Returns the full context object (game, scene, me, isDm, members).

  • Darqie.getMe()() => Promise<User>

    Currently authenticated user.

  • Darqie.getDM()() => Promise<User | null>

    DM of the current game.

  • Darqie.getGame()() => Promise<Game>

    Game metadata: id, slug, name, system.

  • Darqie.getActiveScene()() => Promise<Scene | null>

    Currently active scene, or null if no scene is set.

  • Darqie.getMembers()() => Promise<Member[]>

    All members of the game (players + DM).

  • Darqie.characters.list()() => Promise<Character[]>

    Character library (`token_templates` rows with kind='character'). RLS scopes results: DM sees every character in the game, players see only those whose `ownerId` is their own user id. Each item: `{ id, name, imageUrl, imageUrls[], portraitUrl, description, ac, hp, hpMax, speed, group, ownerId, folderId, linkedTokenId }`. Numeric stats may be null when the underlying `defaults` jsonb is missing the field.

  • Darqie.characters.update()(id: string, patch: Partial<Character>) => Promise<{ ok: true }>

    Write a partial character patch through to the host. Pass any subset of the Character shape — the host splits the patch between top-level columns (`name`, `imageUrls`, `linkedTokenId`, `ownerId`) and the `defaults` JSONB blob (`hp`, `hpMax`, `ac`, `speed`, `description`, `group`, `portraitUrl`), fetches the current defaults so unmentioned fields survive, then writes via the same `tokensApi.updateTemplate` path the host UI uses — so the character → placed-scene-token cascade fires for everyone in the scene. RLS-enforced: a non-DM trying to patch a character they don't own gets rejected at the DB layer. SDK ≥ 1.9.0.

  • Darqie.getSession()() => Promise<{ isActive: boolean; startedAt: string | null; durationSec: number }>

    Current session timer state.

  • Darqie.getSceneMedia()() => Promise<SceneMedia[]>

    All media items placed on the active scene.

  • Darqie.listMedia()() => Promise<MediaItem[]>

    All media in the game's library.

  • Darqie.getDiceHistory(limit?: number) => Promise<DiceMessage[]>

    Returns the recent `type:"dice_roll"` chat messages in chronological order (oldest first). Each item is `{ id, userId, type, content, characterName, characterAvatar, createdAt }` where `content` is the JSON-parsed dice payload (whatever your extension serialised into the chat message — typically `{ total, formula, throws, groups, … }`). Optional `limit` (default 50, max 200). Designed for the cold-start case where a scene-rendered tray boots in an OBS Studio Browser Source — see the "OBS Studio mirror" section.

Write

Available to all participants.

  • Darqie.sendChat(content: string, opts?: { type?: "chat" | "ic" | "ooc" }) => Promise<{ ok: true }>

    Sends a chat message as the current user.

  • Darqie.sendFrame({ src: string, width?: number, height?: number, title?: string }) => Promise<{ ok: true }>

    Sends a chat message that renders as a sandboxed <iframe> embedding the given URL. Width/height capped (default 320×200, max 320×480 px). The iframe runs without same-origin access — it cannot read host cookies or DOM.

  • Darqie.sendRoll(formula: string) => Promise<{ ok: true }>

    Posts a roll formula to chat as a `type:"roll"` message. The host treats the formula as opaque text — any rolling extension (dice, card-draws, fate, etc.) can use it. Examples: "2d6+3", "1d20", "draw 1 from deck". Was `Darqie.rollDice` in v1 (still works, emits a deprecation warning).

  • Darqie.notify(text: string, level?: "info" | "warn" | "error") => Promise<{ ok: true }>

    Logs a notification (currently surfaced as console.info on the host).

  • Darqie.uploadMedia(source: Blob | string, opts?: { name?: string; mediaType?: string }) => Promise<{ id: string; url: string }>

    Uploads arbitrary media (image / audio / video) to the game's media library and returns a public URL the extension can embed in chat, scene placements, etc. Accepts a Blob, File, or a data: URL string. Available to ALL participants — use this instead of `uploadImage` when the uploader may be a player (e.g. embedding a dice-roll snapshot in chat).

  • Darqie.logEvent(eventType: string, payload?: object) => Promise<{ ok: true }>DM only

    Appends an entry to the game's activity log (visible on the DM's Logs page).

DM-only

Restricted to the Dungeon Master. Non-DM callers receive { error: "forbidden_dm_only" }. Gate DM-only UI behind ctx.isDm.

  • Darqie.placeImage({ url, x, y, w?, h? }) => Promise<SceneMedia>DM only

    Places an image on the active scene.

  • Darqie.moveSceneMedia(id: string, { x?, y?, w?, h?, z_index? }) => Promise<{ ok: true }>DM only

    Moves or resizes a placed media item.

  • Darqie.removeSceneMedia(id: string) => Promise<{ ok: true }>DM only

    Removes a placed media item from the scene.

  • Darqie.uploadImage(base64: string, name?: string) => Promise<{ id, url }>DM only

    Uploads a base64-encoded image to the game's media library. Returns the stored URL.

  • Darqie.setActiveScene(sceneId: string) => Promise<{ ok: true }>DM only

    Switches the active scene for all players.

  • Darqie.startSession() => Promise<{ ok: true }>DM only

    Starts the session timer.

  • Darqie.stopSession() => Promise<{ ok: true }>DM only

    Stops and saves the session.

Items (DM-only)

CRUD for the DM-only Items tab. Items are name + image + description with a stack quantity (quantity / quantityMax) and a character binding (characterId — a character template id, or null = unassigned). Every method requires the DM; a player session rejects with { error: "forbidden_dm_only" }.

  • Darqie.items.list() => Promise<Item[]>DM only

    Lists every item in the game. Each `Item` is `{ id, name, imageUrl, imageUrls[], description, quantity, quantityMax, characterId }` — `quantity` is the current count, `quantityMax` the stack cap (null = no cap), and `characterId` the bound character template id (null = unassigned).

  • Darqie.items.create(opts: { name, description?, quantity?, quantityMax?, characterId?, imageUrl? | imageUrls? }) => Promise<{ id: string }>DM only

    Creates an item. Only `name` is required. `characterId` binds it to a character (a kind='character' template id from `Darqie.characters.list()`; omit / null = unassigned). `imageUrl` (single) or `imageUrls` (array) sets the art — point it at a URL from your own upload or from the item-art library. Resolves with the new id.

  • Darqie.items.update(id: string, patch: Partial<Item>) => Promise<{ ok: true }>DM only

    Patches an item. Pass any subset of `{ name, imageUrl | imageUrls, description, quantity, quantityMax, characterId }`. The host merges into the existing row (unmentioned fields survive), so you can e.g. decrement just `quantity` without touching anything else.

  • Darqie.items.delete(id: string) => Promise<{ ok: true }>DM only

    Deletes an item by id.

  • Darqie.items.setCharacter(id: string, characterId: string | null) => Promise<{ ok: true }>DM only

    Binds an item to a character. `characterId` = a kind='character' template id (from `Darqie.characters.list()`), or `null` to unassign. Shorthand for `update(id, { characterId })`.

Canvas

Canvas API

The canvas.* namespace lets extensions read and control the live map canvas — camera position, drawing tools, strokes, grid snapping and scene visibility. All methods operate in map-space pixels (the coordinate system of the scene itself, independent of zoom level).

Camera & viewport

  • Darqie.canvas.getCamera()() => Promise<{ x: number; y: number; zoom: number }>

    Returns current canvas pan offset (x, y in screen pixels) and zoom factor.

  • Darqie.canvas.setCamera(x: number, y: number, zoom: number) => Promise<{ ok: true }>

    Pans the canvas to (x, y) and applies the given zoom level. zoom is clamped to [0.1, 4].

Tools

Activates a tool in the canvas toolbar — the user can then interact normally, or combine with canvas.addStroke for fully programmatic drawing.

  • Darqie.canvas.setTool(tool: ToolMode | null) => Promise<{ ok: true }>

    Activates a canvas tool. Accepts: "pen", "line", "rect", "circle", "triangle", "arrow", "polygon", "text", "eraser", "select", "pointer", null.

Undo / Redo

  • Darqie.canvas.undo() => Promise<{ ok: true }>

    Undoes the last canvas stroke (equivalent to Ctrl+Z).

  • Darqie.canvas.redo() => Promise<{ ok: true }>

    Redoes the last undone stroke (equivalent to Ctrl+Y).

Grid & snapping

  • Darqie.canvas.getGrid()() => Promise<{ cols, rows, cellW, cellH, type, visible }>

    Returns grid metadata. cellW / cellH are cell sizes in map-space pixels.

  • Darqie.canvas.snapToGrid(x: number, y: number, snap?: "center" | "edge") => Promise<{ x: number; y: number }>

    Snaps map-space coordinates to the grid. snap="center" (default) returns the cell's centre; snap="edge" returns the nearest cell boundary.

Drawing strokes

Strokes are instantly broadcast to all connected players and persisted to the database. The shape of the input object:

// Passed to Darqie.canvas.addStroke(data)
{
  tool:        "pen" | "line" | "rect" | "circle" | "triangle" | "arrow" | "polygon" | "text";
  pts:         number[];   // flat [x0,y0, x1,y1, …] in map-space pixels

  // Optional styling
  color?:      string;     // stroke colour hex — default "#FF6B7A"
  fillColor?:  string;     // fill colour hex  — default "#404040"
  width?:      number;     // stroke width px  — default 3
  opacity?:    number;     // 0–1              — default 1
  fillOpacity?: number;    // 0–1; 0 = no fill — default 0
  closed?:     boolean;    // close polygon/pen — default false

  // Text tool only
  text?:       string;
  fontSize?:   number;     // map-space pixels
}
  • Darqie.canvas.addStroke(data: StrokeInput) => Promise<{ id: string }>

    Programmatically draws a stroke and broadcasts it to all players. Returns the new stroke's UUID.

Scene visibility

When hidden, the DM still sees the scene; players see a hidden state. Toggling broadcasts to all clients automatically.

  • Darqie.canvas.getSceneHidden()() => Promise<boolean>

    Whether the scene is currently hidden from players.

  • Darqie.canvas.setSceneHidden(hidden: boolean) => Promise<{ ok: true }>DM only

    Hides or shows the scene for players. DM always sees the scene.

Canvas example

const ctx = await Darqie.ready();

// Read the grid
const grid = await Darqie.canvas.getGrid();
// → { cols: 20, rows: 15, cellW: 150, cellH: 150, type: "square", visible: true }

// Snap an arbitrary coordinate to the nearest cell centre
const { x, y } = await Darqie.canvas.snapToGrid(310, 220, "center");

// Draw a filled rectangle covering one grid cell
await Darqie.canvas.addStroke({
  tool:        "rect",
  pts:         [x - grid.cellW / 2, y - grid.cellH / 2,
                x + grid.cellW / 2, y + grid.cellH / 2],
  color:       "#FF6B7A",
  fillColor:   "#FF6B7A",
  fillOpacity: 0.25,
  width:       2,
});

// Pan the camera so players are centred on that cell
const cam = await Darqie.canvas.getCamera();
await Darqie.canvas.setCamera(cam.x - x * cam.zoom, cam.y - y * cam.zoom, cam.zoom);

Realtime

Events

Each handler returns an unsubscribe function — call it when your component unmounts to avoid memory leaks.

  • Darqie.onChat(handler: (row: ChatRow) => void) => () => void

    Fires when a new chat message is inserted (any type).

  • Darqie.onRoll(handler: (row: ChatRow) => void) => () => void

    Fires when a roll-type chat message arrives (type === "roll"). Any source: dice extension, manual `/roll`, other rolling extensions. Was `Darqie.onDiceRoll` in v1 (still works, emits a deprecation warning).

  • Darqie.onMembersChange(handler: () => void) => () => void

    Fires when a player joins, leaves or has their role changed.

  • Darqie.onSceneChange(handler: ({ activeSceneId }) => void) => () => void

    Fires when the DM switches the active scene.

  • Darqie.onSceneMediaChange(handler: ({ eventType, row }) => void) => () => void

    Fires when scene media is added, updated or removed.

  • Darqie.onSessionChange(handler: (row: SessionRow) => void) => () => void

    Fires when a session is started or saved.

  • Darqie.onLogPrefChange(handler: ({ enabled }: { enabled: boolean }) => void) => () => void

    Fires whenever the DM flips the per-extension toggle on the Logs page. Use it to keep your in-iframe "append to logs" UI in sync with the host.

DM-only

DM signals

Lets a DM-facing extension toggle a host-side UI feature from inside its iframe (e.g. show a floating panel, mirror a per-extension preference). The host listens for a fixed set of signal names; unknown names are ignored.

  • Darqie.dm.signal(name: string, data?: object) => Promise<{ ok: true }>DM only

    DM-only. Fires a host-handled named signal so an extension can toggle a host UI feature from inside its iframe.

Recognised signal names

  • log.preference{ enabled: boolean }

    Mirrors the extension's "Append to logs" toggle onto the DM's Logs page (per-extension entry under Extensions).

Persistence

Storage

Per-game, per-extension key/value store backed by the database. Values survive page reloads and can be shared between different sessions of the same extension within the same game.

  • Darqie.storage.get(key: string) => Promise<{ value: any }>

    Reads a stored value. Returns { value: null } if key doesn't exist.

  • Darqie.storage.set(key: string, value: any) => Promise<{ ok: true }>

    Writes a stored value. Any JSON-serialisable type.

  • Darqie.storage.delete(key: string) => Promise<{ ok: true }>

    Deletes a stored key.

  • Darqie.storage.list() => Promise<{ keys: string[] }>

    Lists all keys stored by this extension in this game.

Ephemeral

Broadcast

Lightweight pub/sub between every instance of your extension running in the same game (e.g. DM panel ↔ player panel). Messages are ephemeral — they are not persisted and are only delivered to currently connected iframes.

  • Darqie.broadcast(channel: string, data: any) => Promise<{ ok: true }>

    Sends an ephemeral message to every other instance of this extension in the same game.

  • Darqie.subscribe(channel: string, handler: (data: any) => void) => () => void

    Listens on a broadcast channel. Returns an unsubscribe function.

Sound effects

Audio

Browsers block media playback until the document has been activated by a user gesture. Your extension lives in a cross-origin iframe — gestures on the main Darqie page do not automatically grant your iframe permission to play sound. Without this bridge, observers (players who never click inside your iframe) see actions play out silently — including dice impacts, fanfares, ambience cues, etc.

The host fixes this in two ways:

  • The iframe element is mounted with allow="autoplay" Permissions-Policy delegation, so Chrome / Firefox honour the parent's user activation.
  • When the user gestures on the main site, the host posts an audio-unlock signal into every extension iframe. The SDK auto-resumes a shared AudioContext, drains any queued Darqie.audio.play() calls, and fires Darqie.audio.onUnlock() so your code can lazy-load samples / re-attempt playback.
  • Darqie.audio.isUnlocked() => boolean

    True once the host has signalled that the user gestured anywhere on the main site. Until then, the iframe's AudioContext is suspended and any HTMLMediaElement.play() unmuted call will be rejected by the browser.

  • Darqie.audio.onUnlock(handler: () => void) => () => void

    Subscribe to the unlock event. Fires once when the host first detects a user gesture. If audio is ALREADY unlocked by subscription time, the handler fires synchronously. Returns an unsubscribe function.

  • Darqie.audio.context() => AudioContext | null

    Lazily creates a shared AudioContext for this iframe. Auto-resumed when audio unlocks. Returns null on init failure (browsers without Web Audio API). Use this instead of `new AudioContext()` so all your sounds share one decoded-buffer cache and the unlock plumbing is centralised.

  • Darqie.audio.play(el: HTMLAudioElement) => Promise<void>

    Plays an HTMLAudioElement now if audio is unlocked, otherwise queues it for retry on the next unlock event. Returns a Promise that resolves once playback actually starts. Safer than `el.play()` directly because it doesn't lose audio when the user hasn't gestured yet.

Gotcha: any decoded audio buffers / samples must be loaded at or after the unlock event — Chrome won't let you create or resume an AudioContext before the unlock, and downloading samples on first use causes a silent first impact. The official Darqie Dice extension hits this exact case: observers never click inside the dice iframe, so their buffers stayed empty and rolls were silent. The fix lives in the extension itself — kick off your fetch().then(decodeAudioData) chain from Darqie.audio.onUnlock() (or eagerly on mount, accepting that the buffers sit unused until unlock).

// Sound-effect extension (e.g. dice impact, fanfare on success).
//
// Without this plumbing, observers (players who never click INSIDE your
// iframe) will see actions play out silently — the browser blocks
// unmuted playback until the iframe document has been activated by a
// user gesture. The host bridges that gap: when the user clicks
// ANYWHERE on the main site, your iframe receives an "audio-unlock"
// signal and `Darqie.audio.context()` flips to "running".
//
// Two patterns:

// ── 1. HTMLAudioElement — easiest for one-shot sounds ──────────────────
const fanfare = new Audio("/sounds/fanfare.mp3");
fanfare.preload = "auto";

// Use Darqie.audio.play() instead of fanfare.play() so the sound queues
// gracefully if audio isn't unlocked yet — replays automatically on the
// next unlock event.
button.onclick = () => Darqie.audio.play(fanfare);

// ── 2. Web Audio API — for buffer-based / mixed / processed audio ──────
const ctx = Darqie.audio.context();
if (ctx) {
  const buffer = await fetch("/sounds/hit.wav")
    .then(r => r.arrayBuffer())
    .then(b => ctx.decodeAudioData(b));

  // Pre-load samples on unlock so the first impact isn't silent while
  // the file is still downloading. This is exactly what the official
  // Darqie Dice extension does to play impact sounds for observers.
  Darqie.audio.onUnlock(() => {
    // ctx is now in "running" state — safe to schedule playback.
    // (Optionally pre-decode more buffers here.)
  });

  function playHit() {
    if (!Darqie.audio.isUnlocked()) return; // queue your own retry, or no-op
    const src = ctx.createBufferSource();
    src.buffer = buffer;
    src.connect(ctx.destination);
    src.start();
  }
}

Inter-extension

Inter-extension

Where broadcast is fire-and-forget pubsub to every instance of this extension, the ext.* namespace lets two different extensions discover each other and exchange typed request/response calls. Routing goes through the host page; iframes never talk directly.

  • Darqie.ext.list() => Promise<Array<{ extKey: string; name: string }>>

    Lists currently mounted extensions in this game, excluding the caller.

  • Darqie.ext.request(extKey: string, method: string, params?: any) => Promise<any>

    Sends a typed request to another extension and resolves with its handler's return value. Rejects with "ext_not_found", "no_handler", "ext_request_timeout" (30s), or the handler's error message.

  • Darqie.ext.onRequest(handler: (method: string, params: any, fromExtKey: string) => any | Promise<any>) => () => void

    Registers a handler invoked when another extension calls Darqie.ext.request(thisExtKey, ...). Only one handler at a time — calling onRequest replaces any previous one. Returns an unsubscribe function.

Requests time out after 30 seconds and reject with "ext_request_timeout". If the target extension hasn't registered a handler, the request rejects with "no_handler". If no extension with the given extKey is currently mounted, rejects with "ext_not_found".

// ─── Extension A ("custom:hp-tracker") ────────────────────────────────────
// Exposes a "getHp" method that other extensions can call.
Darqie.ready().then(() => {
  Darqie.ext.onRequest(async (method, params, fromExtKey) => {
    if (method === "getHp") {
      const { value } = await Darqie.storage.get("hp:" + params.creatureId);
      return { hp: value ?? 0 };
    }
    throw new Error("unknown_method");
  });
});

// ─── Extension B ("custom:initiative") ────────────────────────────────────
// Discovers the HP tracker and asks it for a creature's HP.
const peers = await Darqie.ext.list();
const hpExt = peers.find(p => p.name.includes("HP Tracker"));
if (hpExt) {
  try {
    const { hp } = await Darqie.ext.request(hpExt.extKey, "getHp", { creatureId: "goblin-1" });
    console.log("Goblin HP:", hp);
  } catch (err) {
    // err.message is one of: "ext_not_found", "no_handler",
    // "ext_request_timeout", or whatever the target threw.
    console.warn("HP lookup failed:", err.message);
  }
}

Integration

Twitch channel-point rewards

Create custom rewards on the game streamer's Twitch channel and react when viewers redeem them. The host handles OAuth, reward CRUD against Helix, and dispatches redemption events back to the iframe — your extension never sees a token. Rewards are tagged with your extKey so two extensions can't touch each other's rewards.

  • Darqie.twitch.getStatus() => Promise<{ connected: boolean; enabled: boolean; channelLogin: string | null }>

    Reports whether the caller has Twitch linked and the link is toggled on, plus the Twitch login string.

  • Darqie.twitch.createReward(p: { title: string; cost: number; prompt?: string; userInput?: boolean; backgroundColor?: string }) => Promise<{ rewardId: string; title: string; cost: number }>

    Creates a channel-point reward on the game's streamer's Twitch channel and stamps it as owned by THIS extension. Caller must be the streamer. `userInput` defaults to true — recommended since input-required redemptions hit instantly via IRC; no-input ones depend on Helix polling (~8s latency).

  • Darqie.twitch.updateReward(rewardId: string, patch: { title?: string; cost?: number; prompt?: string; userInput?: boolean; enabled?: boolean; backgroundColor?: string }) => Promise<{ rewardId: string; title: string; cost: number; prompt: string; userInput: boolean; enabled: boolean }>

    Patches a reward this extension owns. Each field is optional — omit to leave unchanged. Uses Twitch's PATCH so the rewardId and viewer redemption history are preserved (better than delete + recreate). Rejects with "not_owned" on someone else's reward.

  • Darqie.twitch.deleteReward(rewardId: string) => Promise<{ ok: true }>

    Removes a reward this extension owns. Other extensions' rewards are off-limits — the host rejects with "not_owned".

  • Darqie.twitch.listRewards() => Promise<{ items: Array<{ rewardId: string; title: string; cost: number; prompt: string; userInput: boolean }> }>

    Lists rewards this extension previously created in this game.

  • Darqie.twitch.onRedeem(handler: (e: { rewardId: string; rewardTitle: string | null; viewerName: string; viewerInput: string; redeemedAt: string }) => void) => () => void

    Fires when a viewer redeems one of THIS extension's rewards. The event arrives inside the streamer's browser; other clients receive any chat / log side-effects via realtime, not this callback. Returns an unsubscribe function.

Only the game's streamer can call createReward, deleteReward, and listRewards (rewards live on their channel).onRedeem works from any iframe — but the event itself fires inside the streamer's browser, so handlers in other clients see side effects (chat, log entries) only through the usual realtime channels. For instant detection set userInput: true — no-input rewards are picked up via Helix polling (~8 second latency).

UI hook

Settings page

Every extension panel has a Settings button in the host UI. When clicked, the host sends a { v: 1, type: "settings", open: true } postMessage to your iframe. Closing settings sends open: false.

Use Darqie.onSettings(handler) to register a callback, or listen for the raw postMessage event. Add "hasSettings": true to your manifest so the host knows your extension handles the callback.

  • Darqie.onSettings(handler: (open: boolean) => void) => () => void

    Registers a callback fired whenever the host toggles the settings view. Returns an unsubscribe function.

// In your darqie-ext.json:
// "hasSettings": true

// In your extension's JavaScript:
let settingsVisible = false;

Darqie.onSettings((open) => {
  settingsVisible = open;
  document.getElementById("main").style.display    = open ? "none"  : "block";
  document.getElementById("settings").style.display = open ? "block" : "none";
});

// Or listen for the raw postMessage (no SDK required):
window.addEventListener("message", (ev) => {
  if (ev.data?.v === 1 && ev.data?.type === "settings") {
    showSettingsPage(ev.data.open);
  }
});

Recipe

Example: Token Spawner

Clicking a button places a token on the active scene and notifies the table. A scene-change listener keeps the extension in sync when the DM switches scenes.

const ctx = await Darqie.ready();

document.getElementById("add-goblin").onclick = async () => {
  await Darqie.placeImage({
    url: "https://example.com/goblin.png",
    x: 200, y: 200, w: 70, h: 70,
  });
  Darqie.notify("Goblin spawned!", "info");
};

// React to scene changes
Darqie.onSceneChange(({ activeSceneId }) => {
  console.log("Scene switched to", activeSceneId);
});

OBS

OBS Overlay widgets

Extensions can ship widgets that appear in the DM's OBS scene editor and render — sandboxed, transparent — on the public browser-source overlay. Declare them under obsWidgets in your manifest; each entry produces an iframe whose URL is templated with the runtime context.

Manifest field

URL placeholders {gameId}, {userId}, {sceneId}, {token} and {widgetId} are substituted at render time. Scope is one of global, per_player, freeform.

{
  "name": "Last Roll Widget Pack",
  "version": "1.0.0",
  "category": "Overlay",
  // … the usual manifest fields …

  "obsWidgets": [
    {
      "id":          "last_roll",
      "name":        "Last Roll",
      "description": "Shows the most recent dice result for a player.",
      "url":         "https://my-ext.example/obs/last-roll.html?game={gameId}&widget={widgetId}",
      "scope":       "per_player",
      "defaultSize": { "w": 320, "h": 120 },
      "minSize":     { "w": 160, "h": 80 },
      "transparent": true,
      "propsHint": {
        "userId": { "type": "user",   "label": "Player" },
        "color":  { "type": "color",  "label": "Tint", "default": "#FF6B7A" }
      }
    }
  ]
}

Inspector UI (extension-rendered)

When the DM selects your widget on the OBS scene canvas, the editor renders a fixed slot in the inspector panel for an iframe you ship. Declare the URL via obsWidgets[i].inspectorUrl in your manifest. The host substitutes {gameId}, {widgetId}, {token} and {userId} placeholders. Your iframe receives `Darqie.ready()` + SDK access, renders whatever settings UI it wants, and reads/writes the widget's per-instance props via two methods:

  • Darqie.obs.getProps() => Promise<Record<string, unknown>>

    Returns the current per-instance props of the widget this inspector iframe is attached to. Use it to hydrate your form controls on mount. The host doesn't validate the shape — you own the schema.

  • Darqie.obs.setProps(patch: Record<string, unknown>) => Promise<{ ok: true }>

    Merges a partial patch into the widget's `config.props`. The host persists to layout (autosave + realtime broadcast) and the widget's main iframe re-renders with the new ctx.props on next `Darqie.obs.ready()`. Use this from your inspector iframe on every UI commit.

  • Darqie.obs.setInspectorSize({ width?: number | "auto"; height?: number | "auto" }) => void

    Tells the host how big this inspector slot iframe wants to be. Pass either or both fields in CSS pixels; pass `"auto"` to revert to host defaults (full inspector-panel width / 280px height). Width is clamped to the panel's available width — the host doesn't reflow the panel itself. Cheap (one postMessage); safe to call on every render whenever your UI grows or shrinks.

Auto-fit by default. The host posts an obs/inspector-init message to your iframe on load — the SDK then installs a ResizeObserver on document.documentElement + body and re-emits setInspectorSize on every content resize (debounced to one frame). You typically don't need to call setInspectorSize manually — just style your iframe's body to its natural content height and the slot follows. Manifest inspectorSize only sets the cold-start dimensions before the iframe's first measurement lands.

Manifest example: "inspectorUrl": "https://my-ext.example/?mode=inspector&widgetId={widgetId}" — extension owns its UI entirely. Different extensions ship different settings panels; the host is schema-agnostic.

Tip: when the inspector commits a prop change via setProps, the main widget iframe URL placeholders (e.g. {userId}) are re-substituted from the new props on re-mount. So pure URL-driven widgets can pick up changes automatically without touching the SDK.

SDK methods

  • Darqie.obs.ready(opts?: { widgetId?: string; capabilities?: string[] }) => Promise<ObsCtx>

    Registers the iframe as an OBS-overlay widget instance and resolves with its render context. Rejects with "not-in-obs-context" when called from a regular sidebar iframe — gate OBS-only logic behind a successful resolve.

  • Darqie.obs.broadcast(widgetId: string, payload: object) => void

    Pushes a widget_message onto the active OBS scene's realtime channel. Every renderer of that widget instance (in any browser, including the public overlay page) receives it.

  • Darqie.obs.subscribe(widgetId: string, handler: (payload: object) => void) => () => void

    Subscribes to widget_messages targeted at the given widget instance. The SDK filters on widgetId — the handler never sees other widgets' traffic. Returns an unsubscribe function.

  • Darqie.obs.listWidgets(filter?: { defId?: string }) => Promise<Array<{ widgetId: string; defId: string; sceneId: string | null }>>

    Enumerate OBS widget instances on the game's active OBS scene that match a manifest entry id from this extension's `obsWidgets[]`. Pair with `obs.broadcast(widgetId, payload)` to fan per-instance payloads to every placed widget. Typical use: the dice extension's sidebar discovers every `last_roll` badge the DM placed and forwards the latest roll to each. Pass `{ defId: "last_roll" }` to filter; omit `filter` to return every extension_widget instance regardless of definition.

The ctx resolved by Darqie.obs.ready() contains { gameId, sceneId, widgetId, viewMode, props, viewer }. viewer is "dm" / "player" when previewed inside the editor and "obs" on the public overlay page.

Example: last-roll widget

Renders the most recent dice total for the player passed in via props.userId.

<!doctype html>
<html>
  <body style="margin:0;background:transparent;color:#fff;font-family:system-ui">
    <div id="roll" style="font-size:48px;font-weight:800">—</div>
    <script src="https://darqie-dungeon.com/sdk/v1/darqie-sdk.js"></script>
    <script>
      Darqie.obs.ready().then(ctx => {
        // ctx = { gameId, sceneId, widgetId, viewMode, props, viewer }
        const tinted = ctx.props.color || "#FF6B7A";
        document.body.style.color = tinted;

        // React to fresh dice rolls broadcast by the dice extension
        Darqie.obs.subscribe(ctx.widgetId, (payload) => {
          if (typeof payload.total === "number") {
            document.getElementById("roll").textContent = payload.total;
          }
        });
      }).catch(err => {
        // err.message === "not-in-obs-context" when loaded outside the OBS
        // overlay page — fall back to your sidebar UI here.
      });
    </script>
  </body>
</html>

OBS

OBS Studio mirror

When the streamer opens an OBS Studio Browser Source pointed at /obs/<token>, the host mounts a full mirror of the game scene — same map, strokes, placed media — plus any scene-rendered iframe your extension placed via Darqie.canvas.addFrame or addOverlay. The Browser Source is a separate browser process — different origin, no extension sidebar iframe is mounted there, no BroadcastChannel reaches across processes — so the iframe wakes up alone.

The host fills the gap for you:

  • Every canvas.addFrame / canvas.addOverlay call is persisted to the database. On cold-start, the Browser Source hydrates the scene from those rows so your iframe is placed in the right spot before the streamer touches anything.
  • A token-validated SDK bridge runs at the overlay root. Your iframe can call Darqie.ready(), Darqie.getContext(), Darqie.getActiveScene(), Darqie.getSession(), Darqie.getSceneMedia(), Darqie.getDiceHistory() and Darqie.subscribe(channel, handler) just like in the streamer's tab.
  • Every Darqie.broadcast(channel, data) call the streamer's sidebar fires is mirrored into the Browser Source iframe with sub-100 ms latency. That's how the dice extension's tray learns about new rolls without a sibling.

Restrictions in OBS context

The overlay is an anonymous, read-only view. Any mutating SDK call rejects with { error: "forbidden_anon_overlay" }:

  • Darqie.sendChat, sendRoll, notify, logEvent, placeImage and all DM-only methods.
  • All canvas.add* / canvas.update* / canvas.remove* writes.
  • storage.*, ext.*, dm.signal — none of these have an authenticated user behind them.

The context object reflects this: me is null, isDm is false, members is []. Gate any UI that depends on identity behind those checks.

Recommended pattern

Two iframes from the same extension origin — a sidebar (mode=panel) that lives in the streamer's tab, and a scene-rendered worker (mode=tray, placed via canvas.addFrame). The worker detects whether a sibling is reachable via a 500 ms BroadcastChannel probe; if no ack arrives, it falls back to the SDK.

<!-- mode=tray iframe — placed by your sidebar via Darqie.canvas.addFrame -->
<!doctype html>
<html>
  <body style="margin:0;background:transparent">
    <div id="tray"></div>
    <script src="https://darqie-dungeon.com/sdk/v1/darqie-sdk.js"></script>
    <script>
      // 1. Sibling-probe: in the streamer's tab, the sidebar iframe is
      //    mounted right beside us (same origin) so a BroadcastChannel
      //    works for zero-latency intra-extension messaging. In OBS
      //    Studio Browser Source there's no sibling — fall back to SDK.
      const bc = new BroadcastChannel("my-ext");
      let siblingPresent = null;          // null = unknown, true/false = decided
      bc.postMessage({ type: "sibling-probe" });
      bc.onmessage = (ev) => {
        if (ev.data?.type === "sibling-ack" && siblingPresent === null) {
          siblingPresent = true;
          // Continue using BroadcastChannel — sidebar drives state.
        }
      };
      setTimeout(() => {
        if (siblingPresent !== null) return;
        siblingPresent = false;
        startStandaloneMode();
      }, 500);

      // 2. Standalone (OBS Studio) — SDK is the only transport.
      async function startStandaloneMode() {
        const ctx = await Darqie.ready();   // bridge answers in OBS Studio
        // Cold-start: paint historical rolls so a freshly-loaded
        // Browser Source isn't blank until the next live roll.
        if (typeof Darqie.getDiceHistory === "function") {
          const rolls = await Darqie.getDiceHistory(50);
          for (const r of rolls) renderStatic(r.content);
        }
        // Live updates: the streamer's sidebar broadcasts every new
        // roll on this channel. The host SDK bridge forwards the
        // message into our iframe with <100 ms latency.
        Darqie.subscribe("my-ext/roll", (data) => animate(data));
        // Optional: request current settings so the look matches the
        // streamer's. Sidebar should listen for this and reply.
        Darqie.broadcast("my-ext/settings/request", {});
        Darqie.subscribe("my-ext/settings", (settings) => applyStyle(settings));
      }
    </script>
  </body>
</html>

<!-- mode=panel iframe (sidebar) — runs in the streamer's tab -->
<script>
  // Respond to sibling-probes immediately so the tray knows we exist.
  const bc = new BroadcastChannel("my-ext");
  bc.onmessage = (ev) => {
    if (ev.data?.type === "sibling-probe") bc.postMessage({ type: "sibling-ack" });
    if (ev.data?.type === "settings-changed") publishSettings();
  };
  // Also publish settings + rolls on the SDK channel so OBS Studio
  // tray iframes (no sibling reachable) stay in sync.
  function publishSettings() {
    Darqie.broadcast("my-ext/settings", currentSettings);
  }
  Darqie.subscribe("my-ext/settings/request", publishSettings);
  function onRoll(roll) {
    bc.postMessage({ type: "roll", roll });               // sibling tray (streamer-tab)
    Darqie.broadcast("my-ext/roll", roll);                // OBS Studio trays
  }
</script>

When this matters vs OBS Overlay widgets

The previous section (OBS Overlay widgets) is for widgets the DM places explicitly on the OBS scene layout via the editor — they live in a per-widget iframe, scaled to the box the DM dragged, and use Darqie.obs.ready() + obs.subscribe for their realtime channel. This mirror section is about something different: scene-coordinate iframes (your extension's tray / overlay placed on the game map) that should automatically appear inside the Browser Source as part of the mirrored scene, without the DM having to add them as widgets. Both surfaces can coexist on the same overlay.

Security

Permissions & sandbox

Extension iframes run under sandbox="allow-scripts allow-same-origin allow-forms allow-popups". The permissions field in the manifest is informational — it is displayed to users but does not restrict which SDK methods can be called at runtime. DM-only methods called by a non-DM user return { error: "forbidden_dm_only" }.

Pattern

Sidebar UI + scene-rendered worker iframe

For extensions that need a UI in the sidebar and something drawn on the active scene (a physics tray, an animated overlay, a mini-game), the recommended approach is to ship two views from the same extension URL and have them talk over a same-origin BroadcastChannel.

The sidebar iframe is what the host's CustomExtensionFrame mounts. On mount, it calls Darqie.canvas.addFrame with interactive: false and a ?mode=tray (or any query flag) pointing at the same extension URL. The host places that iframe inside the pan/zoom layer; pointer-events stay disabled so the user's draw / move tools keep working through it.

Both iframes load from the same origin (your extension's URL), so a plain new BroadcastChannel("my-ext") is enough — the sidebar sends commands ("roll these dice", "clear"), the worker streams back state ("dice settled at 14"). Subscribe to Darqie.onSceneChange to reposition the frame when the DM switches scenes, and call canvas.removeFrame on unmount so it doesn't leak. The Darqie Dice extension uses exactly this split: a 2D sidebar (picker + result pill) and an invisible physics tray on the scene.

Building something cool? Join the community on Discord or reach out via the contact page.