← Getting Started

SDK Reference

Complete reference for the Polyhedral SDK — components, APIs, and theme. Looking for working code? Browse the examples.

SDK API

Sheets run inside a sandboxed iframe with React 19 and ReactDOM available as globals. The Polyhedral global provides access to data, state, dice rolling, theme variables, and the component library.

Polyhedral.sheet      // getData(), setData(), onDataChange()
Polyhedral.state      // get(), set(patch), onChange() — ephemeral UI state
Polyhedral.dice       // roll(), roll3d(), rollToChat()
Polyhedral.storage    // upload() — image uploads to Supabase Storage
Polyhedral.events     // on(), off(), once() — live game event subscriptions
Polyhedral.components // the built-in React components
Polyhedral.theme      // colors, mode, radius + onChange()
Polyhedral.tokens     // all design tokens: colors, typography, spacing, radius
Polyhedral.viewport   // insets of the iOS app's bars + onChange()
Polyhedral.meta       // instanceId, gameId, characterName, userId, isEditor
Polyhedral.useFieldData // React hook for field data with live updates

Component Library

Pre-built components that match the Polyhedral design system. Destructure them from Polyhedral.components.

Layout

<Sheet>

Root wrapper with padding and theme.

{ children }
<StickyHeader>

A bar that sticks to the top while the sheet scrolls. In the iOS app it fills the area under the native navigation bar. Make it the first child of Sheet.

{ children }
<Section>

Card-like grouping with optional heading.

{ title?, children }
<Row>

Horizontal flex row.

{ gap?, children }
<Column>

Vertical flex column.

{ gap?, children }
<Grid>

CSS grid layout.

{ columns, gap?, children }

Data Display

<StatBlock>

Label + value pair in a bordered box.

{ label, value }
<AbilityScore>

Ability score with derived modifier.

{ name, score }
<HealthBar>

Colored HP bar.

{ current, max, label? }
<ResourceTrack>

Pip-based tracker (spell slots, etc).

{ label, current, max, onToggle? }
<Badge>

Status pill. Variants: default, success, warning, danger.

{ children, variant? }
<ImageUpload>

Image uploader bound to field data URL.

{ field, size? }

Interactive

<EditableText>

Text input bound to field data.

{ field, placeholder?, label? }
<EditableNumber>

Number input bound to field data.

{ field, min?, max?, label? }
<Checkbox>

Boolean toggle bound to field data.

{ field, label }
<Select>

Dropdown select bound to field data.

{ field, label?, options }
<DiceButton>

Rolls dice on click. Default mode 'chat' animates 3D dice and posts to chat. '3d' animates without chat. 'silent' is instant math only.

{ notation, label?, mode?: 'silent'|'3d'|'chat', children? }

Typography

<Heading>

h1, h2, or h3.

{ level?, children }
<Text>

Body text. Sizes: small, default, large.

{ size?, muted?, children }
<Label>

Small uppercase label.

{ children }

Dice API

Three tiers of dice rolling, from silent math to full 3D animation with chat integration. Each call rolls one notation — there is no separator meaning “and roll that again”, so a crammed 3d6,3d6 is rejected rather than quietly rolling the first term. For several rolls, call once per roll.

Polyhedral.dice.roll(notation)

Silent math — instant result, no 3D animation, no chat message. Returns {total, rolls}.

Polyhedral.dice.roll3d(notation, label?)

3D animated roll — shows dice animation on screen but does not post to chat. Returns {total, rolls}.

Polyhedral.dice.rollToChat(notation, label?)

3D animated roll + posts the result to chat. The label appears as a heading above the roll result. Returns {total, rolls}.

// Silent math — instant, no visuals
const result = await Polyhedral.dice.roll('2d6+3');

// 3D animated — dice fly across screen
const result = await Polyhedral.dice.roll3d('1d20', 'Attack Roll');

// 3D animated + posts to chat
const result = await Polyhedral.dice.rollToChat('1d20', 'Attack Roll');

// All three return { total: number, rolls: number[] }
// total: the result, modifiers and keep/drop applied
// rolls: every die's face in order, dropped dice included
//        ('4d6kh3' → four faces; the +3 in '2d6+3' is not a die)

// Several rolls at once — one call each, awaited together
const scores = await Promise.all(
  ['Str', 'Dex', 'Con', 'Int', 'Wis', 'Cha'].map((name) =>
    Polyhedral.dice.roll3d('4d6kh3', name)
  )
);

A notation is at most 100 characters. A roll rejects when its notation does not parse, where 3D dice are not available (roll3d and rollToChat), and after a timeout: 10 seconds for roll, 30 for the 3D rolls. Catch it and tell the player.

Storage API

Upload images (portraits, icons, etc.) from within the sandbox. Files are stored in Supabase Storage and a public URL is returned.

Polyhedral.storage.upload(file)

Accepts a File instance (max 5 MB, images only). Returns a Promise<string> resolving to the public URL.

