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.
// main-entities.ts — entity only, no PointerEvents
clickable_cube: {
components: {
Transform: { position: { x: 8, y: 1, z: 8 } },
MeshRenderer: { mesh: { $case: 'box', box: { uvs: [] } } }
}
}
// src/index.ts — register clickability via the helper system
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' } },
() => { /* what happens on click */ }
)
}
}
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)
// Add click handler
pointerEventsSystem.onPointerDown(
{
entity: cube,
opts: {
button: InputAction.IA_POINTER, // Left click
hoverText: 'Click me!',
maxDistance: 10
}
},
(event) => {
console.log('Cube clicked!', event.hit?.position)
}
)
All Input Actions
InputAction.IA_POINTER // Left mouse button
InputAction.IA_PRIMARY // E key
InputAction.IA_SECONDARY // F key
InputAction.IA_ACTION_3 // 1 key
InputAction.IA_ACTION_4 // 2 key
InputAction.IA_ACTION_5 // 3 key
InputAction.IA_ACTION_6 // 4 key
InputAction.IA_JUMP // Space key
InputAction.IA_FORWARD // W key
InputAction.IA_BACKWARD // S key
InputAction.IA_LEFT // A key
InputAction.IA_RIGHT // D key
InputAction.IA_WALK // Shift key
All Event Types
PointerEventType.PET_DOWN // Button pressed
PointerEventType.PET_UP // Button released
PointerEventType.PET_HOVER_ENTER // Cursor enters entity
PointerEventType.PET_HOVER_LEAVE // Cursor leaves entity
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) // Invisible box collider — defaults include CL_POINTER
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 // metres
}
},
() => { /* run when the player presses the button within range */ }
)
pointerEventsSystem.onProximityEnter(
{ entity: door, opts: { maxPlayerDistance: 5 } },
() => { /* fired once when the player enters the radius */ }
)
pointerEventsSystem.onProximityLeave(
{ entity: door, opts: { maxPlayerDistance: 5 } },
() => { /* fired once when the player leaves the radius */ }
)
}
maxPlayerDistanceis 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) // or TriggerArea.setSphere(area)
Transform.create(area, {
position: Vector3.create(8, 0, 8),
scale: Vector3.create(4, 4, 4) // Size the area via Transform.scale
})
// Register enter/exit/stay events
triggerAreaEventsSystem.onTriggerEnter(area, (event) => {
console.log('Entity entered trigger:', event.trigger.entity)
})
triggerAreaEventsSystem.onTriggerExit(area, () => {
console.log('Entity exited trigger')
})
triggerAreaEventsSystem.onTriggerStay(area, () => {
// Called every frame while an entity is inside
})
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'
// Area that only reacts to custom layers
TriggerArea.setBox(area, ColliderLayer.CL_CUSTOM1 | ColliderLayer.CL_CUSTOM2)
// Mark a moving entity to activate the area
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:
// 1. Local direction — relative to entity rotation
{ $case: 'localDirection', localDirection: Vector3.Forward() }
// 2. Global direction — world-space, ignores entity rotation
{ $case: 'globalDirection', globalDirection: Vector3.Down() }
// 3. Global target — aim at a world position
{ $case: 'globalTarget', globalTarget: Vector3.create(10, 0, 10) }
// 4. Target entity — aim at another entity
{ $case: 'targetEntity', targetEntity: entityId }
Callback-Based Raycasting (Recommended)
import { raycastSystem, RaycastQueryType, ColliderLayer } from '@dcl/sdk/ecs'
// Local direction raycast
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)
}
}
)
// Global direction raycast
raycastSystem.registerGlobalDirectionRaycast(
{ entity: myEntity, opts: { queryType: RaycastQueryType.RQT_HIT_FIRST, direction: Vector3.Down(), maxDistance: 20 } },
(result) => { /* handle hits */ }
)
// Target position raycast
raycastSystem.registerGlobalTargetRaycast(
{ entity: myEntity, opts: { globalTarget: Vector3.create(8, 0, 8), maxDistance: 20 } },
(result) => { /* handle result */ }
)
// Target entity raycast
raycastSystem.registerTargetEntityRaycast(
{ entity: sourceEntity, opts: { targetEntity: targetEntity, maxDistance: 15 } },
(result) => { /* handle result */ }
)
// Remove raycast from entity
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 // Set true for continuous raycasting
})
// Check results
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(() => {
// Check if E key was just pressed this frame
if (inputSystem.isTriggered(InputAction.IA_PRIMARY, PointerEventType.PET_DOWN)) {
console.log('E key pressed!')
}
// Check if a key is currently held down
if (inputSystem.isPressed(InputAction.IA_SECONDARY)) {
console.log('F key is held!')
}
// Entity-specific input via system
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'
// Check if cursor is locked
const isLocked = PointerLock.get(engine.CameraEntity).isPointerLocked
// Get cursor position and world ray
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
maxDistanceon pointer events (8-16m is typical) - Always set
hoverTextso users know they can interact - Clean up handlers when entities are removed
- Use
MeshColliderfor 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: falseon 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.
微信扫一扫