Scene format

The full schema of the SandboxGame v2 document: fields, types, limits, canonicalisation, keccak256 hash and migrations between format versions.

A Sandbox game is one JSON document of type SandboxGame. It holds everything: assets, terrain, entities, settings, logic and scripts. This page describes every field of version 2, how the document is validated and hashed, and the migrations applied to older files.

Source of truth

The reference types live in src/engine/types.ts, migrations in src/engine/migrate.ts and server validation in src/lib/server/validate.ts. This page follows them field by field.

Document root

FieldTypeRole
formatnumberFormat version. Current: 2.
metaGameMetaTitle, description, mode, timestamps, project, remix.
assetsVoxelAsset[]Every asset of the game (96 max).
terrainTerrainHeight grid up to 256×256.
entitiesEntity[]Everything placed in the world (3,000 max).
rulesRulesMode and look settings.
varsGameVar[]Game variables (v2).
gameRulesGameRule[]Event → conditions → actions rules (v2).
settingsGameSettingsDuration, players, teams, respawn, win and lose (v2).
hudHudSettingsOn-screen elements (v2).
scriptsScriptDoc[]JavaScript scripts run in a Worker (v2).

Limits

ConstantValueApplies to
MIN_GRID8Smallest voxel grid in the editor.
MAX_GRID64Largest voxel grid (cube side).
MAX_ASSETS96Assets per document.
MAX_ENTITIES3000Entities per document.
MAX_TERRAIN256Maximum terrain width and depth, in cells.

GameMeta

titlestringrequired
Game title, not empty, 64 characters at most.
descriptionstringrequired
Description, 400 characters at most.
modeGameModerequired
One of the seven modes: race, platformer, arena, runner, explore, ctf, tower.
authorstring
Free author name.
createdAtnumberrequired
Creation timestamp in milliseconds.
updatedAtnumberrequired
Last modification timestamp in milliseconds.
projectIdstring
Studio project id, kept when publishing so remixes can be traced.
remixOfnumber
On-chain id of the original game when the document is a remix.

Assets (VoxelAsset)

An asset has a nature, kind: voxel (default), mesh (primitives) or model (imported or library file). All three share the same base fields and the same animation clips.

idstringrequired
Unique id within the document; entities point at it.
namestringrequired
Name shown in the studio.
kind'voxel' | 'mesh' | 'model'Default 'voxel'
Nature of the asset.
sizenumberrequired
Side of the editing grid; voxels live in [0, size). Validation accepts 4 to 64.
palettestring[]required
Hex colours, index 0 is empty. 256 entries at most.
partsPart[]required
Rigid animatable parts, 64 at most.
scalenumberrequired
World units per voxel when placed.
clipsClip[]required
Keyframed animation clips.
primitivesPrimitive[]
For kind = mesh: the pieces, 400 at most.
modelModelData
For kind = model: the file and its normalisation.
thumbstring
Cached thumbnail as a data URL. Stripped by the server before hashing.

Part

idstringrequired
Part id, target of animation tracks.
namestringrequired
Display name.
pivotVec3required
Point the part rotates around.
parentstring
Parent part: the child inherits its transform.
voxelsRecord<string, number>required
Sparse map "x,y,z" → colour index (1 to 255).

Primitive

idstringrequired
Piece id.
shapePrimitiveShaperequired
box, sphere, cylinder, cone, torus, wedge, plane, capsule, rounded, extrude, lathe.
posVec3required
Position in world units.
rotVec3required
Rotation in degrees.
sizeVec3required
Dimensions in world units.
materialMaterialrequired
PBR material, see below.
partstring
Bone (animation part) carrying the piece.
namestring
Display name.
op'add' | 'subtract' | 'intersect'
Boolean operation against the pieces listed before it in the same bone.
bevelnumber
Rounded box radius or extrude bevel (0 to 0.5 of the smallest size).
profileProfileId
Extrude or lathe profile: star, hexagon, arch, heart, cross, gear, vase, bottle, column, bowl, goblet.