// Let the user pick a portrait image
const input = document.createElement('input');
input.type = 'file';
input.accept = 'image/*';
input.onchange = async () => {
  const file = input.files[0];
  const url = await Polyhedral.storage.upload(file);
  Polyhedral.sheet.setData({ portrait: url });
};
input.click();

Events API

Subscribe to live game events from within your sheet. When someone rolls dice, activates a map, or changes an encounter, your sheet is notified in real time — no polling, no server required.

events.on(eventType, callback)    // subscribe, returns unsubscribe fn
events.off(eventType, callback)   // remove a callback
events.once(eventType, callback)  // subscribe for one event, then auto-remove

Callbacks receive a GameEvent with eventType, userId, payload, and timestamp.

// Subscribe to dice rolls
const unsub = Polyhedral.events.on('dice.rolled', (event) => {
  console.log(event.userId, 'rolled', event.payload);
});

// Listen for a single encounter change
Polyhedral.events.once('encounter.created', (event) => {
  // Update UI for new encounter
});

// Clean up when done
unsub();

Available Events

dice.rolled              // notation, total, individual rolls
encounter.created        // encounter data
encounter.updated        // updated encounter data
encounter.deleted        // deleted encounter id
map.activated            // map shown to players
map.deactivated          // map hidden from players
table.rolled             // rollable table result

GameEvent Shape

interface GameEvent {
  eventType: string;   // e.g. 'dice.rolled'
  userId: string;      // who triggered the event
  payload: object;     // event-specific data
  timestamp: string;   // ISO 8601
}

useFieldData Hook

A React hook that reads a single field from field_data and re-renders the component when it changes. Cleaner than calling getData() + onDataChange() manually.

const hp = Polyhedral.useFieldData('hp', 0);
const name = Polyhedral.useFieldData('name', 'Unnamed');

// Equivalent to:
// const [hp, setHp] = useState(Polyhedral.sheet.getData().hp ?? 0);
// useEffect(() => Polyhedral.sheet.onDataChange(d => setHp(d.hp ?? 0)), []);

Theme

The platform's design tokens are CSS custom properties on :root. Inside a game they carry the game's theme, and they follow light/dark mode and theme edits live, without reloading the sheet. Colors are bare HSL triplets with no alpha: write hsl(var(--primary)) or hsl(var(--primary) / 0.2). A :root rule in your own CSS still overrides any token. See Design Tokens on the Getting Started page for every value.

Colors   --background --foreground --raised --card --card-foreground
         --primary --primary-foreground --secondary --secondary-foreground
         --muted --muted-foreground --accent --accent-foreground --hover
         --destructive --destructive-foreground --warning --warning-foreground
         --success --success-foreground --border --input --ring --surface
Type     --font-ui --font-reading --font-mono --root-font-size
         --ts-{display,title-lg,title,title-sm,body,body-sm,eyebrow,xs} (+ -lh, -ls)
Spacing  --spacing --space-{1,2,3,4,5,6,8,10,12,16} --spacing-touch
Radius   --radius --radius-{outer,inner,nested,interactive,lg,md,sm,xs}

--primary is the theme accent; --secondary, --accent = --muted; --card = --raised; --input = --border

In JavaScript, Polyhedral.theme holds the colors (camelCase), mode and radius; Polyhedral.tokens holds everything, grouped. Both are replaced on each update, and theme.onChange() fires every time:

const bg = Polyhedral.theme.background;      // "32 14% 18%"
const dark = Polyhedral.theme.mode === 'dark';
const gap = Polyhedral.tokens.spacing.space4;  // "1rem"

const unsub = Polyhedral.theme.onChange((theme, tokens) => {
  ctx.fillStyle = `hsl(${theme.primary})`;
});
unsub();

Phones and the iOS App

Sheets open on phones, in the browser and in the iOS app. In the app a sheet is a full screen: it fills the display, the native navigation bar and floating tab bar sit on top of it, and its content scrolls underneath them. A sheet that follows this list works in all of them.

1. Let the document scroll

The sheet's own page is the scroll container. Never put overflow (auto, scroll or hidden) or a fixed height: 100vh on a root wrapper: it becomes a second scroller, and position: sticky inside it stops working. Sheet is safe.

2. Lay out against the insets

--polyhedral-inset-top, -right, -bottom and -left say how far the app's bars reach in from each edge, in px. On the web they are 0px. By default the body is padded by them, so content starts below the bar and ends above the tab bar, and anchor jumps stop short of both. Your own body padding replaces that default.

/* Opt out, all sides or one */
body { padding: 0; }
body { padding-top: 0; }

/* Keep the insets and add your own gutter */
body { padding: calc(var(--polyhedral-inset-top) + 16px) 16px
               calc(var(--polyhedral-inset-bottom) + 16px); }

/* A sticky header that fills the area under the native bar
   (or use <StickyHeader>, which does this for you) */
body { padding-top: 0; }
.tabs { position: sticky; top: 0; padding-top: var(--polyhedral-inset-top);
        background: hsl(var(--background)); }
