Polyhedral SDK

Custom sheets let you design exactly the character sheet your game needs — stat blocks, dice rollers, trackers, notes, and more. They run inside a sandboxed iframe with a built-in React component library, and every field is automatically persisted to the database. Authoring, publishing, and integration access require Builder Access ($5/month). Any Polyhedral AI-powered actions also consume prepaid AI credits.

Build Types

The Polyhedral CLI handles auth, validation, pushing, and live preview. Choose a build type when you scaffold a project:

In-App (JSX)

Write JSX using the built-in component library. The platform compiles and renders it for you. Validation runs offline via Babel before each push.

External (HTML)

Bring your own HTML — plain HTML/JS, or the output of a bundler like Vite. Use any framework: React, Svelte, Vue, or vanilla JS.

Prerequisites

  1. The Polyhedral CLI installed.

Your First Sheet

Let's build a character sheet step by step. Each step adds a new capability so you can see how the pieces fit together.

1. The Boilerplate

Every sheet starts with Sheet (the root wrapper that provides padding and theme) and Section (a card-like grouping). Destructure them from Polyhedral.components and render to the root element.

const { Sheet, Section } = Polyhedral.components;

function CharacterSheet() {
  return (
    <Sheet>
      <Section title="Hello">
        <p>My first sheet!</p>
      </Section>
    </Sheet>
  );
}

ReactDOM.createRoot(document.getElementById('root')).render(<CharacterSheet />);

2. Add Editable Fields

Interactive components like EditableText bind to the sheet's field_data via the field prop. When a player types a name, it's automatically saved to the database — no save button needed.

const { Sheet, Section, Row, EditableText, ImageUpload } = Polyhedral.components;

function CharacterSheet() {
  return (
    <Sheet>
      <Section>
        <Row gap={16}>
          <ImageUpload field="portrait" size="lg" />
          <div style={{ flex: 1 }}>
            <EditableText field="name" label="Character Name"
              placeholder="Enter name..." />
            <EditableText field="race" label="Race" />
            <EditableText field="class" label="Class" />
          </div>
        </Row>
      </Section>
    </Sheet>
  );
}

ReactDOM.createRoot(document.getElementById('root')).render(<CharacterSheet />);

3. Display Data with Computed Values

Use Polyhedral.sheet.getData() to read the current field data. Display components like AbilityScore and StatBlock render values in styled containers — they're read-only display, not editable inputs.

const { Sheet, Section, Row, Grid, EditableText, ImageUpload,
        AbilityScore, StatBlock } = Polyhedral.components;

function CharacterSheet() {
  return (
    <Sheet>
      <Section>
        <Row gap={16}>
          <ImageUpload field="portrait" size="lg" />
          <div style={{ flex: 1 }}>
            <EditableText field="name" label="Character Name"
              placeholder="Enter name..." />
            <EditableText field="race" label="Race" />
            <EditableText field="class" label="Class" />
          </div>
        </Row>
      </Section>

      <Section title="Ability Scores">
        <Grid columns={6} gap={8}>
          <AbilityScore name="STR" score={Polyhedral.sheet.getData().str || 10} />
          <AbilityScore name="DEX" score={Polyhedral.sheet.getData().dex || 10} />
          <AbilityScore name="CON" score={Polyhedral.sheet.getData().con || 10} />
          <AbilityScore name="INT" score={Polyhedral.sheet.getData().int || 10} />
          <AbilityScore name="WIS" score={Polyhedral.sheet.getData().wis || 10} />
          <AbilityScore name="CHA" score={Polyhedral.sheet.getData().cha || 10} />
        </Grid>
      </Section>

      <Section title="Combat Stats">
        <Row gap={12}>
          <StatBlock label="AC" value={10} />
          <StatBlock label="Speed" value="30 ft" />
          <StatBlock label="Prof" value="+2" />
        </Row>
      </Section>
    </Sheet>
  );
}

ReactDOM.createRoot(document.getElementById('root')).render(<CharacterSheet />);

4. Add Dice Rolling

The DiceButton component gives players one-click rolls that animate 3D dice and post results to the game chat. For custom logic (like checking for crits), call the dice API directly.

const { DiceButton } = Polyhedral.components;