Material

colorstringrequired
Base hex colour.
metalnessnumber
Metalness, 0 to 1.
roughnessnumber
Roughness, 0 to 1.
emissivestring
Emitted colour.
emissiveIntensitynumber
Emission intensity.
opacitynumber
Opacity, 0 to 1.
flatboolean
Flat (faceted) shading.
mapstring
Colour texture URL.
normalMapstring
Normal map URL.
roughnessMapstring
Roughness map URL.
repeatnumber
How many times textures repeat across the piece.

ModelData

format'glb' | 'obj' | 'fbx' | 'stl'required
File format (an imported glTF is stored as glb).
datastring
File as base64 (inline import). Validation refuses more than 16 million characters, about a 12 MB file.
urlstring
File URL (library or AI generation). Must be a relative path or an https Vercel Blob / vercel.app URL.
libraryIdstring
Library asset id, or gen:<jobId> for a generation.
bytesnumberrequired
File size in bytes.
animationsstring[]required
Clip names found in the file, playable by name.
fitnumberrequired
Uniform scale applied to reach the target height.
liftnumberrequired
Vertical offset that stands the model on the ground.
heightnumberrequired
World height after fitting (added in v2, 1.8 by default on migration).
animFromstring
URL of another GLB whose clips play on this model (same bone names).
A data model makes the document heavy; a url model weighs only a few bytes. Prefer the library when an equivalent exists.

Clip, Track, Keyframe

types.tsTypeScript
interface Clip {
  id: string
  name: string
  duration: number   // seconds
  loop: boolean
  tracks: Track[]
}

interface Track {
  partId: string     // the Part (or bone) it drives
  keys: Keyframe[]
}

interface Keyframe {
  t: number          // seconds
  pos?: Vec3
  rot?: Vec3         // Euler degrees
  scale?: Vec3
}

The runtime picks a clip per role (idle, move, jump, hit, attack, spin); an entity can force its own clips with params.clips. See Animation.

Terrain

widthnumberrequired
Cells along X, 4 to 256.
depthnumberrequired
Cells along Z, 4 to 256.
heightsnumber[]required
Heights in blocks, row-major, exactly width × depth entries.
colorsnumber[]required
Colour index per cell into palette, same length.
palettestring[]required
Terrain hex colours, index 0 unused.
cellnumberrequired
World units per block.

Stepped or smooth rendering is set by rules.smoothTerrain. Tools are covered in Terrain and water.

Entities

idstringrequired
Unique entity id.
assetIdstring | nullrequired
Asset shown, or null for a marker (player start, trigger, waypoint…).
roleEntityRolerequired
What the entity does in the game. Full list: Entity roles.
posVec3required
World position.
yawnumberrequired
Rotation around Y, in degrees.
scalenumberrequired
Uniform scale.
paramsEntityParamsrequired
Role-specific parameters (an empty object is fine).

EntityParams

FieldTypeUse
tagstringFree label targeted by rules and scripts.
hiddenbooleanHidden at start, revealed by an action.
team'red' | 'blue'Team (flags, bases, players, enemies).
spawner{ asset, role, every, max, total, radius, behavior? }What a spawner spawns and how often.
sound{ url, radius, loop, volume }Spatial sound.
fireRatenumberTurret fire rate.
rangenumberTurret range.
triggerTriggerLocal event: on (touch, interact, stomp), once, needs, actions.
path{ to, seconds, pause? }Back-and-forth of a platform or patrol.
tintstringColour that overrides a mesh material.
labelstringText shown above the entity.
ordernumberCheckpoint order.
hpnumberEnemy hit points.
valuenumberCoin value.
strengthnumberBoost strength.
behavioridle | patrol | chase | orbit | spin | bob | flee | wander | guardBehaviour of enemies, NPCs and animated props.
speednumberBehaviour speed.
radiusnumberBehaviour or zone radius.
textstringSign text or NPC line.
clipsPartial<Record<ClipRole, string>>Clips forced per animation role.
solidbooleanBlocks the player or not.

