Components API

Create, list, and delete game components (tiles, decks, tokens, etc.) in Playtest Parlor.

The Components API lets you create, update, list, and delete game pieces. Components are the building blocks of your game - tiles, stacks (decks), tokens, boards, and more.

All component creation uses upsert semantics: if a component with the same kind and externalKey already exists, it is updated in place. This means re-exporting is always safe -- you never create duplicates, and existing components retain their identity across updates so they keep their positions on tables.

Component Kinds

Playtest Parlor supports these component kinds:

KindDescription
tileA single game piece (card, token, etc.)
deck (alias: stack)A collection of tiles, typically for drawing
boardA play surface with positions and zones
tokenSpriteA visual representation for player pieces
tokenModelA 3D or complex token definition
dieA die with numbered faces
dieFaceA single face of a die
containerA holding area for game pieces
counterA numerical tracker
bundleA group of related components
bookA readable book with ordered pages and an optional cover

Books use book-specific request fields (pages and cover, as R2 keys) instead of assets; see the Books API for the full contract. All the other kinds follow the asset flow documented on this page.

Parent-Child Relationships

Components can be nested using parentExternalKey. Only certain kinds are allowed as children:

Parent KindValid Child KindsNotes
deckcard, tileBoth are treated as deck cards. tile is accepted as a synonym for card when nested under a deck.
bundletileTiles grouped into a two-sided set.
diedieFaceIndividual faces of a die.

Providing parentExternalKey on any other kind (e.g., deck, board, tokenSprite) will return a validation error.

Create or Update Single Component

Create or update one component at a time. If a component with the same kind and externalKey already exists, it is updated in place. Assets are upserted by sideKey -- uploading a new image for the same side replaces the previous one.

Request:

POST /api/v1/games/:gameId/components
Authorization: Bearer pp_your_token
Content-Type: application/json

{
  "kind": "tile",
  "name": "Fighter Card",
  "externalKey": "fighter-card",
  "count": 12,
  "widthMm": 88.9,
  "heightMm": 127,
  "shape": "box",
  "assets": [
    {
      "sideKey": "front",
      "key": "uploads/fighter-front.png",
      "sourceUrl": "https://assets.playtestparlor.com/uploads/fighter-front.png",
      "contentType": "image/png",
      "widthPx": 750,
      "heightPx": 1050,
      "sourcePixelsPerInch": 300
    },
    {
      "sideKey": "back",
      "key": "uploads/fighter-back.png",
      "sourceUrl": "https://assets.playtestparlor.com/uploads/fighter-back.png",
      "contentType": "image/png",
      "widthPx": 750,
      "heightPx": 1050,
      "sourcePixelsPerInch": 300
    }
  ]
}

Request Body:

FieldTypeRequiredDescription
kindstringyesComponent type (see table above)
namestringyesDisplay name (max 256 characters)
externalKeystringyesUnique identifier for this component within the game (used for deduplication and parent references)
countnumbernoNumber of instances (min 1)
widthMmnumbernoWidth in millimeters
heightMmnumbernoHeight in millimeters
shapestringnoTile/card outline: box (rectangle, the default), hex, or orb (circle). Applies to tile/card components and is inherited by the parent deck/stack/bundle.
sidesnumbernoNumber of die faces (2, 4, 6, 8, 10, 12, or 20). Only applies to die components.
parentExternalKeystringnoExternal key of the parent component (e.g., for cards belonging to a deck)
assetsarraynoAsset objects to attach to this component (see Asset Object below)

If shape is omitted, Playtest Parlor infers the outline from the component's payload and otherwise defaults to box (rectangle). Set shape explicitly to import hex or circular tiles reliably.

Asset Object

Each entry in the assets array maps an uploaded image to a specific face of the component using the sideKey field.

FieldTypeRequiredDescription
sideKeystringyesWhich face this image belongs to (see sideKey values below)
keystringyesThe key from the presign response (also accepts r2ObjectKey)
sourceUrlstringyesThe publicUrl from the presign response (also accepts publicUrl)
contentTypestringyesMIME type (e.g., image/png, image/jpeg)
widthPxnumberyesImage width in pixels
heightPxnumberyesImage height in pixels
sourcePixelsPerInchnumbernoDPI of the source image (default: 300)
bleedModestringno"bleed" (default) or "noBleed"

