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

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,
  "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
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)

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: "front" or "back"
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"

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,
      "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
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. Only works on components in a draft revision.

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
  • REVISION_FINALIZED: The component belongs to a finalized revision and cannot be deleted
  • 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. This removes everything from the current draft revision, providing a clean slate for re-export.

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
        }
      ]
    }
  ]
}

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 })
});

Rate Limits

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

See Errors for all rate limits.