Complete reference for the Polyhedral SDK — components, APIs, and theme. Looking for working code? Browse the examples.
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
Pre-built components that match the Polyhedral design system. Destructure them from Polyhedral.components.
<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 }<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? }<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? }<Heading>h1, h2, or h3.
{ level?, children }<Text>Body text. Sizes: small, default, large.
{ size?, muted?, children }<Label>Small uppercase label.
{ children }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.
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();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();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
interface GameEvent {
eventType: string; // e.g. 'dice.rolled'
userId: string; // who triggered the event
payload: object; // event-specific data
timestamp: string; // ISO 8601
}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)), []);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 = --borderIn 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();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.
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.
--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.
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.
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.
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.
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" }
]text — String values (EditableText, Select)number — Numeric values (EditableNumber, AbilityScore)boolean — True/false toggles (Checkbox)image — URL string from storage upload (ImageUpload)field_data directly — they don't need the schema to function.name_field is set on the template, the instance name auto-syncs when that field changes.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 });
}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.
Polyhedral.meta, and the game events it subscribes to.esm.sh and cdn.jsdelivr.net (in-app sheets), stylesheets and fonts from Google Fonts, and images from any https: URL.window.parent, and no access to its DOM, cookies or storage. Its requests carry no session, so it can't call Polyhedral's API as the player.fetch reaches only Polyhedral's own origin, and esm.sh in in-app sheets. External sheets load scripts only inline or from Polyhedral's origin.localStorage, sessionStorage, IndexedDB or cookies. Keep it in field data or state.alert, confirm, prompt), navigate the page, submit a form natively, or embed audio, video or other pages.<style> tags; stylesheets load only from Google Fonts.data: URIs, link images over https:, or upload them with Polyhedral.storage.upload().@font-face { src: url(data:font/woff2;base64,…) }.The Polyhedral CLI provides all the tools you need to build, validate, and deploy custom sheets.
polyhedral docsPrint the full SDK reference to stdout — types, components, examples, and theme variables.
polyhedral listList your saved templates.
polyhedral pull [id]Download a template to a local file.
polyhedral pushValidate, upload, and publish a new version.
polyhedral validate [file]Validate JSX syntax locally. Reports line and column on error.
polyhedral devWatch file, auto-push on save, and open live preview.
polyhedral init [dir]Scaffold a new template project.
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.