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 |
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,
"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 |
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) |
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: "front" or "back" |
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" |
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,
"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 |
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. 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 appREVISION_FINALIZED: The component belongs to a finalized revision and cannot be deletedGAME_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. 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:
| 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
}
]
}
]
}
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.