// One-click dice button — animates 3D dice and posts to chat
<DiceButton notation="1d20" label="Attack Roll">Roll Attack</DiceButton>

// Or call the API directly for custom logic
const handleRoll = async () => {
  const result = await Polyhedral.dice.rollToChat('1d20+5', 'Attack Roll');
  if (result.total >= 20) {
    // Critical hit!
  }
};

Core Concepts

Templates vs Instances

A template is the sheet design — the JSX source, compiled code, and optional field schema. Templates are owned by you and reusable across games.

An instance is a deployed copy in a specific game. It holds the character's actual data (HP, stats, notes). The template code is copied into the instance at creation time, so instances are independent — updating the template doesn't change existing instances.

Lifecycle: create template → preview and iterate → deploy as instance to a game → players fill in their data.

Field Data Flow

Every instance has a field_data JSON object that stores the character's persisted data. Interactive components (EditableText, Checkbox, etc.) read and write to it automatically via their field prop. You can also read it directly with Polyhedral.sheet.getData() and write with Polyhedral.sheet.setData().

// Read all field data
const data = Polyhedral.sheet.getData();

// Write a single field (merges, doesn't replace)
Polyhedral.sheet.setData({ hp: 25 });

// Listen for changes from other components
Polyhedral.sheet.onDataChange((data) => {
  console.log('Field data changed:', data);
});

// Or use the React hook for cleaner code
const hp = Polyhedral.useFieldData('hp', 0);

State vs Field Data

field_data is persisted to the database — it survives page reloads and is shared across sessions. Polyhedral.state is ephemeral UI state that resets on reload. Use it for things like which tab is active, whether a section is collapsed, or temporary UI flags.

Rule of thumb: if a player would expect the value to be there next time they open the sheet, use field_data. If it's just UI chrome, use state.

// Ephemeral UI state — resets on reload.
// set() takes a patch object and merges it; get() returns the whole state object.
Polyhedral.state.set({ activeTab: 'skills' });
const tab = Polyhedral.state.get().activeTab;

// Subscribe to state changes, the same way sheet.onDataChange works.
// Both return an unsubscribe function.
const unsubscribe = Polyhedral.state.onChange((state) => {
  console.log('UI state changed:', state.activeTab);
});

// Example: tab switching with state
function TabbedSheet() {
  const [tab, setTab] = React.useState(
    Polyhedral.state.get().activeTab || 'stats'
  );

  const switchTab = (name) => {
    setTab(name);
    Polyhedral.state.set({ activeTab: name });
  };

  return (
    <Sheet>
      <Row gap={8}>
        <button onClick={() => switchTab('stats')}>Stats</button>
        <button onClick={() => switchTab('skills')}>Skills</button>
      </Row>
      {tab === 'stats' && <Section title="Stats">...</Section>}
      {tab === 'skills' && <Section title="Skills">...</Section>}
    </Sheet>
  );
}

Game Events

Sheets can subscribe to live game events — dice rolls, encounter changes, map activations, and more. When something happens in the game, every subscribed sheet is notified in real time via Polyhedral.events.

// Highlight the sheet when someone rolls dice
Polyhedral.events.on('dice.rolled', (event) => {
  console.log(event.userId, 'rolled:', event.payload);
});

// React to encounter changes
Polyhedral.events.on('encounter.created', (event) => {
  // Update your initiative tracker, combat dashboard, etc.
});

See the Events API reference for the full list of event types and the event shape.

The Sandbox

Sheets run inside a sandboxed iframe for security: no access to the page around them, no localStorage, and the network only as far as a short allowlist. All data flows through the SDK APIs. External packages can be imported via esm.sh. See what a sheet can and can't do in the reference.

Design Tokens

Sheets get the platform's design tokens as CSS custom properties on :root. Inside a game they carry the game's theme, and they follow light/dark mode and theme edits live — the sheet never reloads for them. Build on them and a sheet looks like part of the table in every game. A :root rule in your own CSS still overrides any of them.

Colors

Colors are bare HSL triplets like 32 14% 18%, never with alpha. Use them as hsl(var(--primary)), or add your own alpha with hsl(var(--primary) / 0.2). The document's color-scheme follows the mode on screen, so form controls and light-dark() match too.