Trigger

TypeScript
interface Trigger {
  on: 'touch' | 'interact' | 'stomp'
  once: boolean          // fire once, or every time
  needs?: string         // key name set by an 'unlock' action
  actions: TriggerAction[]
}

type TriggerAction =
  | { type: 'message'; text: string }
  | { type: 'score'; value: number }
  | { type: 'teleport'; x: number; y: number; z: number }
  | { type: 'remove'; target?: string }
  | { type: 'show'; target: string }
  | { type: 'win'; text?: string }
  | { type: 'lose'; text?: string }
  | { type: 'heal'; value: number }
  | { type: 'hurt'; value: number }
  | { type: 'speed'; value: number; seconds: number }
  | { type: 'spawn'; asset: string; role: EntityRole; count: number }
  | { type: 'unlock'; key: string }

Rules: mode and look

The rules object mixes mode settings and look settings. Every field is optional: the mode and the engine have defaults.

Mode settings

lapsnumber
Race: number of laps.
botsnumber
Race: number of AI drivers.
timeLimitnumber
Time limit in seconds, 0 = none.
livesnumber
Number of lives.
coinsToWinnumber
Platformer / explore: coins needed to open the finish, 0 = all optional.
wavesnumber
Arena: number of waves.
speednumber
Player top speed multiplier.
gravitynumber
Gravity.
jumpHeightnumber
Jump height.
lanesnumber
Runner: number of lanes.
camera'third' | 'first' | 'top' | 'side' | 'fly'
Player camera.

Look

style'voxel' | 'lowpoly' | 'toon' | 'pbr'
Render style of the whole world.
skyPreset'day' | 'sunset' | 'night' | 'overcast' | 'void' | 'neon'
Sky preset.
skystring
Sky colour.
fogstring
Fog colour.
fogDensitynumber
Fog density, 0 to 1.
hournumber
Hour 0 to 24, drives the sun.
dayCycleMinutesnumber
Real-time minutes for a full day, 0 = frozen.
sunnumber
Sun intensity.
ambientnumber
Ambient light intensity.
skyDomeboolean
Procedural sky dome instead of a flat colour.
hdristring
URL of an equirectangular HDR used as sky and environment.
weather'none' | 'rain' | 'snow'
Weather.
water{ level, color, opacity, waves }
Water plane: height, colour, opacity, wave amplitude.
post{ bloom?, ssao?, vignette?, grade?, outline? }
Post-processing; grade ∈ none, warm, cool, noir, vivid.
bloomboolean
Bloom switch.
outlineboolean
Outlines.
musicstring
Background music URL.
moodstring
Last mood preset applied (for the editor).
smoothTerrainboolean
Smooth terrain instead of stepped blocks.

Logic (v2)

GameVar

namestringrequired
Variable name.
type'number' | 'boolean' | 'text'required
Value type.
valuenumber | boolean | stringrequired
Initial value.
scope'global' | 'player' | 'object'required
One per game, one per player, or one per entity.

GameRule

TypeScript
interface GameRule {
  id: string
  name: string
  enabled: boolean
  once: boolean
  event: RuleEvent            // start, tick, touch, interact, kill, pickup, timer,
                              // variable, score, time, enter, leave
  conditions: RuleCondition[] // { name, op: '==' | '!=' | '>' | '>=' | '<' | '<=', value }
  actions: RuleAction[]       // every TriggerAction, plus setVar, addVar, spawnAt,
                              // removeTag, showTag, sound, mood, hud, timer, endRound
}

The semantics of each event and action are covered in Rules and events.

GameSettings