sideKey Values by Component Kind

The sideKey identifies which face of the component an asset belongs to. The valid values depend on the component kind:

Component KindValid sideKey ValuesExample
tile, card, board"front", "back""front" for the card face, "back" for the card back
dieFace"face-1", "face-2", ... "face-N""face-3" for the third face of a die

Using the wrong sideKey pattern (e.g., "front" on a dieFace) will cause the asset to not render on the component.

A tile or card must have a "front" image. These kinds render their front face with no color fallback, so one created without a "front" asset would show up blank on the table. The API rejects such a push with a VALIDATION_ERROR that names the offending components. The "front" can come from the current request, or already exist on the component from a previous push (so metadata-only re-exports that omit assets still succeed). Decks, stacks, and bundles are exempt: their front art lives on their child tiles/cards, and they may carry only a shared "back".

Image derivation is handled automatically. There is no separate derivation step.

Response:

{
  "id": "j57a..."
}

Errors:

  • VALIDATION_ERROR: Invalid component data
  • GAME_NOT_FOUND: The game does not exist
  • PERMISSION_DENIED: You do not own this game
  • RATE_LIMITED: You have exceeded component creation limits

Batch Create or Update Components

Create or update up to 200 components in a single request. Same upsert semantics as the single endpoint.

Request:

POST /api/v1/games/:gameId/components/batch
Authorization: Bearer pp_your_token
Content-Type: application/json

{
  "components": [
    {
      "kind": "deck",
      "name": "Main Deck",
      "externalKey": "main-deck",
      "count": 1,
      "widthMm": 88.9,
      "heightMm": 127
    },
    {
      "kind": "tile",
      "name": "Basic Card",
      "externalKey": "basic-card",
      "count": 50,
      "widthMm": 88.9,
      "heightMm": 127,
      "parentExternalKey": "main-deck",
      "assets": [
        {
          "sideKey": "front",
          "key": "uploads/basic-front.png",
          "sourceUrl": "https://assets.playtestparlor.com/uploads/basic-front.png",
          "contentType": "image/png"
        }
      ]
    },
    {
      "kind": "tile",
      "name": "Special Card",
      "externalKey": "special-card",
      "count": 10,
      "widthMm": 88.9,
      "heightMm": 127,
      "parentExternalKey": "main-deck",
      "assets": [
        {
          "sideKey": "front",
          "key": "uploads/special-front.png",
          "sourceUrl": "https://assets.playtestparlor.com/uploads/special-front.png",
          "contentType": "image/png"
        },
        {
          "sideKey": "back",
          "key": "uploads/special-back.png",
          "sourceUrl": "https://assets.playtestparlor.com/uploads/special-back.png",
          "contentType": "image/png"
        }
      ]
    }
  ]
}

Request Body:

FieldTypeRequiredDescription
componentsarrayyesArray of component objects (max 200)

Each component object has the same fields as the single create endpoint.

Response:

{
  "components": [
    { "id": "j57a..." },
    { "id": "k82b..." },
    { "id": "m93c..." }
  ]
}

Errors:

  • VALIDATION_ERROR: Invalid component data or too many components (max 200)
  • GAME_NOT_FOUND: The game does not exist
  • PERMISSION_DENIED: You do not own this game
  • RATE_LIMITED: You have exceeded component creation limits (120 per minute, 10 batch requests per minute)

List Components

Retrieve all components your app has created for a game. Only returns components from your app's import source.

Request:

GET /api/v1/games/:gameId/components
Authorization: Bearer pp_your_token

Response:

{
  "components": [
    {
      "id": "j57a...",
      "kind": "deck",
      "name": "Main Deck",
      "externalKey": "main-deck",
      "count": 1,
      "widthMm": 88.9,
      "heightMm": 127,
      "parentExternalKey": null,
      "payload": {}
    },
    {
      "id": "k82b...",
      "kind": "tile",
      "name": "Basic Card",
      "externalKey": "basic-card",
      "count": 50,
      "widthMm": 88.9,
      "heightMm": 127,
      "shape": "hex",
      "parentExternalKey": "main-deck",
      "payload": {
        "imageFaces": [
          { "sideKey": "front", "assetId": "a1b2...", "derivedAssetId": "c3d4..." }
        ]
      }
    }
  ]
}

