File Format (.purl and .purlx)
Purl uses two file formats:
.purl— Complete project (cells, objects, markers, settings).purlx— Reusable objects for sharing/importing
Both formats use the same CellObject schema for objects. This document specifies the schemas so valid files can be generated programmatically.
For scripting syntax, see the Scripting doc. For dynamics behavior, see the Dynamics doc. For object semantics, see the Objects doc.
.purlx Format
A .purlx file is a thin wrapper around an array of objects. The objects use the exact same CellObject format as .purl files (documented below).
Structure
{
"version": "1.0",
"type": "purlx",
"metadata": {
"name": "Component Name",
"description": "Optional description",
"createdAt": 1700000000000,
"tags": ["optional", "tags"]
},
"objects": [
// Same CellObject format as cell.interior.objects in .purl
// See "Objects (CellObject)" section below
]
}
.purlx Root
| Field | Type | Required | Description |
|---|---|---|---|
version |
"1.0" |
yes | Format version |
type |
"purlx" |
yes | Must be "purlx" |
metadata.name |
string | yes | Display name |
metadata.description |
string | no | Description |
metadata.author |
string | no | Author name |
metadata.createdAt |
number | yes | Unix timestamp in milliseconds |
metadata.tags |
string[] | no | Searchable tags |
objects |
CellObject[] | yes | Array of objects — same format as .purl cell objects |
Import Behavior
When a .purlx is imported:
- All objects get new unique IDs (prevents collisions)
- Objects are positioned at the import location
- If names conflict with existing objects, imported objects get numeric suffixes (e.g.,
Shape1→Shape12) - Scripts referencing other objects in the same
.purlxwork correctly (they're imported together)
.purl Format
IDs
All id fields use the format {timestamp}-{random7} where timestamp is Date.now() and random7 is 7 random alphanumeric characters (base-36). Example: "1768686621803-sazcegg". Every id in a file must be unique.
Minimal Valid File
{
"id": "1700000000000-abc1234",
"title": "My Project",
"author": "",
"version": "1.0.0",
"createdAt": 1700000000000,
"updatedAt": 1700000000000,
"cells": [
{
"id": "1700000000001-def5678",
"label": "Start",
"position": { "col": 2, "row": 2 },
"rotation": 0,
"size": { "width": 1, "height": 1 },
"visuals": {
"background": {
"fillLayers": [{ "id": "bg1", "type": "color", "color": "#4b5563", "opacity": 1 }],
"pattern": "none",
"patternColor": "rgba(0,0,0,0.2)",
"patternScale": 1
}
},
"interior": { "objects": [] },
"initialCamera": { "x": 0, "y": 0, "zoom": 1 }
}
],
"markers": [
{ "type": "start", "cellId": "1700000000001-def5678", "showInPlayMode": true }
]
}
Project (root)
| Field | Type | Required | Description |
|---|---|---|---|
id |
string | yes | Unique project ID |
title |
string | yes | Project name |
author |
string | yes | Author name (can be "") |
version |
string | yes | Semver, use "1.0.0" |
createdAt |
number | yes | Unix timestamp in milliseconds |
updatedAt |
number | yes | Unix timestamp in milliseconds |
cells |
Cell[] | yes | Array of cells (scenes/screens) |
markers |
Marker[] | yes | Navigation markers |
settings |
GameSettings | no | Game/export settings |
Marker
| Field | Type | Required | Description |
|---|---|---|---|
type |
"start" or "finish" |
yes | Marker type |
cellId |
string | yes | ID of the marked cell |
showInPlayMode |
boolean | yes | Show marker indicator in play |
Every project needs at least one "start" marker pointing to a valid cell.
GameSettings
All fields optional. Defaults shown.
| Field | Type | Default | Description |
|---|---|---|---|
letterboxColor |
string | "#000000" |
Letterbox bar color |
scaleMode |
"fit" or "fixed" |
"fit" |
How canvas scales to window |
fullscreenMode |
"button", "auto", "disabled" |
"button" |
Fullscreen behavior |
cameraDeadZone |
number | 0.4 |
0-1, camera follow dead zone size |
cameraAspectRatio |
number | 1.7778 |
Camera width/height ratio (16:9) |
Cell
| Field | Type | Required | Default | Description |
|---|---|---|---|---|
id |
string | yes | Unique cell ID | |
label |
string | yes | Display name | |
position |
{ col, row } |
yes | Grid position on map (integers, can be negative) | |
rotation |
number | yes | 0 |
Cell rotation in degrees |
size |
{ width, height } |
yes | { "width": 1, "height": 1 } |
Cell dimensions in screen units (1 = one viewport) |
visuals |
CellVisuals | yes | Background styling | |
interior |
CellInterior | yes | Objects and scripts | |
initialCamera |
{ x, y, zoom } |
yes | { "x": 0, "y": 0, "zoom": 1 } |
Initial camera in play mode |
tags |
string[] | no | Custom tags for scripting | |
gravity |
number | no | -10 to 10, vertical acceleration | |
wind |
number | no | -10 to 10, wind magnitude | |
windAngle |
number | no | 0 |
Wind direction in degrees (0=right, 90=down) |
perspectiveX |
number | no | 1 |
Parallax X factor (0=fixed, 1=scrolls with camera) |
perspectiveY |
number | no | 1 |
Parallax Y factor |
CellVisuals
{
"background": {
"fillLayers": [{ "id": "bg1", "type": "color", "color": "#4b5563", "opacity": 1 }],
"pattern": "none",
"patternColor": "rgba(0,0,0,0.2)",
"patternScale": 1
}
}
| Field | Type | Required | Description |
|---|---|---|---|
fillLayers |
FillLayer[] | yes | Background fill layers (see Fill Layers section) |
pattern |
string | yes | Overlay pattern: "none", "grid", "dots", etc. |
patternColor |
string | yes | Pattern color with alpha |
patternScale |
number | yes | Pattern scale (0.5-2) |
CellInterior
{
"objects": [],
"scripts": { "scripts": [{ "id": "...", "name": "Main", "code": "...", "enabled": true }] },
"layers": []
}
All fields except objects are optional.
Coordinate System
All object positions and sizes use unified coordinates where 1.0 = one cell height unit:
x: 0.5, y: 0.5= center of a 1x1 cellwidth: 0.2, height: 0.15= 20% of cell height wide, 15% tall- For cells larger than 1x1: coordinates extend beyond 1.0 (e.g., a 2x1 cell has x range 0 to 2)
x: 0, y: 0= top-left corner
Polygon vertices, line endpoints, and path segment coordinates are 0-1 relative to the object's own bounds.
Objects (CellObject)
A CellObject is either a Prime or a Component. Primes have a primeType field. Components have a children field.
Prime
Required fields:
| Field | Type | Description |
|---|---|---|
id |
string | Unique object ID |
name |
string | Script-addressable name (e.g., "Shape1", "Player") |
primeType |
string | "text", "button", "shape", "line", "grid" |
x |
number | X position in cell coords |
y |
number | Y position in cell coords |
visible |
boolean | Runtime visibility (usually true) |
Common optional fields:
| Field | Type | Default | Description |
|---|---|---|---|
libraryId |
string | Source library template ID | |
width |
number | Object width (not used by lines) | |
height |
number | Object height (not used by lines) | |
zIndex |
number | Render order (higher = on top, starts at 1) | |
buildVisible |
boolean | true |
Editor-only visibility |
pointerEvents |
string | "auto" |
"auto" or "none" |
opacity |
number | 1 |
0-1 |
rotation |
number | 0 |
Degrees |
flipX |
boolean | false |
Horizontal flip |
flipY |
boolean | false |
Vertical flip |
pivot |
{ x, y } |
{ "x": 0.5, "y": 0.5 } |
Rotation center, 0-1 normalized |
tags |
string[] | Script-addressable tags | |
layerId |
string | yes | Layer this object belongs to |
scripts |
ObjectScripts | Event scripts |
Shape Properties (primeType: "shape")
| Field | Type | Default | Description |
|---|---|---|---|
shapeType |
string | "polygon" |
See shape types below |
fillLayers |
FillLayer[] | Multi-layer fill (see Fill Layers below) | |
strokeWidth |
number | 0 |
Border width |
strokeColor |
string | "#000000" |
Border color |
strokeStyle |
string | "solid" |
"solid", "dashed", "dotted" |
cornerRadius |
number | 0 |
Corner rounding |
cornerRadiusUnit |
string | "px" |
"px" or "%" |
polygonPoints |
array | Vertex array for polygon shapes | |
pathSegments |
array | Bezier path for path shapes | |
content |
string | Optional text displayed inside the shape | |
textColor |
string | "#ffffff" |
Color of content text |
textAlign |
string | "center" |
Alignment: "left", "center", "right" |
Shape types: "polygon", "circle", "ellipse", "path"
Polygon shapes require polygonPoints — an array of { "x": 0-1, "y": 0-1 } vertices relative to object bounds. A rectangle is 4 points:
"polygonPoints": [
{ "x": 0, "y": 0 }, { "x": 1, "y": 0 },
{ "x": 1, "y": 1 }, { "x": 0, "y": 1 }
]
Path shapes require pathSegments — an array of bezier curve commands (M, L, C, Q, Z).
Common libraryId values for shapes:
"prime:rectangle"— polygon with 4 corners"prime:circle"— circle (shapeType:"circle")"prime:triangle"— polygon with 3 vertices"prime:pentagon","prime:hexagon","prime:star"— polygons"prime:heart"— path with bezier curves"prime:arrow"— polygon with 7 vertices
Shadow Properties
| Field | Type | Default |
|---|---|---|
shadowX |
number | 0 |
shadowY |
number | 0 |
shadowBlur |
number | 0 |
shadowSpread |
number | 0 |
shadowColor |
string | "rgba(0,0,0,0.3)" |
Text Properties (primeType: "text" or "button")
How text sizing works:
- Text renders in an SVG with a fixed 80px font in a 100×100 viewBox
- The object's height controls font size (viewBox scales to fit height)
- Text overflows horizontally — width doesn't constrain the text, it spills out
- Newlines (
\n) don't work — SVG text ignores them, producing one long line - For multi-line text, use separate text objects
Typical single-line sizes:
- Small label:
height: 0.04(short text like "Score: 0") - Medium text:
height: 0.06(labels, buttons) - Large heading:
height: 0.1(titles)
| Field | Type | Default | Description |
|---|---|---|---|
content |
string | "" |
Text content (newlines ignored) |
textColor |
string | "#ffffff" |
Text color |
fontSize |
number | 0.05 |
Reserved for future use |
fontFamily |
string | "inherit" |
CSS font family |
fontWeight |
string | "normal" |
"normal" or "bold" |
fontStyle |
string | "normal" |
"normal" or "italic" |
textDecoration |
string | "none" |
"none" or "underline" |
textAlign |
string | "center" |
"left", "center", "right" |
label |
string | Button label (primeType: "button") |
Line Properties (primeType: "line")
Lines have no width/height. They use endpoint coordinates in cell space.
| Field | Type | Default | Description |
|---|---|---|---|
lineX1 |
number | Start X in cell coords | |
lineY1 |
number | Start Y | |
lineX2 |
number | End X | |
lineY2 |
number | End Y | |
strokeWidth |
number | 2 |
Line thickness |
strokeColor |
string | "#ffffff" |
Line color |
strokeStyle |
string | "solid" |
"solid", "dashed", "dotted" |
lineCap |
string | "round" |
"butt", "round", "square" |
lineStartMarker |
string | "none" |
"none", "arrow", "circle", "square", "diamond" |
lineEndMarker |
string | "none" |
Same as above |
Grid Properties (primeType: "grid")
| Field | Type | Default | Description |
|---|---|---|---|
columns |
number | 8 |
Number of columns |
rows |
number | 8 |
Number of rows |
gridColor |
string | "#888888" |
Grid line color |
gridData |
array | 2D array `(string |
Dynamics Properties
These make objects interactive in play mode. See Dynamics doc for behavior details.
| Field | Type | Description |
|---|---|---|
movable |
MovableConfig | Enables physics movement |
jumpable |
JumpableConfig | Enables jumping |
input |
string | What controls movement (see InputType) |
subject |
boolean | Camera follows this object + viewport bounded |
inputBinding |
InputBinding | Key bindings |
blocking |
boolean or string | Blocks other objects (true or expression) |
mass |
number | Object mass (default: width * height) |
friction |
number | Surface friction 0-1 (0=ice, 1=sticky) |
restitution |
number | Bounciness 0-1 (0=dead stop, 1=perfect bounce) |
oneWay |
boolean | One-way platform (blocks from above only) |
sensor |
boolean | Overlap detection without blocking (fires onOverlap/onOverlapEnd) |
snapToGrid |
string | Grid object ID for grid-based movement |
perspectiveX |
number | Per-object parallax X (0-1) |
perspectiveY |
number | Per-object parallax Y (0-1) |
Presets
Presets store named snapshots of children's visual properties. Used with set Object "presetName" in scripts.
"presets": [
{
"name": "default",
"values": {
"ChildName": { "fillLayers": [{ "id": "c1", "type": "color", "color": "#ff0000", "opacity": 1 }] }
},
"includesPosition": false,
"includesTransform": false
}
],
"state": "default"
Component
A component is an invisible container. Its children move, scale, and rotate together. Required fields:
| Field | Type | Description |
|---|---|---|
id |
string | Unique object ID |
name |
string | Script-addressable name |
x |
number | X position in cell coords |
y |
number | Y position in cell coords |
visible |
boolean | Runtime visibility |
children |
CellObject[] | Array of child Primes or Components |
Optional fields (computed automatically on load if omitted):
| Field | Type | Description |
|---|---|---|
width |
number | Container width (computed from children bounding box) |
height |
number | Container height (computed from children bounding box) |
Components support the same optional fields as Primes: zIndex, rotation, pivot, scripts, tags, layerId, movable, jumpable, input, subject, blocking, sensor, mass, friction, restitution, oneWay, snapToGrid, perspectiveX, perspectiveY, presets, state, buildVisible, pointerEvents, inputBinding, libraryId.
Additional component field:
| Field | Type | Description |
|---|---|---|
exposedProperties |
array | { childName, propertyName, displayName? }[] — properties surfaced to parent |
Sub-Types
FillLayer
Multi-layer fill. Each layer has a type and composites over previous layers.
Common fields on all layers:
| Field | Type | Required |
|---|---|---|
id |
string | yes |
type |
string | yes |
opacity |
number | yes (0-1) |
blendMode |
string | no |
Color layer:
{ "id": "...", "type": "color", "opacity": 1, "color": "#6366f1" }
Image layer:
{ "id": "...", "type": "image", "opacity": 1, "imageData": "data:image/png;base64,...", "mode": "fill" }
Mode: "fill", "fit", "stretch", "tile", "center". Optional: scale, offsetX, offsetY.
Gradient layer:
{ "id": "...", "type": "gradient", "opacity": 1,
"gradientType": "linear", "angle": 180,
"stops": [{ "color": "#ff0000", "position": 0 }, { "color": "#0000ff", "position": 1 }] }
Gradient types: "linear", "radial", "conic", "diamond".
Pattern layer:
{ "id": "...", "type": "pattern", "opacity": 1,
"pattern": "dots", "color": "#000000", "scale": 1 }
Patterns: "dots", "grid", "lines", "diagonal", "cross". Optional: weight.
Noise layer:
{ "id": "...", "type": "noise", "opacity": 1,
"noiseType": "fractalNoise", "scale": 1, "intensity": 0.5, "monochrome": false }
Noise types: "fractalNoise", "turbulence".
PathSegment
Bezier path segments for shapeType: "path". All coordinates 0-1 relative to object bounds.
"pathSegments": [
{ "type": "M", "x": 0, "y": 0.5 },
{ "type": "C", "cp1x": 0.25, "cp1y": 0, "cp2x": 0.75, "cp2y": 1, "x": 1, "y": 0.5 },
{ "type": "Z" }
]
| Type | Fields | Description |
|---|---|---|
"M" |
x, y |
Move to |
"L" |
x, y |
Line to |
"C" |
cp1x, cp1y, cp2x, cp2y, x, y |
Cubic bezier |
"Q" |
cpx, cpy, x, y |
Quadratic bezier |
"Z" |
— | Close path |
MovableConfig
{ "speed": 0.3, "acceleration": 5, "deceleration": 5 }
| Field | Type | Required | Description |
|---|---|---|---|
speed |
number | yes | Max velocity (units/sec) |
acceleration |
number | no | How quickly reaches max speed |
deceleration |
number | no | Friction/drag when no input |
moveStyle |
string | no | Animation style: "teleport", "slide", "fade", "jump" |
JumpableConfig
{ "height": 0.5, "keys": ["Space", " "], "multiJump": 1 }
| Field | Type | Default | Description |
|---|---|---|---|
height |
number | 0.5 |
Upward impulse velocity |
keys |
string[] | ["Space", " "] |
Trigger keys |
multiJump |
number | 1 |
Max jumps before landing (2 = double jump) |
InputType
"keyboard" | "click"
keyboard— WASD/arrow keysclick— click-to-move (clicks set destination, object moves toward it)- Objects with
movablebut noinputrespond only to physics (gravity, wind) and scripts
InputBinding
{ "player": 1 }
| Field | Type | Description |
|---|---|---|
player |
1 or 2 | Player 1: WASD + arrows. Player 2: arrows only |
keys |
object | Custom: { "up": ["w"], "down": ["s"], "left": ["a"], "right": ["d"] } |
Scripts
{
"scripts": {
"scripts": [
{
"id": "1700000000010-scr1234",
"name": "Main",
"code": "onClick:\n go \"Level2\"",
"enabled": true
}
]
}
}
The code field contains Purl DSL source. See the Scripting doc for syntax. Scripts can be on cells (in interior.scripts) or objects (in object scripts field).
Layer
Editor-only organization. Does not affect play mode or scripts. Every cell must have at least one layer.
{ "id": "...", "name": "Background", "zIndex": 1, "buildVisible": true, "locked": false }
| Field | Type | Required | Description |
|---|---|---|---|
id |
string | yes | Unique layer ID |
name |
string | yes | Display name |
layerId |
string | no | Parent layer ID (for nesting) |
zIndex |
number | no | Visual ordering (higher = on top) |
buildVisible |
boolean | no | Editor-only visibility (default true) |
locked |
boolean | no | If true, objects in layer cannot be selected or edited |
Objects reference their layer via layerId. Layers can nest via layerId on the layer itself.
Example: Shape with Fill and Stroke
{
"id": "1700000000002-shp1234",
"name": "Platform1",
"primeType": "shape",
"libraryId": "prime:rectangle",
"x": 0.3,
"y": 0.8,
"width": 0.4,
"height": 0.05,
"visible": true,
"zIndex": 2,
"shapeType": "polygon",
"polygonPoints": [
{ "x": 0, "y": 0 }, { "x": 1, "y": 0 },
{ "x": 1, "y": 1 }, { "x": 0, "y": 1 }
],
"fillLayers": [
{ "id": "fl1", "type": "color", "opacity": 1, "color": "#22c55e" }
],
"strokeWidth": 2,
"strokeColor": "#000000",
"blocking": true
}
Example: Movable Player with Script
{
"id": "1700000000003-plr1234",
"name": "Player",
"primeType": "shape",
"libraryId": "prime:circle",
"x": 0.5,
"y": 0.3,
"width": 0.08,
"height": 0.08,
"visible": true,
"zIndex": 5,
"shapeType": "ellipse",
"fillLayers": [
{ "id": "fl2", "type": "color", "opacity": 1, "color": "#3b82f6" }
],
"movable": { "speed": 0.3, "acceleration": 5, "deceleration": 5 },
"jumpable": { "height": 0.5 },
"input": "keyboard",
"subject": true,
"mass": 1,
"scripts": {
"scripts": [
{
"id": "1700000000011-sc5678",
"name": "Main",
"code": "onEnter:\n set self blocking true",
"enabled": true
}
]
}
}
Example: Component with Children
{
"id": "1700000000004-cmp1234",
"name": "Coin",
"x": 0.7,
"y": 0.5,
"width": 0.06,
"height": 0.06,
"visible": true,
"zIndex": 3,
"children": [
{
"id": "1700000000005-bg5678",
"name": "Background",
"primeType": "shape",
"x": 0.7,
"y": 0.5,
"width": 0.06,
"height": 0.06,
"visible": true,
"shapeType": "ellipse",
"fillLayers": [
{ "id": "fl3", "type": "color", "opacity": 1, "color": "#fbbf24" }
]
}
],
"scripts": {
"scripts": [
{
"id": "1700000000012-sc9012",
"name": "Main",
"code": "onClick:\n hide self",
"enabled": true
}
]
}
}
Note: component children use global cell coordinates (same coordinate space as the parent), not coordinates relative to the component bounds.
Example: Line
{
"id": "1700000000006-lin1234",
"name": "Line1",
"primeType": "line",
"libraryId": "prime:line",
"x": 0,
"y": 0,
"visible": true,
"zIndex": 1,
"lineX1": 0.2,
"lineY1": 0.5,
"lineX2": 0.8,
"lineY2": 0.5,
"strokeWidth": 3,
"strokeColor": "#ffffff",
"lineEndMarker": "arrow"
}
Example: Side-View Platformer Cell
{
"id": "1700000000007-cel1234",
"label": "Level 1",
"position": { "col": 2, "row": 2 },
"rotation": 0,
"size": { "width": 2, "height": 1 },
"visuals": {
"background": {
"fillLayers": [{
"id": "bg1", "type": "gradient", "opacity": 1,
"gradientType": "linear", "angle": 180,
"stops": [
{ "color": "#1e3a5f", "position": 0 },
{ "color": "#0f172a", "position": 1 }
]
}],
"pattern": "none",
"patternColor": "rgba(0,0,0,0.2)",
"patternScale": 1
}
},
"interior": {
"objects": [
{
"id": "1700000000008-flr1234",
"name": "Floor",
"primeType": "shape",
"libraryId": "prime:rectangle",
"x": 0,
"y": 0.92,
"width": 2,
"height": 0.08,
"visible": true,
"zIndex": 1,
"shapeType": "polygon",
"polygonPoints": [
{ "x": 0, "y": 0 }, { "x": 1, "y": 0 },
{ "x": 1, "y": 1 }, { "x": 0, "y": 1 }
],
"fillLayers": [
{ "id": "fl4", "type": "color", "opacity": 1, "color": "#374151" }
],
"blocking": true
},
{
"id": "1700000000009-ply1234",
"name": "Player",
"primeType": "shape",
"libraryId": "prime:rectangle",
"x": 0.2,
"y": 0.75,
"width": 0.06,
"height": 0.1,
"visible": true,
"zIndex": 5,
"shapeType": "polygon",
"polygonPoints": [
{ "x": 0, "y": 0 }, { "x": 1, "y": 0 },
{ "x": 1, "y": 1 }, { "x": 0, "y": 1 }
],
"fillLayers": [
{ "id": "fl5", "type": "color", "opacity": 1, "color": "#3b82f6" }
],
"movable": { "speed": 0.4, "acceleration": 8, "deceleration": 6 },
"jumpable": { "height": 0.55 },
"input": "keyboard",
"subject": true,
"blocking": true,
"mass": 1
}
]
},
"gravity": 1.5,
"initialCamera": { "x": 0, "y": 0, "zoom": 1 }
}