Composites
This skill carries the shared composite format reference used by other Decentraland skills (create-scene, add-3d-models, sdk-scenes).
The mandatory workflow below applies to EVERY composite you author or edit: check the Creator Hub MCP gate first, compute scene bounds (Step 0), consult the format catalog in the reference while writing entities, then run the validation gate at the end before finishing.
Gate — use the Creator Hub MCP instead of editing the file (check FIRST)
Hand-editing main.composite is the fallback, not the default. The Creator Hub exposes an MCP server (skill: creator-hub-mcp) whose tools — scene_state, create_entity, set_component, remove_entity, place_smart_item, attach_script, set_scene_settings, … — change the open scene live: the viewport updates, the editor autosaves the composite itself, inspector::Nodes and the rest of the editor bookkeeping stay consistent, and every change is undoable by the user. Editing the file instead risks the classic failure: the editor's autosave regenerates the file wholesale and your work silently vanishes.
Before touching the file, pick the first row that matches:
- Creator Hub MCP tools are available in the session (
mcp__creator-hub__*in Claude Code; barescene_state/create_entityinside the Creator Hub's own AI assistant) → do NOT editmain.composite,main.crdt, orscene.jsonby hand. Use the tools for every entity, component, and settings change. The format catalog in this skill remains your reference for component shapes —set_componenttakes the same JSON the composite stores — and Step 0 (bounds) still applies. - The Creator Hub is running but the tools are not connected → tell the user the MCP is the safer route and how to connect (
{baseDir}/../creator-hub-mcp/reference/connect.md: Settings > Experimental > Expose AI assistant MCP server, copy the snippet). Fall back to file editing only if they decline, and only with the scene closed in the Creator Hub. - No Creator Hub in play (CLI-only project, CI, the app is closed and the user wants it that way) → the file workflow below applies in full.
Step 0 — Read scene.json and Compute Bounds (MANDATORY)
Before writing a single entity position, read scene.json and calculate the scene bounds. This must happen first — all entity positions must fit within these bounds or they will not render.
How to calculate bounds
- Open
scene.jsonand locatescene.parcels(array of"x,y"strings) andscene.base. - Parse every parcel as integers. Find the min and max X and Y across all parcels.
- Compute:
parcelsWide = max(parcel_x) - min(parcel_x) + 1
parcelsDeep = max(parcel_y) - min(parcel_y) + 1
maxX = parcelsWide * 16
maxZ = parcelsDeep * 16
- Valid entity positions: X in [0, maxX], Z in [0, maxZ]. Negative values and values above maxX/maxZ are outside the scene and will not render.
Step 0b — Account for 3D Model Bounding Boxes (MANDATORY for GLB models)
A model's Transform.position is its local origin, NOT its visual extent. Tree and vegetation models commonly extend 6–12 m beyond their origin in one or more directions. Placing a tree at x=2 can cause it to render at x=–10, which is outside the scene bounds.
How to find a model's bounding box — parse the GLB binary and apply node-level transforms. Raw accessor min/max values alone are not reliable because many GLB models have large scale factors or translations baked into the GLTF node hierarchy (e.g. a model whose accessors say 0.6 m but whose node scale is 24× giving an actual rendered size of 14 m).
node -e "
const buf = require('fs').readFileSync('assets/Models/MyModel.glb');
const jsonLen = buf.readUInt32LE(12);
const json = JSON.parse(buf.slice(20, 20+jsonLen));
let minW=[Infinity,Infinity,Infinity], maxW=[-Infinity,-Infinity,-Infinity];
json.nodes?.forEach(n => {
if (n.mesh === undefined) return;
const s = n.scale || [1,1,1];
const t = n.translation || [0,0,0];
for (const prim of json.meshes[n.mesh].primitives) {
const acc = json.accessors[prim.attributes.POSITION];
if (!acc.min || !acc.max) continue;
for (let i = 0; i < 3; i++) {
const lo = acc.min[i]*s[i]+t[i], hi = acc.max[i]*s[i]+t[i];
minW[i] = Math.min(minW[i], lo, hi);
maxW[i] = Math.max(maxW[i], lo, hi);
}
}
});
const w=maxW[0]-minW[0], h=maxW[1]-minW[1], d=maxW[2]-minW[2];
console.log('Rendered size:', w.toFixed(2)+'m x', h.toFixed(2)+'m x', d.toFixed(2)+'m');
console.log('World min:', minW.map(v=>v.toFixed(2)), 'max:', maxW.map(v=>v.toFixed(2)));
"
Measure per model — don't guess or hard-code. Extents vary wildly: running the script above on a typical tree often reveals ~11 m of reach in one horizontal direction from the origin (safe minimum origin z≥12), while a column reaches under 1 m in every direction. Always compute the box for the specific GLB you're placing.
Rule: For every GLB model, compute:
minSafeX = max(0, -bbox.minX) + margin (≥1 m)
minSafeZ = max(0, -bbox.minZ) + margin (≥1 m)
maxSafeX = maxX - (bbox.maxX + margin)
maxSafeZ = maxZ - (bbox.maxZ + margin)
Only place the model if its Transform position satisfies all four bounds.
For tree/vegetation models where the bounding box is unknown, assume a 12 m safe buffer from all edges — i.e., place origins in [12, maxX-12] × [12, maxZ-12].
Examples
| scene.json parcels | parcelsWide | parcelsDeep | Valid X | Valid Z |
|---|---|---|---|---|
["0,0"] |
1 | 1 | 0 – 16 | 0 – 16 |
["0,0","1,0"] |
2 | 1 | 0 – 32 | 0 – 16 |
["0,0","1,0","0,1","1,1"] |
2 | 2 | 0 – 32 | 0 – 32 |
Never change scene.json parcel count without explicit user instruction
Adding parcels to scene.json is not always an option, it depends where the scene will be published to. If publishing to Genesis City, parcels must be owned or rented by the deploying wallet; if publishing to a World, it might be an option. If the scene is currently too small for what the user is asking for, ask the user for confirmation to change the scene layout and include more parcels. If they disagree then work within the existing parcel bounds and make the scene as rich as possible within 16×16m. Do not silently expand the parcel list. If more space is truly needed, ask the user first.
Composite format catalog (reference)
Read {baseDir}/composite-reference.md for the full specification of the main.composite JSON format: the JSON structure and jsonSchema rules, entity ID allocation, the per-component format catalog (core::Transform, core::GltfContainer, … core::NftShape), the component-grouping pattern, edit-mode rules (inspector::Nodes), runtime spawning, and patterns for fetching composite entities from TypeScript.
Validation Checklist
Step 1 — Detect mode. Scan the composite for inspector::*, composite::root, or asset-packs::ActionTypes. If any are present, you are in edit mode — use the edit-mode checklist below. Otherwise use the authoring-from-scratch checklist.
Authoring-from-scratch checklist
Before writing a fresh composite, verify:
-
versionis1 - NO
inspector::*components whatsoever — noinspector::Nodes,inspector::SceneMetadata(any version),inspector::Selection,inspector::TransformConfig,inspector::UIState. These are all auto-generated by the Creator Hub and including them in a fresh file breaks the entity tree or causes build errors. - NO
composite::rootorasset-packs::ActionTypes— auto-generated by engine - Every user entity (512+) has
core::Transformandcore-schema::Name - No duplicate entity IDs across the composite
- No duplicate entity IDs with entities created via code with an explicit ID
-
core::components do NOT havejsonSchema— this is a hard requirement; including jsonSchema on a core:: component will cause the Creator Hub to fail to parse entities correctly - Non-core components (
asset-packs::*,core-schema::*) MUST havejsonSchema(copied from catalog) - All
GltfContainer.srcpaths use slugified name format:assets/asset-packs/<slug>/<filename> - All referenced asset files were downloaded to disk (GLB, audio, images)
- Default collision masks set on GltfContainer (
visibleMeshesCollisionMask: 0,invisibleMeshesCollisionMask: 3) - All positions within parcel bounds — bounds were calculated in Step 0 from the actual
scene.jsonparcel list. Every entity's X is in[0, maxX]and Z is in[0, maxZ]. Negative values and values above maxX/maxZ do not render. If the user requested a "large" scene but parcel count was not changed, all entities fit within the original bounds. - For every
GltfContainerentity: checked whether the GLB contains animations (clip names embedded in the file). If it does, ancore::Animatorcomponent is present on that entity. A model with animations but no Animator will silently loop its first clip with no way to control it. - For every
GltfContainerentity: checked whether the GLB contains collision meshes (any mesh whose name includes the string_collider). If yes,invisibleMeshesCollisionMaskis set to3(CL_POINTER + CL_PHYSICS) to activate them. If no built-in colliders, evaluated whether acore::MeshColliderbox/sphere is needed to cover the model's rough shape (for walkable surfaces, walls, or clickable objects). - If
asset-packs::Actions,asset-packs::Triggers, orasset-packs::Statesexist anywhere in the composite, thenasset-packs::Countermust exist on entity 0, withvalue= the highestidused inside any Actions/Triggers/States data (it is the id allocator for those ids) - No
{self},{assetPath}, or placeholder strings — all resolved to concrete values - Component names use base names (e.g.,
asset-packs::Actions, notasset-packs::Actions-v1). Never use versioned suffixes like-v3. - A composite using only core components needs no extra library. If it contains
asset-packs::*components, older SDKs require@dcl/asset-packsas a project dependency; current SDKs fall back to the copy bundled inside@dcl/inspector
Edit-mode checklist (composite already contains inspector::*)
- The Creator Hub MCP is NOT available. If its tools are in the session, stop — every item below is done for you by
create_entity/set_component(see the gate at the top of this skill and creator-hub-mcp). - The scene is NOT currently open in the Creator Hub. The inspector autosaves and overwrites
main.compositewholesale from its in-memory engine, discarding external edits with no error. Ask the user to close the scene before you write, and to reopen it afterwards.
For every NEW entity <id> you add, in addition to the authoring-from-scratch rules above (with the relaxation that inspector::* etc. are kept, not stripped):
-
<id>has been appended to the end of thechildrenarray of the entity-0entry insideinspector::Nodes.data["0"].json.value. - A new entry
{ "entity": <id>, "children": [...] }has been appended to thevaluearray ofinspector::Nodes.data["0"].json(use[]if the entity has no children of its own). - If
<id>'sTransform.parentis not0, then<id>is in the parent entity'schildrenarray (not the root's). -
core-schema::Name.data["<id>"]has a{ "json": { "value": "..." } }entry — names are required for the entity to appear correctly in the entity tree and to be looked up by code. -
inspector::TransformConfig.data["<id>"]has a{ "json": {} }entry (empty object is fine). -
entity-names.tsis either updated to include the new name (inEntityNames) OR left untouched so the Creator Hub regenerates it on next save. Do NOT hand-edit the auto-generated header. - You did NOT delete or strip pre-existing
inspector::Nodes,inspector::SceneMetadata-*,inspector::Selection,inspector::TransformConfig,composite::root, orasset-packs::ActionTypes. These are managed by the Creator Hub and must stay. -
inspector::SceneMetadata-*is unchanged unlessscene.jsonparcels changed (in which case the layout block must matchscene.json). - Reserved entities
1(PlayerEntity) and2(CameraEntity) are still present ininspector::Nodesas their own top-level{ "entity": 1, "children": [] }/{ "entity": 2, "children": [] }entries. They are separate tree roots — they must NOT appear inside entity0'schildrenarray (or any otherchildrenarray).
Verification command (edit mode): after editing, every entity ID present in core::Transform.data should also appear:
- As a top-level
{ "entity": <id>, ... }entry ininspector::Nodes.data["0"].json.value, AND - In exactly one
childrenarray within that samevaluelist (its parent's children).
Entities 0, 1, and 2 are the exception to rule 2 — they are tree roots and appear in no children array.
Missing entries here are the root cause of "entity renders but is invisible in the Creator Hub entity tree".
Post-Write Validation
After writing the composite, run the SDK build to verify:
npx sdk-commands build
The build must pass with zero errors. If it fails, the composite is invalid. Common errors:
Composite references undefined component "X". Ensure provider.schemas was registered pre-seal via setCompositeProvider().(older/released SDKs word this as"X is not defined and there is no schema to define it") → missingjsonSchemaon non-core component, orinspector::*component that shouldn't be there- TypeScript errors → fix generated scripts