Response Fields:

FieldTypeDescription
idstringUnique component identifier
kindstringComponent type
namestringDisplay name
externalKeystringYour external identifier for this component
countnumberNumber of instances
widthMmnumberWidth in millimeters
heightMmnumberHeight in millimeters
shapestring or undefinedThe explicitly set outline (box, hex, or orb). Absent if you never sent shape -- the importer then infers it, defaulting to box.
parentExternalKeystring or nullParent component's external key
payloadobjectComponent metadata including imageFaces for attached assets

Errors:

  • GAME_NOT_FOUND: The game does not exist
  • PERMISSION_DENIED: You do not own this game

Delete Component

Delete a single component and all of its associated assets. Works even after a previous export has published: the deletion lands in a new draft that publishes automatically, leaving your other components intact.

Request:

DELETE /api/v1/games/:gameId/components/:componentId
Authorization: Bearer pp_your_token

Response: 204 No Content

Errors:

  • COMPONENT_NOT_FOUND: The component does not exist or belongs to a different app
  • GAME_NOT_FOUND: The game does not exist
  • PERMISSION_DENIED: You do not own this game

Batch Delete Components

Delete all components (and their assets) your app has created for a game, providing a clean slate for re-export. Deletes apply even after a previous export has published: the next published revision will reflect the removal.

Request:

DELETE /api/v1/games/:gameId/components/batch
Authorization: Bearer pp_your_token

Response:

{
  "deleted": 15
}

Response Fields:

FieldTypeDescription
deletednumberNumber of components that were deleted

Errors:

  • GAME_NOT_FOUND: The game does not exist
  • PERMISSION_DENIED: You do not own this game

Units

All dimensions (widthMm, heightMm) are in millimeters. This is the canonical storage format in Playtest Parlor. When you retrieve components from the app or other APIs, they will always be in millimeters.

Typical Deck Workflow

Here is a typical workflow for creating a deck with multiple card types:

# 1. Upload card images (front and back) via the Assets API
#    For each image: presign -> upload to signed URL -> confirm upload
#    You'll get key, publicUrl, and displayPlan from the presign response

# 2. Create the deck and its cards in one batch request
#    Cards reference the deck via parentExternalKey
#    Each asset's sideKey ("front" or "back") maps the image to a tile face
POST /api/v1/games/:gameId/components/batch
{
  "components": [
    {
      "kind": "deck",
      "name": "Main Deck",
      "externalKey": "main-deck",
      "widthMm": 88.9,
      "heightMm": 127
    },
    {
      "kind": "tile",
      "name": "Basic Card",
      "externalKey": "basic-card",
      "count": 40,
      "widthMm": 88.9,
      "heightMm": 127,
      "parentExternalKey": "main-deck",
      "assets": [
        {
          "sideKey": "front",
          "key": "uploads/basic-front.png",
          "sourceUrl": "https://assets.playtestparlor.com/uploads/basic-front.png",
          "contentType": "image/png",
          "widthPx": 750,
          "heightPx": 1050,
          "sourcePixelsPerInch": 300
        },
        {
          "sideKey": "back",
          "key": "uploads/card-back.png",
          "sourceUrl": "https://assets.playtestparlor.com/uploads/card-back.png",
          "contentType": "image/png",
          "widthPx": 750,
          "heightPx": 1050,
          "sourcePixelsPerInch": 300
        }
      ]
    },
    {
      "kind": "tile",
      "name": "Special Card",
      "externalKey": "special-card",
      "count": 10,
      "widthMm": 88.9,
      "heightMm": 127,
      "parentExternalKey": "main-deck",
      "assets": [
        {
          "sideKey": "front",
          "key": "uploads/special-front.png",
          "sourceUrl": "https://assets.playtestparlor.com/uploads/special-front.png",
          "contentType": "image/png",
          "widthPx": 750,
          "heightPx": 1050,
          "sourcePixelsPerInch": 300
        },
        {
          "sideKey": "back",
          "key": "uploads/card-back.png",
          "sourceUrl": "https://assets.playtestparlor.com/uploads/card-back.png",
          "contentType": "image/png",
          "widthPx": 750,
          "heightPx": 1050,
          "sourcePixelsPerInch": 300
        }
      ]
    }
  ]
}