--background / --foregroundThe surface the sheet sits on, and its text
--raisedFloating surfaces: popovers, menus, dialogs
--card / --card-foregroundCards — the raised surface and its text
--primary / --primary-foregroundAccents and actions — in a game, the theme's accent
--secondary / --secondary-foregroundSecondary fills (same as muted)
--muted / --muted-foregroundSubtle fills, de-emphasized text
--accent / --accent-foregroundSubtle highlight (same as muted)
--hoverHover fill for rows and items
--destructive / --destructive-foregroundDanger
--warning / --warning-foregroundWarnings
--success / --success-foregroundSuccess
--border / --input / --ringBorders, inputs, focus rings
--surfaceThe tabletop behind the app's panels

Typography

--font-ui is the platform face (Inter), --font-reading the serif for long-form text (Libre Baskerville), --font-mono the monospace stack. Both webfonts load inside the sheet. The scale is larger on a phone — a touch screen narrower than 768px, where the root font size is also 18px — and tighter everywhere else, a narrow panel on a desktop and a tablet included. Each size has a -lh line height and an -ls letter spacing. The body uses --font-ui and --ts-body by default.

TokenPhoneDesktopLine heightUse
--ts-display2.25rem1.875rem1.1Hero headings
--ts-title-lg1.875rem1.5rem1.2Page titles
--ts-title1.5rem1.25rem1.25Section titles
--ts-title-sm1.125rem1rem1.3Subsections
--ts-body1rem0.875rem1.5Body text (default)
--ts-body-sm0.875rem0.75rem1.5Small text, captions
--ts-eyebrow0.6875rem0.625rem1.5Labels, eyebrows
--ts-xs0.8125rem0.75rem1remExtra small text
<h2 style={{ fontSize: 'var(--ts-title)', lineHeight: 'var(--ts-title-lh)' }}>Skills</h2>
<span style={{ fontSize: 'var(--ts-eyebrow)', color: 'hsl(var(--muted-foreground))' }}>
  STRENGTH
</span>
<p style={{ fontFamily: 'var(--font-reading)' }}>Backstory…</p>

Spacing

--space-N is N steps of the 0.25rem unit, the same scale as the app's own spacing.

--spacing0.25rem
--space-10.25rem
--space-20.5rem
--space-30.75rem
--space-41rem
--space-51.25rem
--space-61.5rem
--space-82rem
--space-102.5rem
--space-123rem
--space-164rem
--spacing-touch1rem

Radius

--radius1rem
--radius-outervar(--radius)
--radius-innercalc(var(--radius) - 2px)
--radius-nestedcalc(var(--radius) - 4px)
--radius-interactivecalc(var(--radius) - 6px)
--radius-lg1rem
--radius-mdcalc(1rem - 2px)
--radius-smcalc(1rem - 4px)
--radius-xscalc(1rem - 8px)

Viewport insets

--polyhedral-inset-top, -right, -bottom and -left are how far the iOS app's navigation and tab bars reach into the sheet, in px. On the web they are 0px. The body is padded by them unless the sheet sets its own body padding, and Polyhedral.viewport holds them in JavaScript. See Phones and the iOS app.

In JavaScript

Polyhedral.theme holds the colors in camelCase (cardForeground is --card-foreground), mode and radius. Polyhedral.tokens holds every token, grouped. Both are replaced on each update, and Polyhedral.theme.onChange() fires every time.

Polyhedral.theme.mode                // 'light' | 'dark'
Polyhedral.tokens.colors.success      // "145 55% 45%"
Polyhedral.tokens.spacing.space4      // "1rem"
Polyhedral.theme.onChange((theme, tokens) => redraw(theme.primary))

Quick Start with AI

With the CLI installed, your AI assistant can build sheets for you end-to-end:

  1. It reads the SDK docs via polyhedral docs.
  2. It writes JSX using the component library and validates it with polyhedral validate.
  3. It pushes the sheet with polyhedral push and gives you a preview URL.
  4. You review, iterate, and when ready deploy it to a game as an instance.

Try asking something like:

"Build me a character sheet with a stress track,
trauma checkboxes, and rated actions"

Examples

Complete, copy-pasteable examples to learn from and build on.

View all examples →

Next Steps