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
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
| Field | Type | Role |
|---|---|---|
format | number | Format version. Current: 2. |
meta | GameMeta | Title, description, mode, timestamps, project, remix. |
assets | VoxelAsset[] | Every asset of the game (96 max). |
terrain | Terrain | Height grid up to 256×256. |
entities | Entity[] | Everything placed in the world (3,000 max). |
rules | Rules | Mode and look settings. |
vars | GameVar[] | Game variables (v2). |
gameRules | GameRule[] | Event → conditions → actions rules (v2). |
settings | GameSettings | Duration, players, teams, respawn, win and lose (v2). |
hud | HudSettings | On-screen elements (v2). |
scripts | ScriptDoc[] | JavaScript scripts run in a Worker (v2). |
Limits
| Constant | Value | Applies to |
|---|---|---|
MIN_GRID | 8 | Smallest voxel grid in the editor. |
MAX_GRID | 64 | Largest voxel grid (cube side). |
MAX_ASSETS | 96 | Assets per document. |
MAX_ENTITIES | 3000 | Entities per document. |
MAX_TERRAIN | 256 | Maximum terrain width and depth, in cells. |
GameMeta
titlestringrequireddescriptionstringrequiredmodeGameModerequiredrace, platformer, arena, runner, explore, ctf, tower.authorstringcreatedAtnumberrequiredupdatedAtnumberrequiredprojectIdstringremixOfnumberAssets (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.
idstringrequirednamestringrequiredkind'voxel' | 'mesh' | 'model'Default 'voxel'sizenumberrequiredpalettestring[]requiredpartsPart[]requiredscalenumberrequiredclipsClip[]requiredprimitivesPrimitive[]modelModelDatathumbstringPart
idstringrequirednamestringrequiredpivotVec3requiredparentstringvoxelsRecord<string, number>required"x,y,z" → colour index (1 to 255).Primitive
idstringrequiredshapePrimitiveShaperequiredbox, sphere, cylinder, cone, torus, wedge, plane, capsule, rounded, extrude, lathe.posVec3requiredrotVec3requiredsizeVec3requiredmaterialMaterialrequiredpartstringnamestringop'add' | 'subtract' | 'intersect'bevelnumberprofileProfileIdstar, hexagon, arch, heart, cross, gear, vase, bottle, column, bowl, goblet.Material
colorstringrequiredmetalnessnumberroughnessnumberemissivestringemissiveIntensitynumberopacitynumberflatbooleanmapstringnormalMapstringroughnessMapstringrepeatnumberModelData
format'glb' | 'obj' | 'fbx' | 'stl'requireddatastringurlstringlibraryIdstringgen:<jobId> for a generation.bytesnumberrequiredanimationsstring[]requiredfitnumberrequiredliftnumberrequiredheightnumberrequiredanimFromstringdata model makes the document heavy; a url model weighs only a few bytes. Prefer the library when an equivalent exists.Clip, Track, Keyframe
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
widthnumberrequireddepthnumberrequiredheightsnumber[]requiredwidth × depth entries.colorsnumber[]requiredpalette, same length.palettestring[]requiredcellnumberrequiredStepped or smooth rendering is set by rules.smoothTerrain. Tools are covered in Terrain and water.
Entities
idstringrequiredassetIdstring | nullrequirednull for a marker (player start, trigger, waypoint…).roleEntityRolerequiredposVec3requiredyawnumberrequiredscalenumberrequiredparamsEntityParamsrequiredEntityParams
| Field | Type | Use |
|---|---|---|
tag | string | Free label targeted by rules and scripts. |
hidden | boolean | Hidden 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. |
fireRate | number | Turret fire rate. |
range | number | Turret range. |
trigger | Trigger | Local event: on (touch, interact, stomp), once, needs, actions. |
path | { to, seconds, pause? } | Back-and-forth of a platform or patrol. |
tint | string | Colour that overrides a mesh material. |
label | string | Text shown above the entity. |
order | number | Checkpoint order. |
hp | number | Enemy hit points. |
value | number | Coin value. |
strength | number | Boost strength. |
behavior | idle | patrol | chase | orbit | spin | bob | flee | wander | guard | Behaviour of enemies, NPCs and animated props. |
speed | number | Behaviour speed. |
radius | number | Behaviour or zone radius. |
text | string | Sign text or NPC line. |
clips | Partial<Record<ClipRole, string>> | Clips forced per animation role. |
solid | boolean | Blocks the player or not. |
Trigger
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
lapsnumberbotsnumbertimeLimitnumberlivesnumbercoinsToWinnumberwavesnumberspeednumbergravitynumberjumpHeightnumberlanesnumbercamera'third' | 'first' | 'top' | 'side' | 'fly'Look
style'voxel' | 'lowpoly' | 'toon' | 'pbr'skyPreset'day' | 'sunset' | 'night' | 'overcast' | 'void' | 'neon'skystringfogstringfogDensitynumberhournumberdayCycleMinutesnumbersunnumberambientnumberskyDomebooleanhdristringweather'none' | 'rain' | 'snow'water{ level, color, opacity, waves }post{ bloom?, ssao?, vignette?, grade?, outline? }grade ∈ none, warm, cool, noir, vivid.bloombooleanoutlinebooleanmusicstringmoodstringsmoothTerrainbooleanLogic (v2)
GameVar
namestringrequiredtype'number' | 'boolean' | 'text'requiredvaluenumber | boolean | stringrequiredscope'global' | 'player' | 'object'requiredGameRule
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
durationnumberplayersnumberteamsbooleanrespawnbooleanrespawnSecondsnumberwin{ type: 'score' | 'survive' | 'collectAll' | 'finish' | 'flags' | 'waves' | 'custom' }score and flags carry a value.lose{ type: 'lives' | 'time' | 'baseDestroyed' | 'custom' }HudSettings
score, timer, health, lives, coins, messagebooleanvarsstring[]titlestringScriptDoc
idstringrequirednamestringrequiredcodestringrequiredenabledbooleanrequiredMinimal 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.
{
"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.
| Check | Message |
|---|---|
| Format equals the current version after migration | Unknown format version |
| Non-empty title, 64 characters max | A title is needed |
| Description of 400 characters max | Description too long (400 max) |
| Known mode | Unknown mode |
| 96 assets max, grid size between 4 and 64, 64 parts, 256-colour palette | Too many assets |
| 400 primitives max per mesh asset | Too many primitives (400 max) |
| Model with data or an allowed url | Model url must be a library url |
| Terrain between 4 and 256 cells, arrays of the right length | Bad terrain size |
| 3000 entities max | Too many entities |
| At least one player entity | Place 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.
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 → to | Change |
|---|---|
| 1 → 2 | model.url allowed instead of model.data; model.height added (1.8 by default); vars, gameRules, settings, hud and scripts initialised empty. |