Typical Die Workflow

Dice use a parent-child model, just like decks and cards. You must create separate dieFace child components for each face -- you cannot attach assets directly to the die parent.

ConceptDeck analogyDie equivalent
Parent componentdeckdie
Child componenttile (card)dieFace
Child links to parent viaparentExternalKeyparentExternalKey
Asset sideKey on child"front" / "back""face-1", "face-2", ... "face-N"

The die parent component carries no assets. All face images go on individual dieFace children.

Set sides on the die parent to specify the die type (e.g., "sides": 8 for a d8). You do not need to provide a dieFace child for every face -- faces without images will display default numbered labels. Dice do not require widthMm/heightMm -- dimensions are automatically derived from the sides value (e.g., a d6 defaults to 25mm).

# 1. Upload face images via the Assets API
#    For each face image: presign -> upload to signed URL -> confirm upload

# 2. Create the die and its faces in one batch request
POST /api/v1/games/:gameId/components/batch
{
  "components": [
    {
      "kind": "die",
      "name": "Attack Die",
      "externalKey": "attack-die",
      "sides": 6
    },
    {
      "kind": "dieFace",
      "name": "Face 1",
      "externalKey": "attack-die-face-1",
      "parentExternalKey": "attack-die",
      "assets": [
        {
          "sideKey": "face-1",
          "key": "uploads/attack-face-1.png",
          "sourceUrl": "https://assets.playtestparlor.com/uploads/attack-face-1.png",
          "contentType": "image/png",
          "widthPx": 256,
          "heightPx": 256
        }
      ]
    },
    {
      "kind": "dieFace",
      "name": "Face 2",
      "externalKey": "attack-die-face-2",
      "parentExternalKey": "attack-die",
      "assets": [
        {
          "sideKey": "face-2",
          "key": "uploads/attack-face-2.png",
          "sourceUrl": "https://assets.playtestparlor.com/uploads/attack-face-2.png",
          "contentType": "image/png",
          "widthPx": 256,
          "heightPx": 256
        }
      ]
    },
    {
      "kind": "dieFace",
      "name": "Face 3",
      "externalKey": "attack-die-face-3",
      "parentExternalKey": "attack-die",
      "assets": [
        {
          "sideKey": "face-3",
          "key": "uploads/attack-face-3.png",
          "sourceUrl": "https://assets.playtestparlor.com/uploads/attack-face-3.png",
          "contentType": "image/png",
          "widthPx": 256,
          "heightPx": 256
        }
      ]
    },
    {
      "kind": "dieFace",
      "name": "Face 4",
      "externalKey": "attack-die-face-4",
      "parentExternalKey": "attack-die",
      "assets": [
        {
          "sideKey": "face-4",
          "key": "uploads/attack-face-4.png",
          "sourceUrl": "https://assets.playtestparlor.com/uploads/attack-face-4.png",
          "contentType": "image/png",
          "widthPx": 256,
          "heightPx": 256
        }
      ]
    },
    {
      "kind": "dieFace",
      "name": "Face 5",
      "externalKey": "attack-die-face-5",
      "parentExternalKey": "attack-die",
      "assets": [
        {
          "sideKey": "face-5",
          "key": "uploads/attack-face-5.png",
          "sourceUrl": "https://assets.playtestparlor.com/uploads/attack-face-5.png",
          "contentType": "image/png",
          "widthPx": 256,
          "heightPx": 256
        }
      ]
    },
    {
      "kind": "dieFace",
      "name": "Face 6",
      "externalKey": "attack-die-face-6",
      "parentExternalKey": "attack-die",
      "assets": [
        {
          "sideKey": "face-6",
          "key": "uploads/attack-face-6.png",
          "sourceUrl": "https://assets.playtestparlor.com/uploads/attack-face-6.png",
          "contentType": "image/png",
          "widthPx": 256,
          "heightPx": 256
        }
      ]
    }
  ]
}

Set sides on the die parent to control the die type. Supported values: 2 (coin), 4, 6, 8, 10, 12, 20. You only need to create dieFace children for faces that have custom images -- faces without a corresponding dieFace child will display default numbered labels.

