| name | add-interactivity |
| description | Add click handlers, hover effects, pointer events, trigger areas, raycasting, and global input to Decentraland scene entities. Use when the user wants to make objects clickable, add hover effects, detect player proximity, handle E/F key actions, or cast rays. Do NOT use for advanced input patterns like movement restriction, cursor lock, or WASD control (see advanced-input). Do NOT use for screen-space UI buttons (see build-ui). |
Adding Interactivity to Decentraland Scenes
Authoring split
The clickable entity (cube, button, model) is static — declare it in main-entities.ts with its Transform / Mesh / Material. The clickability itself is always added at runtime in src/index.ts via pointerEventsSystem.onPointerDown(...) (or the related helpers). The helper writes the PointerEvents component AND registers the callback in a single call — do NOT also declare PointerEvents in main-entities.ts; the helper would just overwrite it and the duplication invites drift.
clickable_cube: {
components: {
Transform: { position: { x: 8, y: 1, z: 8 } },
MeshRenderer: { mesh: { $case: 'box', box: { uvs: [] } } }
}
}
import { engine, pointerEventsSystem, InputAction } from '@dcl/sdk/ecs'
export function main() {
const cube = engine.getEntityOrNullByName('clickable_cube')
if (cube) {
pointerEventsSystem.onPointerDown(
{ entity: cube, opts: { button: InputAction.IA_POINTER, hoverText: 'Open' } },
() => { }
)
}
}
TriggerArea and Raycast are also runtime — they live in src/index.ts. Code examples below that create entities inline with engine.addEntity() are for runtime/technical entities (raycast probes, trigger volumes generated from data); for static clickable props, declare the prop in main-entities.ts and attach handlers in src/index.ts as above.
Decision Tree
| Need | Approach | API |
|---|
| Click/hover on a specific entity | Pointer events | pointerEventsSystem.onPointerDown() |
| Detect player entering an area | Trigger area | TriggerArea + triggerAreaEventsSystem |
| Poll key state every frame | Global input | inputSystem.isTriggered() / isPressed() |
| Detect objects in a direction | Raycasting | raycastSystem or Raycast component |
| Read cursor position / lock state | Cursor state | PointerLock, PrimaryPointerInfo |
Pointer Events (Click / Hover)
Using the Helper System (Recommended)
import { engine, Transform, MeshRenderer, pointerEventsSystem, InputAction } from '@dcl/sdk/ecs'
import { Vector3 } from '@dcl/sdk/math'
const cube = engine.addEntity()
Transform.create(cube, { position: Vector3.create(8, 1, 8) })
MeshRenderer.setBox(cube)
pointerEventsSystem.onPointerDown(
{
entity: cube,
opts: {
button: InputAction.IA_POINTER,
hoverText: 'Click me!',
maxDistance: 10
}
},
(event) => {
console.log('Cube clicked!', event.hit?.position)
}
)
All Input Actions
InputAction.IA_POINTER
InputAction.IA_PRIMARY
InputAction.IA_SECONDARY
InputAction.IA_ACTION_3
InputAction.IA_ACTION_4
InputAction.IA_ACTION_5
InputAction.IA_ACTION_6
InputAction.IA_JUMP
InputAction.IA_FORWARD
InputAction.IA_BACKWARD
InputAction.IA_LEFT
InputAction.IA_RIGHT
InputAction.IA_WALK
All Event Types
PointerEventType.PET_DOWN
PointerEventType.PET_UP
PointerEventType.PET_HOVER_ENTER
PointerEventType.PET_HOVER_LEAVE
Pointer Up (Release)
pointerEventsSystem.onPointerDown(
{ entity: cube, opts: { button: InputAction.IA_POINTER, hoverText: 'Hold me' } },
() => { console.log('Pressed!') }
)
pointerEventsSystem.onPointerUp(
{ entity: cube, opts: { button: InputAction.IA_POINTER } },
() => { console.log('Released!') }
)
Removing Handlers
pointerEventsSystem.removeOnPointerDown(cube)
pointerEventsSystem.removeOnPointerUp(cube)
Important: Colliders Required
Pointer events only work on entities with a collider on the CL_POINTER layer. Add one if your entity doesn't have a mesh:
import { MeshCollider } from '@dcl/sdk/ecs'
MeshCollider.setBox(entity)
For GLTF models, set the collision mask:
GltfContainer.create(entity, {
src: 'models/button.glb',
visibleMeshesCollisionMask: ColliderLayer.CL_POINTER
})
Proximity Events (Pointer-Free Triggers)
Like pointerEventsSystem.onPointerDown, but fires based on player distance to the entity instead of a click. Useful for "press E when near" interactions and signposts that highlight on approach. No collider required — the system polls the player position vs the entity transform.
import { engine, pointerEventsSystem, InputAction } from '@dcl/sdk/ecs'
const door = engine.getEntityOrNullByName('shop_door')
if (door) {
pointerEventsSystem.onProximityDown(
{
entity: door,
opts: {
button: InputAction.IA_PRIMARY,
hoverText: 'Open shop',
maxPlayerDistance: 3
}
},
() => { }
)
pointerEventsSystem.onProximityEnter(
{ entity: door, opts: { maxPlayerDistance: 5 } },
() => { }
)
pointerEventsSystem.onProximityLeave(
{ entity: door, opts: { maxPlayerDistance: 5 } },
() => { }
)
}
maxPlayerDistance is required and is measured from the avatar root, not the camera.
priority (number) — if multiple proximity events overlap, the higher value wins.
- Remove with
pointerEventsSystem.removeOnProximityDown(entity) etc.
Prefer proximity events over pointerEventsSystem.onPointerDown when the entity has no visible collider or when the player shouldn't need to aim at it (signs, doors that just open when approached, etc.).
Trigger Areas (Proximity Detection)
Detect when the player enters, exits, or stays inside an area:
import { engine, Transform, TriggerArea } from '@dcl/sdk/ecs'
import { triggerAreaEventsSystem } from '@dcl/sdk/ecs'
import { Vector3 } from '@dcl/sdk/math'
const area = engine.addEntity()
TriggerArea.setBox(area)
Transform.create(area, {
position: Vector3.create(8, 0, 8),
scale: Vector3.create(4, 4, 4)
})
triggerAreaEventsSystem.onTriggerEnter(area, (event) => {
console.log('Entity entered trigger:', event.trigger.entity)
})
triggerAreaEventsSystem.onTriggerExit(area, () => {
console.log('Entity exited trigger')
})
triggerAreaEventsSystem.onTriggerStay(area, () => {
})
By default, trigger areas react to the player layer. Use ColliderLayer to restrict which entities activate the area:
import { ColliderLayer, MeshCollider } from '@dcl/sdk/ecs'
TriggerArea.setBox(area, ColliderLayer.CL_CUSTOM1 | ColliderLayer.CL_CUSTOM2)
const mover = engine.addEntity()
Transform.create(mover, { position: Vector3.create(8, 0, 8) })
MeshCollider.setBox(mover, ColliderLayer.CL_CUSTOM1)
Raycasting
Raycast Direction Types
Four direction modes are available:
{ $case: 'localDirection', localDirection: Vector3.Forward() }
{ $case: 'globalDirection', globalDirection: Vector3.Down() }
{ $case: 'globalTarget', globalTarget: Vector3.create(10, 0, 10) }
{ $case: 'targetEntity', targetEntity: entityId }
Callback-Based Raycasting (Recommended)
import { raycastSystem, RaycastQueryType, ColliderLayer } from '@dcl/sdk/ecs'
raycastSystem.registerLocalDirectionRaycast(
{ entity: myEntity, opts: { queryType: RaycastQueryType.RQT_HIT_FIRST, direction: Vector3.Forward(), maxDistance: 16, collisionMask: ColliderLayer.CL_POINTER } },
(result) => {
if (result.hits.length > 0) {
console.log('Hit:', result.hits[0].entityId)
}
}
)
raycastSystem.registerGlobalDirectionRaycast(
{ entity: myEntity, opts: { queryType: RaycastQueryType.RQT_HIT_FIRST, direction: Vector3.Down(), maxDistance: 20 } },
(result) => { }
)
raycastSystem.registerGlobalTargetRaycast(
{ entity: myEntity, opts: { globalTarget: Vector3.create(8, 0, 8), maxDistance: 20 } },
(result) => { }
)
raycastSystem.registerTargetEntityRaycast(
{ entity: sourceEntity, opts: { targetEntity: targetEntity, maxDistance: 15 } },
(result) => { }
)
raycastSystem.removeRaycasterEntity(myEntity)
Component-Based Raycasting
import { engine, Raycast, RaycastResult, RaycastQueryType } from '@dcl/sdk/ecs'
import { Vector3 } from '@dcl/sdk/math'
const rayEntity = engine.addEntity()
Raycast.create(rayEntity, {
direction: { $case: 'localDirection', localDirection: Vector3.Forward() },
maxDistance: 16,
queryType: RaycastQueryType.RQT_HIT_FIRST,
continuous: false
})
engine.addSystem(() => {
const result = RaycastResult.getOrNull(rayEntity)
if (result && result.hits.length > 0) {
const hit = result.hits[0]
console.log('Hit entity:', hit.entityId, 'at', hit.position)
}
})
Camera Raycast
Cast a ray from the camera to detect what the player is looking at:
raycastSystem.registerGlobalDirectionRaycast(
{
entity: engine.CameraEntity,
opts: {
direction: Vector3.rotate(Vector3.Forward(), Transform.get(engine.CameraEntity).rotation),
maxDistance: 16
}
},
(result) => {
if (result.hits.length > 0) console.log('Looking at:', result.hits[0].entityId)
}
)
Global Input Handling
Listen for key presses anywhere (not entity-specific):
import { inputSystem, InputAction, PointerEventType } from '@dcl/sdk/ecs'
engine.addSystem(() => {
if (inputSystem.isTriggered(InputAction.IA_PRIMARY, PointerEventType.PET_DOWN)) {
console.log('E key pressed!')
}
if (inputSystem.isPressed(InputAction.IA_SECONDARY)) {
console.log('F key is held!')
}
const clickData = inputSystem.getInputCommand(
InputAction.IA_POINTER,
PointerEventType.PET_DOWN,
myEntity
)
if (clickData) {
console.log('Entity clicked via system:', clickData.hit.entityId)
}
})
Cursor State
import { PointerLock, PrimaryPointerInfo } from '@dcl/sdk/ecs'
const isLocked = PointerLock.get(engine.CameraEntity).isPointerLocked
const pointerInfo = PrimaryPointerInfo.get(engine.RootEntity)
console.log('Cursor position:', pointerInfo.screenCoordinates)
console.log('World ray direction:', pointerInfo.worldRayDirection)
Toggle Pattern (Click to Switch States)
Common pattern for toggleable objects:
let doorOpen = false
pointerEventsSystem.onPointerDown(
{ entity: door, opts: { button: InputAction.IA_POINTER, hoverText: 'Toggle door' } },
() => {
doorOpen = !doorOpen
const mutableTransform = Transform.getMutable(door)
mutableTransform.rotation = doorOpen
? Quaternion.fromEulerDegrees(0, 90, 0)
: Quaternion.fromEulerDegrees(0, 0, 0)
}
)
Best Practices
- Always set
maxDistance on pointer events (8-16m is typical)
- Always set
hoverText so users know they can interact
- Clean up handlers when entities are removed
- Use
MeshCollider for invisible trigger surfaces
- For complex interactions, use a system with state tracking
- Test interactions in preview — hover text should be visible and clear
- Set
continuous: false on raycasts unless you need per-frame results
- Design for both desktop and mobile — mobile has no keyboard, rely on pointer and on-screen buttons
For the full input action list and advanced patterns, see {baseDir}/references/input-reference.md.