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:
| Kind | Description |
|---|---|
tile | A single game piece (card, token, etc.) |
deck (alias: stack) | A collection of tiles, typically for drawing |
board | A play surface with positions and zones |
tokenSprite | A visual representation for player pieces |
tokenModel | A 3D or complex token definition |
die | A die with numbered faces |
dieFace | A single face of a die |
container | A holding area for game pieces |
counter | A numerical tracker |
bundle | A group of related components |
book | A 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 Kind | Valid Child Kinds | Notes |
|---|---|---|
deck | card, tile | Both are treated as deck cards. tile is accepted as a synonym for card when nested under a deck. |
bundle | tile | Tiles grouped into a two-sided set. |
die | dieFace | Individual 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:
| Field | Type | Required | Description |
|---|---|---|---|
kind | string | yes | Component type (see table above) |
name | string | yes | Display name (max 256 characters) |
externalKey | string | yes | Unique identifier for this component within the game (used for deduplication and parent references) |
count | number | no | Number of instances (min 1) |
widthMm | number | no | Width in millimeters |
heightMm | number | no | Height in millimeters |
shape | string | no | Tile/card outline: box (rectangle, the default), hex, or orb (circle). Applies to tile/card components and is inherited by the parent deck/stack/bundle. |
sides | number | no | Number of die faces (2, 4, 6, 8, 10, 12, or 20). Only applies to die components. |
parentExternalKey | string | no | External key of the parent component (e.g., for cards belonging to a deck) |
assets | array | no | Asset 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.
| Field | Type | Required | Description |
|---|---|---|---|
sideKey | string | yes | Which face this image belongs to (see sideKey values below) |
key | string | yes | The key from the presign response (also accepts r2ObjectKey) |
sourceUrl | string | yes | The publicUrl from the presign response (also accepts publicUrl) |
contentType | string | yes | MIME type (e.g., image/png, image/jpeg) |
widthPx | number | yes | Image width in pixels |
heightPx | number | yes | Image height in pixels |
sourcePixelsPerInch | number | no | DPI of the source image (default: 300) |
bleedMode | string | no | "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 Kind | Valid sideKey Values | Example |
|---|---|---|
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 dataGAME_NOT_FOUND: The game does not existPERMISSION_DENIED: You do not own this gameRATE_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:
| Field | Type | Required | Description |
|---|---|---|---|
components | array | yes | Array 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 existPERMISSION_DENIED: You do not own this gameRATE_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:
| Field | Type | Description |
|---|---|---|
id | string | Unique component identifier |
kind | string | Component type |
name | string | Display name |
externalKey | string | Your external identifier for this component |
count | number | Number of instances |
widthMm | number | Width in millimeters |
heightMm | number | Height in millimeters |
shape | string or undefined | The explicitly set outline (box, hex, or orb). Absent if you never sent shape -- the importer then infers it, defaulting to box. |
parentExternalKey | string or null | Parent component's external key |
payload | object | Component metadata including imageFaces for attached assets |
Errors:
GAME_NOT_FOUND: The game does not existPERMISSION_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 appGAME_NOT_FOUND: The game does not existPERMISSION_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:
| Field | Type | Description |
|---|---|---|
deleted | number | Number of components that were deleted |
Errors:
GAME_NOT_FOUND: The game does not existPERMISSION_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.
| Concept | Deck analogy | Die equivalent |
|---|---|---|
| Parent component | deck | die |
| Child component | tile (card) | dieFace |
| Child links to parent via | parentExternalKey | parentExternalKey |
| 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:
- Are you creating
dieFacechildren? The most common mistake is attaching assets directly to thedieparent component. Dice require separatedieFacechild components, each with its own asset. The die parent must have"assets": []or omit the field entirely. - 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. - 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.
- Are extra fields being sent on the die? Fields like
sides,dieKind, andfaceLabelsat the top level of the component body are ignored. The API only reads:kind,name,externalKey,count,widthMm,heightMm,shape,parentExternalKey,payload, andassets. The die type is determined by counting itsdieFacechildren.
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.