Re-export Workflow

Component creation uses upsert semantics, so re-exporting is straightforward. There are two common patterns:

Partial re-export (only changed tiles)

If only some tiles in a deck changed, just upload the new assets and upsert those tiles. Unchanged tiles are left alone.

// 1. Upload new/changed assets via the Assets API
// 2. Upsert only the changed tiles
await fetch(`https://playtestparlor.com/api/v1/games/${gameId}/components/batch`, {
  method: "POST",
  headers: { "Authorization": `Bearer ${token}`, "Content-Type": "application/json" },
  body: JSON.stringify({ components: changedTiles })
});
// Existing tiles with the same externalKey are updated in place.
// Other tiles in the deck are untouched.

Full re-export (complete deck sync)

If tiles may have been added or removed, compare the current state in PP with the full tile list from your design tool and delete any tiles that no longer exist.

// 1. Get what's currently in PP
const { components } = await fetch(
  `https://playtestparlor.com/api/v1/games/${gameId}/components`,
  { headers: { "Authorization": `Bearer ${token}` } }
).then(r => r.json());

// 2. Find tiles that no longer exist in the design
const deckKey = "deck-my-deck-id";
const currentTileKeys = new Set(myDesignTiles.map(t => t.externalKey));
const orphanedTiles = components.filter(c =>
  c.parentExternalKey === deckKey && !currentTileKeys.has(c.externalKey)
);

// 3. Delete orphaned tiles
for (const tile of orphanedTiles) {
  await fetch(
    `https://playtestparlor.com/api/v1/games/${gameId}/components/${tile.id}`,
    { method: "DELETE", headers: { "Authorization": `Bearer ${token}` } }
  );
}

// 4. Upload assets and upsert all current tiles
await fetch(`https://playtestparlor.com/api/v1/games/${gameId}/components/batch`, {
  method: "POST",
  headers: { "Authorization": `Bearer ${token}`, "Content-Type": "application/json" },
  body: JSON.stringify({ components: allCurrentTiles })
});

Troubleshooting

Die faces not rendering

If your die component is created successfully but face images do not appear, check:

  1. Are you creating dieFace children? The most common mistake is attaching assets directly to the die parent component. Dice require separate dieFace child components, each with its own asset. The die parent must have "assets": [] or omit the field entirely.
  2. Is the sideKey correct? Each dieFace asset must use "face-N" (e.g., "face-1", "face-2"). Using "front", "side1", or any other value will silently fail to bind.
  3. Does each dieFace reference the die via parentExternalKey? Without this link, the face is created as an orphan component and will not attach to the die.
  4. Are extra fields being sent on the die? Fields like sides, dieKind, and faceLabels at the top level of the component body are ignored. The API only reads: kind, name, externalKey, count, widthMm, heightMm, shape, parentExternalKey, payload, and assets. The die type is determined by counting its dieFace children.

Incorrect example (will not work)

{
  "kind": "die",
  "name": "D6",
  "externalKey": "my-die",
  "sides": 6,
  "dieKind": "polyhedral",
  "assets": [
    { "sideKey": "front", "key": "...", "sourceUrl": "...", "contentType": "image/png" }
  ]
}

This fails because: assets are on the die parent (should be on dieFace children), sideKey is "front" (should be "face-N"), and sides/dieKind are unrecognized fields.

Correct pattern

{
  "components": [
    { "kind": "die", "name": "D6", "externalKey": "my-die", "sides": 6 },
    {
      "kind": "dieFace", "name": "Face 1", "externalKey": "my-die-f1",
      "parentExternalKey": "my-die",
      "assets": [{ "sideKey": "face-1", "key": "...", "sourceUrl": "...", "contentType": "image/png", "widthPx": 256, "heightPx": 256 }]
    },
    {
      "kind": "dieFace", "name": "Face 2", "externalKey": "my-die-f2",
      "parentExternalKey": "my-die",
      "assets": [{ "sideKey": "face-2", "key": "...", "sourceUrl": "...", "contentType": "image/png", "widthPx": 256, "heightPx": 256 }]
    }
  ]
}

Rate Limits

  • Component create: 120 per minute
  • Batch create: 10 per minute

See Errors for all rate limits.