durationnumber
Round length in seconds, 0 = none.
playersnumber
Planned player count (reserved for multiplayer, not wired yet).
teamsboolean
Team game.
respawnboolean
Respawn after death.
respawnSecondsnumber
Respawn delay.
win{ type: 'score' | 'survive' | 'collectAll' | 'finish' | 'flags' | 'waves' | 'custom' }
What wins the round; score and flags carry a value.
lose{ type: 'lives' | 'time' | 'baseDestroyed' | 'custom' }
What loses the round.

HudSettings

score, timer, health, lives, coins, messageboolean
Shows or hides each HUD element.
varsstring[]
Variables shown as extra lines.
titlestring
Title shown in the HUD.

ScriptDoc

idstringrequired
Script id.
namestringrequired
Name shown in the Scripts panel and in errors.
codestringrequired
JavaScript source (32,000 characters at most to run).
enabledbooleanrequired
A disabled script is stored but never compiled.

Scripting API reference

Minimal document

This is the smallest document the server accepts: a title, a known mode, a 4×4 terrain whose arrays have the right length, and a player start.

hello.sandbox.jsonJSON
{
  "format": 2,
  "meta": {
    "title": "Hello Sandbox",
    "description": "One player on a small plain.",
    "mode": "explore",
    "createdAt": 1790000000000,
    "updatedAt": 1790000000000
  },
  "assets": [],
  "terrain": {
    "width": 4,
    "depth": 4,
    "heights": [1, 1, 1, 1, 1, 1, 1, 1, 1, 1, 1, 1, 1, 1, 1, 1],
    "colors": [1, 1, 1, 1, 1, 1, 1, 1, 1, 1, 1, 1, 1, 1, 1, 1],
    "palette": ["", "#6fbf5a"],
    "cell": 1
  },
  "entities": [
    { "id": "e1", "assetId": null, "role": "player", "pos": [0, 1, 0], "yaw": 0, "scale": 1, "params": {} }
  ],
  "rules": {},
  "vars": [],
  "gameRules": [],
  "settings": {},
  "hud": {},
  "scripts": []
}

Validation

On publish, validateGame migrates the document then checks its structure. Errors come back in plain words, for instance Place a player start first.

CheckMessage
Format equals the current version after migrationUnknown format version
Non-empty title, 64 characters maxA title is needed
Description of 400 characters maxDescription too long (400 max)
Known modeUnknown mode
96 assets max, grid size between 4 and 64, 64 parts, 256-colour paletteToo many assets
400 primitives max per mesh assetToo many primitives (400 max)
Model with data or an allowed urlModel url must be a library url
Terrain between 4 and 256 cells, arrays of the right lengthBad terrain size
3000 entities maxToo many entities
At least one player entityPlace a player start first

The request body is capped at 40 MB and the PNG cover at 2 MB. Each asset’s thumb field is stripped before hashing.

Canonicalisation and hash

The validated document is serialised canonically: each object’s keys are sorted, undefined values are dropped, arrays keep their order. The resulting string is hashed with keccak256. Two identical documents always share the same hash, whatever the key order in the file.

voxel.ts / validate.tsTypeScript
function canonical(value: unknown): string {
  if (Array.isArray(value)) return `[${value.map(canonical).join(',')}]`
  if (value && typeof value === 'object') {
    const o = value as Record<string, unknown>
    return `{${Object.keys(o).sort()
      .filter((k) => o[k] !== undefined)
      .map((k) => `${JSON.stringify(k)}:${canonical(o[k])}`)
      .join(',')}}`
  }
  return JSON.stringify(value)
}

const hash = keccak256(toHex(canonical(doc)))

That hash is the file name (objects/<hash>.json) and the contentHash in the on-chain registry. See Publishing a game.

Migrations

migrate(doc) reads format (missing = 1), applies each step up to the current version and never mutates its input. A document newer than the engine is refused. The server, file import, remix and local project loading all go through it.

From → toChange
1 → 2model.url allowed instead of model.data; model.height added (1.8 by default); vars, gameRules, settings, hud and scripts initialised empty.

See also