.section { scroll-margin-top: 48px; /* the header's height */ }

/* Fixed elements */
.roll-button { position: fixed; right: 16px;
               bottom: calc(var(--polyhedral-inset-bottom) + 16px); }

env(safe-area-inset-*) is always 0 inside a sheet: it runs in an iframe, and the web has no safe areas. Use the variables, which carry the app's bars and the device's safe areas. In CSS you also ship elsewhere, take whichever is larger: max(var(--polyhedral-inset-top, 0px), env(safe-area-inset-top, 0px)). For layout in JavaScript, Polyhedral.viewport.insets holds the same values as numbers, and Polyhedral.viewport.onChange(cb) fires when they change.

3. Size type and targets for touch

On a phone — a touch screen narrower than 768px — the root font size is 18px, so rem values grow with it, and the --ts-* sizes use the larger mobile scale. Everywhere else takes the desktop scale: a mouse at any width, a narrow side panel included, and a tablet. The editor's phone preview shows the phone sizes. Give inputs a font size of 16px or more: iOS Safari zooms into smaller inputs on focus. Make tap targets at least 44px, and never under 40px. The SDK's components do both on touch screens and keep their desktop sizes with a mouse. For your own controls, two variables are set on touch screens only: var(--polyhedral-control-font-size, 14px) for field text (the body size, never under 16px) and min-height: var(--polyhedral-touch-target, auto) for 44px targets. The fallback is what a mouse gets.

4. Take every style from the tokens

Colors, type, spacing and radius come from the design tokens, so the sheet follows light/dark mode, the game's theme and the iOS app. No hard-coded colors.

5. Expect the network

In the iOS app an in-app (JSX) sheet needs a connection to start: React loads from polyhedral.co and Babel from cdn.jsdelivr.net. External (pre-built) sheets start offline, and Google Fonts fall back to local faces.

Field Schema

A template's field_schema declares what data fields the sheet expects. It's optional — interactive components work without it — but defining one enables default values when creating instances and better tooling support.

Each field has a key (matching the field prop on components), a type, and optional label and default.

Declare every top-level data key the sheet reads or writes, spelled exactly as the code uses it. The schema is what MCP tools and the AI see: they read field_data with the schema and write values by schema key, and defaults are applied by key. A key the sheet doesn't read is a value it never shows.

field_schema: [
  { key: "name",      type: "text",    label: "Character Name", default: "" },
  { key: "hp",        type: "number",  label: "Hit Points",     default: 10 },
  { key: "level",     type: "number",  label: "Level",          default: 1 },
  { key: "inspired",  type: "boolean", label: "Inspired",       default: false },
  { key: "portrait",  type: "image",   label: "Portrait" }
]

Supported Types

How It Works

External Packages

Import npm packages via esm.sh. The sandbox CSP allows scripts from esm.sh and cdn.jsdelivr.net.

import confetti from 'https://esm.sh/canvas-confetti@1';

// Fire confetti on a nat 20 (3D animated + posts to chat)
const result = await Polyhedral.dice.rollToChat('1d20', 'Attack Roll');
if (result.total === 20) {
  confetti({ particleCount: 100, spread: 70 });
}

What a sheet can and can't do

A sheet runs in a sandboxed iframe with an opaque origin: scripts only, cut off from the page around it. Everything it reaches, it reaches through the SDK.

It can

It can't

So, when you build one

CLI Commands

The Polyhedral CLI provides all the tools you need to build, validate, and deploy custom sheets.

polyhedral docs

Print the full SDK reference to stdout — types, components, examples, and theme variables.

polyhedral list

List your saved templates.

polyhedral pull [id]

Download a template to a local file.

polyhedral push

Validate, upload, and publish a new version.

polyhedral validate [file]

Validate JSX syntax locally. Reports line and column on error.

polyhedral dev

Watch file, auto-push on save, and open live preview.

polyhedral init [dir]

Scaffold a new template project.

External Builds

External builds let you use any framework (Svelte, Vue, plain HTML) instead of the in-app JSX editor. You provide a complete HTML document and the SDK is auto-injected into the <head> at render time.

The Polyhedral global is available just like in JSX builds, but since you're writing vanilla JS (or compiling to it), you'll use the imperative API instead of React components:

// Read a field value
const name = Polyhedral.sheet.getData().name;

// Write a field value (merges into field_data)
Polyhedral.sheet.setData({ hp: 25 });

// Listen for field changes
Polyhedral.sheet.onDataChange((data) => {
  document.getElementById('hp').textContent = data.hp;
});

// Dice rolling works the same way
const result = await Polyhedral.dice.rollToChat('1d20', 'Attack');

// Theme tokens are available as CSS variables
// color: hsl(var(--foreground));

Use the Polyhedral CLI to set up an external build project. Run polyhedral init and select "external" as the build type, then use polyhedral dev for live preview and polyhedral push to deploy.

← Getting Started · Examples · CLI Guide · Webhooks