Playground API
Everything the browser editor does is available over HTTP with an API key —
this is what the Unity client uses. Base URL: https://arx.dev/api/v1/playground.
Send Authorization: Bearer <key> (get a key).
Full schema: OpenAPI (tag Playground).
Scenes
KEY=...; API=https://arx.dev/api/v1/playground
curl -s -H "Authorization: Bearer $KEY" $API/scenes # list
curl -s -H "Authorization: Bearer $KEY" -H 'Content-Type: application/json' \
-d '{"name":"Forest"}' $API/scenes # create → whole scene
curl -s -H "Authorization: Bearer $KEY" $API/scenes/SCENE_ID # whole scene
A scene:
{
"id": "ldwxk0g5", "name": "Forest",
"objects": [
{ "id": "o_ab12cd34", "type": "asset", "asset_id": "3ma8qmgw", "name": "SM_Env_Tree_01",
"position": [3.2, 0, -4.1], "rotation": [0, 45, 0], "scale": [1.1, 1.1, 1.1],
"size_m": { "x": 2.3, "y": 4.69, "z": 2.26 }, "group": "Nature/Trees", "static": true, "grounded": true },
{ "id": "o_ef56gh78", "type": "primitive", "shape": "cylinder", "size": [6, 0.1, 6],
"color": "#2c6e9b", "position": [0, 0, 0], "rotation": [0, 0, 0], "scale": [1, 1, 1], "material": "m_x1y2z3w4" },
{ "id": "o_ij90kl12", "type": "light", "color": "#ffb15a", "intensity": 4, "range": 12, "position": [2, 1.2, 1], "rotation": [0, 0, 0], "scale": [1, 1, 1] },
{ "id": "o_mn34op56", "type": "camera", "name": "Overlook", "fov": 55, "position": [22, 14, 22], "rotation": [-25, 45, 0], "scale": [1, 1, 1] }
],
"environment": { "preset": "night", "ground": "grass", "ground_size": 120, "fog": 0.15, "ground_material": "m_x1y2z3w4" },
"materials": { "m_x1y2z3w4": { "id": "m_x1y2z3w4", "name": "Pond", "color": "#2c6e9b", "roughness": 0.1, "opacity": 0.85,
"pattern": "none", "tile_size": 2, "tint": false } },
"settings": { "asset_lock": "@synty", "groups": { "Nature": { "locked": true } } },
"terrain": { "enabled": true, "style": "hills", "seed": 7, "height": 14, "scale": 90, "roughness": 0.5, "water_level": -2, "flat_radius": 0 },
"terrain_chunks": [ { "cx": 0, "cz": 0, "h": "<base64 raw-deflate int16 cm>", "p": "<base64 raw-deflate uint8 or null>" } ],
"assets": { "3ma8qmgw": { "...": "render info, see below" } },
"turns": [ { "id": "k2j3h4g5", "goal": "build me a forest", "status": "done", "steps": [], "reply": "Forest program complete…" } ]
}
Conventions
- Metres; y up; the ground is y = 0; the default camera looks at the origin from +z.
positionis the object’s base (its bottom), for every type — and for assets, the centre of their footprint: clients anchor an asset at the bottom centre of its bounds, not at the model’s pivot, sosize_m × scaleis exactly the box it fills aroundposition.rotationis degrees, Euler XYZ. Cameras store[pitch, yaw, 0], applied yaw first, looking down −z.groupis a folder path (Nature/Trees);staticmarks scenery (clients may instance it and skip it for clicks);groundedmeans y follows the terrain (set it when placing on the ground).interaction(assets and primitives) makes a door:{ kind: hinge | slide | lift, part, hinge: left | right, angle, direction: left | right, open, auto }.partnames the prefab node(s) that move (comma-separated; none = the whole object). Open / close it with anupdatewhose changes are{ interaction: { open: true } }(partial hashes merge);interaction: nullremoves it. Interactive objects are neverstatic.- Coordinates are three.js (right-handed). For Unity (left-handed): position
(-x, y, z); rotation: build the quaternion, then use(qx, -qy, -qz, qw); cameras additionally turn 180° about y (a three.js camera looks down −z, a Unity camera down +z). The Unity client does this for you.
Changing a scene: ops
POST /scenes/:id/ops with { "ops": [...], "origin": "<your client id>" }. The response has the
applied ops (ids and defaults filled in) and render info for any asset you added.
| op | fields |
|---|---|
add |
object (as above; id optional) |
update |
id, changes — any of position, rotation, scale, name, color, size, intensity, range, fov, emissive, material (null = original look), group (null = no folder), static, grounded |
remove |
ids |
env |
environment — preset (holodeck, day, sunset, night, overcast, space), ground (grid, grass, sand, snow, dirt, water, stone, asphalt, none), ground_size, fog 0–1, sky_color, ground_color, ground_material |
clear |
— removes every object |
material |
material — upsert: name, color, roughness, metalness, emissive, emissive_intensity, opacity, pattern, pattern_color, tile_size, tint |
material_remove |
id |
settings |
settings — asset_lock: @scope, @scope/Pack or null |
group |
path, changes — any of hidden, locked, static, rename (new path), offset [x,y,z], delete — on the folder and its subfolders; comes back as plain updates / removes / settings |
terrain |
terrain — enabled, style (hills, mountains, plains, islands, dunes, flat), seed, height, scale, roughness, water_level (null = none), flat_radius, layers ({ "path": { "color": "#…", "material": "m_…" } }, null resets a layer) |
terrain_brush |
mode (raise, lower, flatten, smooth, noise, terrace, paint), x, z, radius (≤ 80 m), strength (metres for raise/lower; 0–1 otherwise), height (flatten target / terrace step), hardness 0–1, layer (paint) — comes back as terrain_chunks |
terrain_path |
points [{x,z}…], width, mode (flatten = road, lower = river, raise = ridge), depth, layer (painted down the middle), strength |
terrain_paint_rule |
layer, area (circle or rectangle), min_height, max_height, min_slope, max_slope |
terrain_undo |
— puts back what the last terrain edit changed (repeatable, last 30); chunks that hadn’t existed come back as { cx, cz, deleted: true } |
terrain_reset |
— drops every sculpt and paint edit |
camera |
ids / target / position — moves open editors’ view; not stored |
curl -s -H "Authorization: Bearer $KEY" -H 'Content-Type: application/json' $API/scenes/SCENE_ID/ops -d '{
"origin": "my-script",
"ops": [
{ "op": "env", "environment": { "preset": "sunset", "ground": "sand" } },
{ "op": "add", "object": { "type": "asset", "asset_id": "3ma8qmgw", "position": [0, 0, -5] } }
]}'
Terrain
Terrain is endless and procedural: height at any point = a seeded base + sculpt edits. Clients compute
the base themselves from terrain, so only edits travel — as 64 m chunks in terrain_chunks (and in
terrain_chunks ops: { "op": "terrain_chunks", "chunks": [...], "reset": true? }):
h: 65 × 65 int16 little-endian centimetre deltas, row-major by z (rowj= z, columni= x; sample(i, j)is atx = cx·64 + i,z = cz·64 + j), raw-deflated, base64.p: 65 × 65 uint8 paint layers (auto grass dirt sand rock snow path moss), same encoding, or null.
The base is value-noise fBm with a 32-bit integer hash — specified in
app/services/playground/terrain.rb, ported to app/javascript/playground/terrain_math.js and the
Unity package’s TerrainMath.cs, all checked against one fixture. Port that if you render terrain
yourself.
Building with the agent
Hand the server agent a goal; it plans and calls tools until it’s done.
curl -s -H "Authorization: Bearer $KEY" -H 'Content-Type: application/json' \
-d '{"goal":"build a desert oasis at sunset"}' $API/scenes/SCENE_ID/command
# → 202 { "turn": { "id": "k2j3h4g5", "status": "queued", ... } }
curl -s -H "Authorization: Bearer $KEY" $API/scenes/SCENE_ID/turns/k2j3h4g5 # poll
curl -s -X POST -H "Authorization: Bearer $KEY" $API/scenes/SCENE_ID/cancel # halt
A turn’s status goes queued → running → done | failed | cancelled; steps grows as it works
({ tool, args, result, ok, at }) and reply is its closing line. One goal at a time per scene (409 otherwise).
What it cost — per turn and for the scene (steps, model calls, tokens, USD):
curl -s -H "Authorization: Bearer $KEY" $API/scenes/SCENE_ID/usage
# → { "turns": [ { "id", "goal", "status", "steps", "tools": { "place_asset": 12, … }, "llm_calls",
# "input_tokens", "output_tokens", "cost_usd", "seconds" } ], "totals": { … "total_cost_usd" } }
Building with your own loop: tools
Run your own LLM (or plain code) and call the same tools the agent uses:
curl -s -H "Authorization: Bearer $KEY" $API/tools # { tools: [OpenAI function schemas], system_prompt }
curl -s -H "Authorization: Bearer $KEY" -H 'Content-Type: application/json' \
$API/scenes/SCENE_ID/tools/search_assets -d '{"query":"pine tree","limit":5}'
curl -s -H "Authorization: Bearer $KEY" -H 'Content-Type: application/json' \
$API/scenes/SCENE_ID/tools/scatter_assets -d '{
"asset_ids": ["3ma8qmgw"], "count": 40, "group": "forest",
"area": { "center": { "x": 0, "z": 0 }, "radius": 30, "inner_radius": 6 } }'
The tool schemas plug straight into OpenAI-style tools: (Grok, OpenRouter, OpenAI) — or Anthropic’s
input_schema (use function.parameters). Results are JSON with a human summary; failures are
422 { "error": … }. Tool calls made this way show up in the browser console as “Tool calls via the API”.
See the tool reference.
Assets for placement
curl -s -H "Authorization: Bearer $KEY" "$API/assets?q=pine+tree&lock=@synty&limit=8"
curl -s -H "Authorization: Bearer $KEY" $API/assets/3ma8qmgw
curl -s -H "Authorization: Bearer $KEY" $API/vendors
Search returns prefabs first, then models with no prefab, each with asset_id, kind, name,
path, unity_guid, pack_id, size_m, thumbnail_url. Render info (assets/:id, and a scene’s
assets map) tells a renderer how to draw it:
| kind | fields |
|---|---|
model |
glb_url (untextured unless self-hosted), model_url (FBX/OBJ), format, unit_scale (metres per file unit), textures (atlas URL per material slot — apply to every mesh material with extras.slot = index) |
prefab |
prefab_tree (nodes with three.js-space transforms) + parts { files: guid → {url, format, unit_scale}, materials: guid → {texture_url} } — assemble like app/javascript/playground/prefab.js; in metres |
Both carry unity_guid and path, so a Unity project that already contains the pack can place the
real prefab directly (AssetDatabase.GUIDToAssetPath), and anything missing can be fetched as a Unity
package from GET /api/v1/assets/:id/package.
Live updates (WebSocket)
Scenes stream changes over ActionCable. Connect to wss://<host>/cable?token=<key> (send an Origin
header equal to the server’s base URL), then:
→ { "command": "subscribe", "identifier": "{\"channel\":\"PlaygroundChannel\",\"id\":\"SCENE_ID\"}" }
← { "type": "welcome" }
← { "type": "confirm_subscription", "identifier": "…" }
← { "type": "ping", "message": 1791500000 }
← { "identifier": "…", "message": { "type": "ops", "ops": [ … ], "assets": { "asset_id": { … } }, "origin": "my-script" } }
← { "identifier": "…", "message": { "type": "turn", "turn": { "id": "…", "status": "running", "steps": [ … ] } } }
Apply ops messages in order (skip ones whose origin is your own client id — you already applied
them). After a reconnect, re-fetch the scene. turn messages carry agent progress.
Voice
POST /transcribe— multipartaudio(webm/opus, wav, mp3, mp4, ogg) →{ "text": … }POST /speak—{ "text": "…", "audio_format": "mp3" | "wav" }→ audio bytes (the computer’s voice)
Textures
POST /scenes/:id/texture { "prompt": "mossy cobblestone", "tile_size": 2 } paints a seamless texture
and adds it to the scene as a material ({ ops: [ { op: "material", material } ] }). Then
update an object with { "material": "<id>" } or set environment.ground_material.
Limits
600 requests/hour per key across the API (and MCP); 60/hour for speech; agent goals and generated
textures have daily caps per user (defaults 200 and 50). 429 comes with Retry-After.