Migrate a Decentraland SDK6 Scene to SDK7
This is a porting skill, not a scaffolding skill. It assumes an existing SDK6 project on disk. For a brand-new SDK7 scene, use [[create-scene]].
RULE: Verify it really is SDK6 before doing anything
Inspect these files in this order. ALL of the SDK6 signals must be present before treating the project as SDK6 — partial matches usually mean a half-migrated project that needs a different approach.
| Check | SDK6 signal | SDK7 signal |
|---|---|---|
package.json dependencies |
decentraland-ecs |
@dcl/sdk |
package.json scripts |
build-ecs, dcl start |
sdk-commands start, sdk-commands build |
scene.json |
No runtimeVersion, or ecs7 missing |
"runtimeVersion": "7" and/or "ecs7": true |
| Source code imports | No explicit @dcl/sdk/ecs import — symbols (Entity, Transform, Vector3, engine) are globals |
Most things imported from @dcl/sdk/ecs and @dcl/sdk/math |
| Source code patterns | new Entity(), entity.addComponent(...), @Component('name'), class X implements ISystem, OnPointerDown, GLTFShape, Input.instance |
engine.addEntity(), Transform.create(entity, ...), engine.defineComponent(...), pointerEventsSystem.onPointerDown, GltfContainer |
RULE: Do NOT change scene.json's main field
Keep the main value the SDK6 project already had (typically "bin/game.js"). The SDK7 default for new scenes is bin/index.js, but sdk-commands build writes the bundle to whatever path scene.json main names (verified: sdk-commands bundle.ts sets the output file from sceneJson.main) — so an existing bin/game.js keeps working with no changes. Do NOT rename it to bin/index.js unless the user explicitly asks.
You SHOULD add "runtimeVersion": "7" to scene.json (and remove anything ECS6-specific). See [[create-scene]] for the full scene.json schema.
RULE: The composite-first rule applies to migrations too
After migration, all static scenery (models, lights, primitives, text placed at scene load) belongs in assets/scene/main.composite, NOT in TypeScript. SDK6 scenes have no composite — every entity is created in code. Migrating those into a composite is a separate, optional pass; the minimum-viable migration leaves them as engine.addEntity() in TypeScript so the scene runs first.
Order of operations:
- Get the SDK7 TypeScript port running (entities still created in code).
- THEN, optionally, move static entities into
assets/scene/main.composite. See [[create-scene]] for the composite-first rule and [[composites]] for the format.
The Conceptual Shift — read this before writing any code
SDK6 modeled entities as objects with components attached to them. SDK7 is a true ECS where entities are just opaque IDs and components are pure data.
| Concept | SDK6 | SDK7 |
|---|---|---|
| Entity creation | const e = new Entity() then engine.addEntity(e) |
const e = engine.addEntity() (returns the ID) |
| Entity type | IEntity / Entity class with methods (addComponent, getComponent, setParent, alive) |
Entity = opaque integer ID, NO methods |
| Adding a component | entity.addComponent(new Transform({...})) — entry point is the entity |
Transform.create(entity, {...}) — entry point is the component type |
| Defining a component | @Component('name') class GemData { val: number; constructor(...) {...} } |
export const GemData = engine.defineComponent('gemData', { val: Schemas.Number }, { val: 2 }) — schema + defaults, NO methods |
Component methods (reset(), etc.) |
Allowed (class methods) | Forbidden — components are data only. Move methods into free functions or systems |
| Reading component data | entity.getComponent(GemData).val (always mutable) |
GemData.get(entity).val (immutable, fast) OR GemData.getMutable(entity).val = X (only when writing) |
| Modifying component data | Mutate the returned reference | MUST use getMutable() — .get() returns a read-only reference. Using .get() and mutating it silently fails |
| Parenting | child.setParent(parent) |
Set parent field of child's Transform: Transform.create(child, { parent: parentEntity }) or Transform.getMutable(child).parent = parentEntity |
| Defining a system | class MoveGems implements ISystem { update(dt) {...} } then engine.addSystem(new MoveGems()) |
export function moveGemsSystem(dt: number) { ... } then engine.addSystem(moveGemsSystem) |
| Querying entities | const group = engine.getComponentGroup(Transform, GemData) then group.entities |
for (const [entity, gemData, transform] of engine.getEntitiesWith(GemData, Transform)) { ... } |
| Removing an entity | engine.removeEntity(entity) |
engine.removeEntity(entity) (same API) |
| Event callbacks on components | entity.addComponent(new OnPointerDown((e) => {...})) |
pointerEventsSystem.onPointerDown({entity, opts}, () => {...}) — separated from the component |
| Static "scene-level" objects | Camera.instance, Input.instance (singletons) |
engine.PlayerEntity, engine.CameraEntity, engine.RootEntity + component reads (Transform.get(engine.PlayerEntity)) |
Mutability is a performance contract, not just style. Always prefer .get() (read) over .getMutable() (write). Using .getMutable() when you only read causes a CRDT delta to be sent every frame.
API Mapping — quick reference
See {baseDir}/references/api-mapping.md for the full table. The most common conversions:
| SDK6 | SDK7 |
|---|---|
import { ... } from 'decentraland-ecs' (often implicit) |
import { engine, Transform, GltfContainer, Vector3, Quaternion, Color4 } from '@dcl/sdk/ecs' + '@dcl/sdk/math' |
new Entity() + engine.addEntity(entity) |
const entity = engine.addEntity() |
entity.addComponent(new Transform({position, rotation, scale})) |
Transform.create(entity, { position, rotation, scale }) |
entity.getComponent(Transform) (read/write) |
Transform.get(entity) (read) / Transform.getMutable(entity) (write) |
entity.getComponentOrCreate(Transform) |
Transform.getOrCreateMutable(entity) |
entity.addComponentOrReplace(...) |
Component.createOrReplace(entity, {...}) |
entity.hasComponent(Transform) |
Transform.has(entity) |
entity.removeComponent(Transform) |
Transform.deleteFrom(entity) |
child.setParent(parent) |
Transform.getMutable(child).parent = parent (or pass parent in create) |
new GLTFShape('models/x.glb') + entity.addComponent(shape) |
GltfContainer.create(entity, { src: 'models/x.glb' }) |
new BoxShape() / new PlaneShape() / new SphereShape() / new CylinderShape() |
MeshRenderer.setBox(entity) / setPlane(entity) / setSphere(entity) / setCylinder(entity) (add MeshCollider.setBox(entity) etc. for collision) |
new Material() + m.albedoColor = Color3.Blue() + entity.addComponent(m) |
Material.setPbrMaterial(entity, { albedoColor: Color4.Blue() }) (note: Color4 in SDK7) |
new BasicMaterial() + m.texture = new Texture(...) |
Material.setBasicMaterial(entity, { texture: Material.Texture.Common({ src }) }) |
new TextShape(text) + t.fontSize = 1 |
TextShape.create(entity, { text, fontSize: 1 }) |
new Animator() + new AnimationState('Clip') + animator.addClip(state) |
Animator.create(entity, { states: [{ clip: 'Clip', playing: false, loop: false }] }); play with Animator.playSingleAnimation(entity, 'Clip'); stop with Animator.stopAllAnimations(entity) |
new OnPointerDown(handler, { button, hoverText }) on the entity |
pointerEventsSystem.onPointerDown({ entity, opts: { button: InputAction.IA_POINTER, hoverText } }, handler) |
Input.instance.subscribe('BUTTON_DOWN', ActionButton.POINTER, false, handler) |
inputSystem.isTriggered(InputAction.IA_POINTER, PointerEventType.PET_DOWN) inside a system, or pointerEventsSystem.onPointerDown for entity-specific |
ActionButton.POINTER / .PRIMARY / .SECONDARY |
InputAction.IA_POINTER / .IA_PRIMARY / .IA_SECONDARY |
Camera.instance.position / .rotation |
Transform.get(engine.CameraEntity).position / .rotation |
new Vector3(x, y, z) |
Vector3.create(x, y, z) |
Quaternion.Euler(x, y, z) |
Quaternion.fromEulerDegrees(x, y, z) |
Color3.Blue() |
Color4.Blue() (most SDK7 component fields use Color4) |
Vector3.Lerp(a, b, t) |
Vector3.lerp(a, b, t) (lowercase l) |
Scalar.Lerp(a, b, t) |
Scalar.lerp(a, b, t) |
vector.setAll(0.5) |
Vector3.create(0.5, 0.5, 0.5) (no mutating helper — Vector3 is treated as plain data) |
class X implements ISystem { update(dt) {...} } + engine.addSystem(new X()) |
function xSystem(dt: number) { ... } + engine.addSystem(xSystem) |
engine.getComponentGroup(A, B) returning .entities |
engine.getEntitiesWith(A, B) returning iterable of [entity, a, b] tuples |
log(...) |
console.log(...) |
entity.addComponent(new utils.Delay(ms, cb)) (from decentraland-ecs-utils / @dcl-sdk/utils) |
timers.setTimeout(cb, ms) (import { timers } from '@dcl/sdk/ecs', argument order is (callback, ms)) |
entity.addComponent(new utils.Interval(ms, cb)) |
timers.setInterval(cb, ms) (import { timers } from '@dcl/sdk/ecs') |
Custom per-frame timerSystem accumulating dt |
Delete it — use timers.setTimeout / timers.setInterval instead |
Migration Workflow
Do this in order. Skipping steps leads to a broken scene that's hard to debug.
1. Audit the SDK6 project
Read every source file. Build a list of:
- Every custom
@Componentclass — these becomeengine.defineComponent(...)with a flat schema. Class methods (reset(), etc.) must be lifted out into free functions or systems. - Every
class X implements ISystem— these become free functions(dt: number) => void. - Every
OnPointerDown/OnPointerUp/Input.instance.subscribe— these becomepointerEventsSystemcalls orinputSystem.isTriggered()in a system. - Every
GLTFShape,BoxShape, etc. — see mapping table. - Every
Camera.instance,Input.instance— these become static engine entities + component reads. - All
setParentcalls — these becomeTransform.parentfield assignments.
This list is the completion checklist for steps 5–10 — each of those steps is done only when every item in its category has been ported and its SDK6 symbols no longer appear in the source.
2. Replace package.json, tsconfig.json, and update scene.json
- Replace
decentraland-ecswith@dcl/sdk(latest). - Replace
build-ecs/dcl startscripts withsdk-commands start/sdk-commands build. - Replace the old
tsconfig.jsonwith the standard SDK7 one (target: ES2020,module: ESNext,jsx: react-jsx,strict: true). - Add
"runtimeVersion": "7"toscene.json. Do NOT modifymainunless the user asks.
3. Reorganize asset folders under assets/
SDK6 scenes commonly kept assets in top-level folders at the project root: models/, images/, audio/ (or sounds/), textures/, videos/. SDK7 + Creator Hub expects assets under a top-level assets/ folder so the visual editor can index them. Do this move as a discrete step BEFORE porting any code — that way the path rewrites in later steps can target the final locations directly.
- Identify every top-level asset folder in the SDK6 project. Common SDK6 names:
models/,images/,audio/,sounds/,textures/,videos/. - Create the new structure under
assets/using the capitalized category convention used elsewhere in these skills (see [[create-scene]], [[add-3d-models]], [[audio-video]]):models/→assets/Models/images/→assets/Images/audio/orsounds/→assets/Audio/videos/→assets/Videos/textures/→assets/Images/(textures are images; consolidate unless the user wants a separate folder)
- Move files into the new structure. Do NOT leave duplicate copies — delete the old top-level folders once the move is complete. Dual copies cause Creator Hub to index stale paths and roughly double scene size on deploy (both copies get bundled).
- Rewrite every code reference to the moved paths. Grep the entire source tree for the old folder names and update each hit:
GltfContainer.create(e, { src: 'models/chair.glb' })→... { src: 'assets/Models/chair.glb' }AudioSource.create(e, { audioClipUrl: 'sounds/click.mp3' })→... 'assets/Audio/click.mp3'Material.Texture.Common({ src: 'textures/logo.png' })→... 'assets/Images/logo.png'- React-ECS
<UiEntity uiBackground={{ texture: { src: 'images/icon.png' } }} />→... 'assets/Images/icon.png' - Any string literal mentioning the old folder name anywhere in code, JSON, or
.compositefiles.
- Done when: grepping the project for the old folder names returns zero remaining references, and opening the scene in Creator Hub shows the asset tree populated from
assets/.
Exception — reuse existing layout if present. If the project already contains assets/scene/Models/ (the legacy Creator Hub layout) or assets/asset-packs/ / assets/custom/ (Creator Hub adds these for asset-pack items and custom items), reuse those paths instead of creating a parallel assets/Models/. The rule is: one canonical location per asset type — don't fragment.
4. Create src/index.ts with an exported main()
SDK7 scenes start from a top-level exported main() function. The convention is to put most code in another file (e.g. src/game.ts) and have index.ts just call into it:
import { initializeGame } from "./game";
export function main() {
initializeGame();
}
5. Port components first
Convert each @Component class to engine.defineComponent(name, schema, defaults). Flatten nested types — Schemas does not support arbitrary classes, but does support Schemas.Vector3, Schemas.Quaternion, primitives, arrays, and nested Schemas.Map(...). A Vector2 field in SDK6 typically becomes two scalar fields (posX, posY) in SDK7 — see the 2048 example in {baseDir}/references/migration-example.md.
Done when: grep -rn "@Component(" src/ returns zero hits and every component on the step-1 audit list has a corresponding engine.defineComponent call.
6. Port systems
Each class X implements ISystem { update(dt) {...} } becomes a free function. Replace engine.getComponentGroup(A, B).entities iteration with for (const [entity, a, b] of engine.getEntitiesWith(A, B)). Inside the loop, use .getMutable(entity) only when writing.
Done when: grep -rnE "implements ISystem|getComponentGroup" src/ returns zero hits and every system on the step-1 audit list is registered via engine.addSystem.
7. Port the entity/component setup (the bulk of game.ts)
For each new Entity() block, replace with engine.addEntity() and a series of Component.create(entity, ...) calls. Convert setParent to a parent field on the Transform. Use the new assets/Models/..., assets/Audio/..., assets/Images/... paths established in step 3 for every src / audioClipUrl / texture.src.
Done when: grep -rnE "new Entity\(|\.addComponent\(|\.setParent\(" src/ returns zero hits, and no src / audioClipUrl / texture path still references a pre-step-3 folder.
8. Port input/pointer handlers
- Per-entity click handlers (
new OnPointerDown(...)) →pointerEventsSystem.onPointerDown({ entity, opts }, handler). Add a collider if the entity doesn't already have one (MeshCollider.setBox(entity)orvisibleMeshesCollisionMask: 1onGltfContainer). See [[add-interactivity]]. - Global key subscriptions (
Input.instance.subscribe) → useinputSystem.isTriggered(InputAction.IA_X, PointerEventType.PET_DOWN)inside a system. See [[advanced-input]].
Done when: grep -rnE "OnPointerDown|OnPointerUp|Input\.instance|ActionButton\." src/ returns zero hits and every handler on the step-1 audit list has a pointerEventsSystem or inputSystem counterpart.
9. Port animations
new Animator() + new AnimationState('clip') + animator.addClip(...) becomes a single Animator.create(entity, { states: [{ clip, playing, loop }] }). Play with Animator.playSingleAnimation(entity, 'clip'). Stop all with Animator.stopAllAnimations(entity). See [[animations-tweens]].
Done when: grep -rnE "new Animator|AnimationState|addClip|getClip" src/ returns zero hits, and every clip name that appears in an SDK6 playAnimation / getClip call is listed in some states[] array — clips missing from states[] fail silently (see the playSingleAnimation pitfall in Common Pitfalls).
10. Port sounds
new AudioClip(url) + entity.addComponent(new AudioSource(clip)) becomes AudioSource.create(entity, { audioClipUrl: url, playing: false, ... }). Play by toggling AudioSource.getMutable(entity).playing = true. See [[audio-video]].
Done when: grep -rnE "new AudioClip|new AudioSource\(" src/ returns zero hits.
11. Verify and test
Run npm install to fetch the new SDK, then sdk-commands start (or npm start). The migration is complete only when ALL of these hold:
npm run buildexits 0.- A final sweep
grep -rnE "decentraland-ecs|@Component\(|implements ISystem|Input\.instance|Camera\.instance" src/returns zero hits — per-step greps get missed when work is interrupted and resumed; this is the end-to-end check. - The preview loads with no errors in the console.
- The visible layout (positions/rotations) matches the SDK6 scene.
If any check fails, return to the step that owns that symbol category — do NOT report the migration as done.
Common Pitfalls
- Mutating a
.get()result:Transform.get(entity).position.x = 5is silently a no-op. UseTransform.getMutable(entity).position.x = 5. - Passing explicit
rotation: undefined/scale: undefinedtoTransform.createcrashes the serializer: symptom is"Cannot read properties of undefined (reading 'x')"at serialization. Why (verified —@dcl/ecs/dist/components/manual/Transform.js):Transform.createruns the value throughTransformSchema.extend, which spreads...valueAFTER its defaults, so an explicitundefinedfield overrides the default identity rotation / unit scale and is stored asundefined; the binaryserializethen readsvalue.rotation.xand throws. This bites when a port builds a Transform from a partial object that may carryundefinedfields (e.g.Transform.create(e, { position, rotation: maybeRot, scale: maybeScale })). Fix — omit the field entirely instead of passingundefinedso the schema applies its default:const out: Partial<TransformType> = { position } if (rotation) out.rotation = rotation if (scale) out.scale = scale if (parent !== undefined) out.parent = parent Transform.create(entity, out) - Forgetting the schema:
engine.defineComponentREQUIRES a schema. SDK6 class fields with no type cannot be auto-inferred. - Component methods: SDK6 components frequently had a
reset()or constructor logic. These MUST be moved to free functions in SDK7 — components are pure data. - Vector2 fields:
Schemasdoes NOT haveSchemas.Vector2. [UNVERIFIED — confirm by reading the Schemas object exports.] Flatten to twoSchemas.Numberfields, or useSchemas.Map({ x: Schemas.Number, y: Schemas.Number }). setParenton the entity object: Entities are integers in SDK7 — there is no method. Set theparentfield on the child'sTransform.- Color3 vs Color4: Most SDK7 component fields take
Color4(with alpha). ReplaceColor3.X()calls withColor4.X()unless the field is documented as Color3. - Materials are not entities or objects: There is no
new Material()instance you keep a reference to.Material.setPbrMaterial(entity, props)is the only way to set it — a material lives on one entity, not as a shared object. - Shape entities (BoxShape, PlaneShape, etc.) need MeshRenderer AND MeshCollider: In SDK6 a single
BoxShapedid both render and collide (with a flag). In SDK7 these are two separate components — add both if you need clicks/physics on a primitive. - GLTFShape had its visible mesh clickable by default with
withCollisions: true: In SDK7,GltfContainerby default only has collisions on the invisible _collider mesh. Some models may not be ready for that. If an entity is no longer clickable from a lack of collider, it may need explicitvisibleMeshesCollisionMaskset to a value includingCL_POINTER(1) for clicks, orCL_PHYSICS(2) for player collision, or3for both. See [[add-3d-models]]. OnPointerDowncallbacks no longer fire inside aforEachover a component group: In SDK6 they were event-driven; in SDK7 you register the handler once withpointerEventsSystem.onPointerDown(...)— don't put that call inside a system's update loop or you'll register a new handler every frame.Animator.playSingleAnimationsilently no-ops on clips not inAnimator.states(porters who built the Animator in code): this applies specifically to the SDK6-port path where you author theAnimatoryourself in TypeScript and callAnimator.playSingleAnimation(entity, clipName)programmatically. In that case,playSingleAnimationreturnsfalseand does nothing ifclipNameisn't already present in the entity'sstatesarray (verified —@dcl/ecs/dist-cjs/components/extended/Animator.jslines 35-46). No warning. SDK6 hid this —Animator.getClip(name)auto-created the clip on first use, so SDK6 scenes routinely callplayAnimation('Appear')without declaring'Appear'upfront. A naive port that mapsentity.getComponent(Animator).getClip(name).play()→Animator.playSingleAnimation(entity, name)ends up calling it against clip names the porter never added tostates[], and those calls silently fail. Fix: walk everyplayAnimation/getClipcall in the SDK6 source, collect the full set of clip names per entity, and list them all in thestatesarray atAnimator.createtime. For a quick port-friendly shim that restores SDK6's "play any clip, no setup" ergonomics with a lazystates.push, see the wrapper in [[animations-tweens]] (PITFALL section). This rule does not apply to scenes whose Animator was written by the Creator Hub Inspector (which pre-populatesstates[]with every GLB clip) or by asset packs / smart items (which ship withstatespopulated), nor to models with noAnimatorat all — those rely on the autoplay path in the next bullet.- Unnecessary serialization overhead when porting
anim.stop(); anim.play()to a hand-rolled SDK7 helper called every frame: applies when you replace SDK6'sanim.stop(); anim.play()with a custom SDK7 helper that directly mutatesAnimator.states(s.playing,s.shouldReset) AND that helper is invoked from a per-frame caller — a system update, or an input/raycast callback that fires every tick the input is held. The animation will NOT freeze (the CRDT unchanged-mutable suppression layer in@dcl/ecsdetects identical writes and silently drops them), but callinggetMutableOrNull()every tick incurs unnecessary serialization + byte-comparison overhead each frame. Fix: track the last-applied clip per entity and short-circuit the helper when unchanged; on anoLoop + revertToIdlere-call with the same clip, only refresh the revert timer instead of re-mutating states; clear the tracked clip before re-applying the idle clip from the revert callback. Does NOT apply when the helper is called only on a state transition (one-shotonPointerDown, edge-triggered state machine), nor when you useAnimator.playSingleAnimationfrom a transition rather than a per-frame caller. See the "Short-circuit clip-switch helpers" best practice in [[animations-tweens]] for the full helper pattern. GltfContainerautoplays one clip from a multi-animation .glb when noAnimatoris attached: SDK6 models stayed in their bind pose by default; SDK7 picks one clip from the .glb and plays it. This is the same mechanism that lets clip-less Inspector scenes work without registration — it's the complement to the previous bullet, not a contradiction. When the entity has noAnimator, the renderer autoplays; when the entity has anAnimatorand you callplaySingleAnimation, the clip must be instates[]. Which clip the renderer autoplays is not stable across models — observed cases include a Halloween scene where small ghosts with noAnimatorautoplayeddiestraight from the GLB. Fix when you want a different default: attach anAnimator.create(entity, { states: [{ clip: '<intended-default>', playing: true, loop: true }] })to take control. Fix when you also want to switch clips at runtime viaplaySingleAnimation: list every clip you'll switch to instates[](see previous bullet).Input.instance.subscribewithBUTTON_DOWN/BUTTON_UP: there is no direct callback equivalent. UseinputSystem.isTriggered()inside a system for "just pressed this frame".@dcl/ecs-scene-utilstrigger areas (the "Utils library") AND hand-rolled per-frame AABB checks: do NOT port the custom per-frame position-checking system, and do NOT recreate it from scratch in SDK7. SDK6 had no native trigger area, so the community Utils library (and many scenes' bespoke code) polledCamera.instance.positionagainst a box/sphere region every frame and firedonCameraEnter/onCameraExit. SDK7 has a nativeTriggerAreacomponent (@dcl/sdk/ecs) — replace the entire pattern withTriggerArea.setBox(entity)(or.setSphere) +triggerAreaEventsSystem.onTriggerEnter(entity, cb)/.onTriggerExit/.onTriggerStay. Size comes fromTransform.scale, position fromTransform.position, and the parent chain is respected — so triggers parented to a rotated/offset scene root Just Work without manual coordinate transforms. Behavior parity: the Utils library only ever fired for the local player; nativeTriggerAreadefaults toColliderLayer.CL_PLAYER, which fires for ANY player on that layer. The correct guard for local-player-only behavior isif (result.trigger?.entity !== engine.PlayerEntity) return(NOTresult.triggeredEntity— despite the confusing name,triggeredEntityis the trigger area's own entity, so comparing it to the player is always true and the guard never fires). See the "Trigger areas" section of{baseDir}/references/api-mapping.mdand [[add-interactivity]] for the full component reference.utils.Delay/utils.ExpireInfromdecentraland-ecs-utils(later@dcl-sdk/utils) AND hand-rolled per-frame timer systems: do NOT port the communityDelaycomponent, do NOT recreate it in SDK7, and do NOT write a customsetSceneTimeouthelper backed by atimerSystemthat accumulatesdt. Use thetimersnamed export from@dcl/sdk/ecs(import { timers } from '@dcl/sdk/ecs') — it providessetTimeout/clearTimeout/setInterval/clearIntervalbound to the scene engine. Replaceentity.addComponent(new utils.Delay(2000, cb))withtimers.setTimeout(cb, 2000)and delete the "timer entity" the SDK6 scene created just to host the Delay component. Argument order is(callback, ms), NOT(ms, callback)— the SDK6Delay(ms, cb)order is the opposite of the JS standard, and custom helpers written with the SDK6 order are a known footgun that compounds the original mistake of writing the helper in the first place. Do NOT use the native JSsetTimeout/setIntervalglobals — although QuickJS exposes them, they are not bound to the scene engine and can introduce subtle problems; always go throughtimers. See [[scene-runtime]] (Timers section) for the full reference.- SDK6 "intercepting tool" smart-items (keys, magnets, inventory items that act on a clicked target via
Input.instance.subscribe('BUTTON_DOWN', ActionButton.POINTER, true, …)+ entity-hierarchy walking) have no direct SDK7 equivalent.pointerEventsSystem.onPointerDownis per-entity only — there is no global hit-result subscriber. The migration pattern is a small registry keyed by target entity name; the target's ownpointerEventsSystem.onPointerDownconsults the registry before falling back to its default behavior. See{baseDir}/references/api-mapping.md#click-interception-across-entities-keytool-acting-on-a-target. Vector3.GetAngleBetweenVectors,Vector3.Up()etc.: many SDK6 Vector3 helpers existed as static methods. SDK7 keeps most under@dcl/sdk/mathbut capitalization changed (e.g.Vector3.Lerp→Vector3.lerp). Grep forVector3.after migration and verify each call against the math module.log()is not exported in SDK7: replace withconsole.log().Attachable.FIRST_PERSON_CAMERA/Attachable.AVATARon held items (guns, aiming reticles, flashlights, fixed-position tools): SDK6 only offered two coarse follow modes —Attachable.FIRST_PERSON_CAMERA(follow the camera) andAttachable.AVATAR(follow the avatar root). In SDK7 you have a new choice that didn't exist before: bone-levelAvatarAttachwithanchorPointId(e.g.AAPT_RIGHT_HAND). The bone anchor looks like the natural SDK7 equivalent of "put it in the avatar's hand", but bone anchors inherit avatar animation (idle bob, walk cycle, gestures) — a gun attached toAAPT_RIGHT_HANDjitters every frame and is unaimable. The correct SDK7 mapping for held gameplay items is to parent the entity viaTransform.parent, notAvatarAttach. For any aim-sensitive item (gun, reticle, flashlight, anything the player should be able to point by looking around), the right SDK7 default isTransform.parent = engine.CameraEntity— this mirrors SDK6'sFIRST_PERSON_CAMERAand tracks both yaw AND pitch, so the muzzle aims where the camera looks.Transform.parent = engine.PlayerEntityis a different choice and a common subtle failure mode: it follows the player root (feet position + body yaw only, no pitch and no animation), so a gun parented toPlayerEntitystays flat when the player tilts the camera up to aim — the item ignores the look direction. UsePlayerEntityonly for body-fixed items that should NOT track aim (a held shield, a static torch, a non-aimed inventory item carried at hip-level). ReserveAvatarAttachfor cosmetic items where riding the animation is desired (hats, halos, backpacks, name plates). Picking by intent —engine.CameraEntity(aim-sensitive, the recommended default for held weapons),engine.PlayerEntity(yaw-only / no pitch, for body-fixed items),AvatarAttach(boneId)(cosmetic only, animates with the bone). See the "Held items vs cosmetic items" section of [[player-avatar]] for the comparison table and a worked gun example.- Billboarded models may appear facing AWAY from the camera after porting: observed in some SDK6 → SDK7 ports — a model that displayed its front face under SDK6's
new Billboard(false, true, false)(Y-axis billboard) shows it
…(truncated)