agentleFS
decentralanddecentraland/sdk-skills/advanced-input/SKILL.md

advanced-input

Skill4 starsChanged 9 days agoApache-2.0View on GitHub

System-level input polling, player movement control, platform and language detection, and mobile on-screen controls in Decentraland. Covers inputSystem, InputModifier, PointerLock, PrimaryPointerInfo, getPlatform/getPlayerLanguage, and TouchScreenControls. Use when the user wants continuous key polling, WASD-controlled entities, to freeze the player during a cutscene, FPS-style cursor lock, multi-key combo patterns, mobile-specific behavior, localized text in the player's language, or to hide/customize the mobile joystick, crosshair, or on-screen buttons. For event-driven clicks and hover on entities see add-interactivity.

The file

We split this skill into the rules it gives an agent

5 rules. About 0 are common, 2 are distinctive, and 3 are very specific to this project.

  • ≈0

    Common · estimate

    Many files like this have a rule of this kind.

  • ≈2

    Distinctive · estimate

    This author's own choices. Worth a look.

  • 3

    Very specific

    Tied to their project's names, files or tools. Probably skip.

  • Common
  • Distinctive
  • Very specific
  1. Distinctive:

    Check if a specific input action occurred on a specific entity:

    line 90
  2. Distinctive:

    Check if a specific key was pressed, regardless of if the player's cursor was pointing at an entity or not.

    line 119
  3. Very specific:

    Use isTriggered() for one-shot actions (fire a weapon, open a door) — it returns true only on the frame the key is first pressed.

    line 123
  4. Very specific:

    Requires @dcl/sdk 7.30.0+: Check the scene's pin before using it on an older project.

    line 240
  5. Very specific:

    Touch only: no hover states, keyboard shortcuts, right-click or mouse movement (screenDelta stays 0).

    line 347

≈ “Common” is an estimate: at least 1 in 5 of the 350,495 skill files we read have a rule of the same kind, not the same words. “Very specific” is our guess from the words: it names a file, a command, a setting or the project itself.

Keep what fits and drop the rest. Your version, ready for your agents.
Files
SKILL.md

Advanced Input Handling in Decentraland

Pointer Lock State

Detect whether the cursor is captured (first-person mode) or free:

import { engine, PointerLock } from '@dcl/sdk/ecs'

function checkPointerLock() {
  const isLocked = PointerLock.get(engine.CameraEntity).isPointerLocked

  if (isLocked) {
    // Cursor is captured — player is in first-person control
  } else {
    // Cursor is free — player can click UI elements
  }
}

engine.addSystem(checkPointerLock)

Requesting / releasing pointer lock (writable)

PointerLock.isPointerLocked is a plain writable boolean — a scene can request or release cursor capture by mutating it, from a click handler or a system:

PointerLock.createOrReplace(engine.CameraEntity, { isPointerLocked: false })
// request lock (e.g. from a button)
PointerLock.getMutable(engine.CameraEntity).isPointerLocked = true
// release lock
PointerLock.getMutable(engine.CameraEntity).isPointerLocked = false

PITFALL: getMutable(engine.CameraEntity) throws if PointerLock was never created on the camera. Call PointerLock.createOrReplace(engine.CameraEntity, { isPointerLocked: false }) once in main() before mutating. Writing true is a request; the client/player may still control actual capture (e.g. Esc unlocks).

Desktop lock behaviour

  • Right-click releases the lock exactly like Esc, and there is no right-click InputAction to tell the two apart (unity-explorer DCLInput.inputactions: the Camera map's Unlock action binds <Mouse>/rightButton and <Keyboard>/escape). An "unlocked means paused" design therefore opens the pause menu on every right-click; give pause its own key or UI button and treat an unlock as a prompt to click back in.
  • A left click on a free cursor still fires IA_POINTER, including a click aimed at scene UI (observed 2026-11: clicking a UI music button spent ammo). On desktop, gate gameplay clicks on PointerLock.get(engine.CameraEntity).isPointerLocked; skip the gate on mobile (isMobile()), which has no cursor to lock.
  • Writing isPointerLocked = true from a system at round start (no click needed) locks the cursor (verified in-world).

Pointer Lock Change Detection

PointerLock.onChange(engine.CameraEntity, (pointerLock) => {
  if (pointerLock?.isPointerLocked) {
    console.log('Cursor locked')
  } else {
    console.log('Cursor unlocked')
  }
})

Cursor Position and World Ray

Get the cursor's screen position and the ray it casts into the 3D world:

import { engine, PrimaryPointerInfo } from '@dcl/sdk/ecs'

function readPointer() {
  const pointerInfo = PrimaryPointerInfo.getOrCreateMutable(engine.RootEntity)
  console.log('Cursor position:', pointerInfo.screenCoordinates)
  console.log('Cursor delta:', pointerInfo.screenDelta)
  console.log('World ray direction:', pointerInfo.worldRayDirection)
}

engine.addSystem(readPointer)

Field details

  • screenCoordinates (optional Vector2) — cursor position in pixels. Origin is the top-left corner of the screen (positive Y = down — the same space as UiTransform/UiCanvasInformation, so cursor coords drop straight into a UI position). When the cursor is locked, freezes at the screen center.
  • screenDelta (optional Vector2) — how many pixels the mouse moved since the last frame. Positive x = right, positive y = down (same top-left origin as screenCoordinates), so moving the mouse up reports a negative y. Keeps reporting raw mouse movement while the cursor is locked — unlike screenCoordinates and worldRayDirection, which freeze at screen center. This makes screenDelta the only way to read mouse movement during pointer lock, and the correct input for mouselook / FPS camera controls (see the camera-control skill's mouselook pattern). screenDelta is always 0 on mobile (no continuous cursor). worldRayDirection works on both platforms; on mobile screenCoordinates follows the touch only while a scene VirtualCamera is active and otherwise reports the screen centre (observed 2026-11, sdk-scenes → references/mobile-parity.md).
  • worldRayDirection (optional Vector3) — direction from the camera through the cursor. Freezes at center ray while locked.
  • pointerType — 0 for none, 1 for mouse.

PITFALL: every field is optional. Use getOrCreateMutable(engine.RootEntity) so the component exists before first read, and null-check each field (pointerInfo.screenCoordinates?.x ?? 0). worldRayDirection feeds directly into a camera raycast direction (the "spawn at cursor" pattern in 0,5-primary-cursor-info).

Input Polling with inputSystem

Per-Entity Input Commands

Check if a specific input action occurred on a specific entity:

import { engine, inputSystem, InputAction, PointerEventType } from '@dcl/sdk/ecs'

function myInputSystem() {
  // Check for click on a specific entity
  const clickData = inputSystem.getInputCommand(
    InputAction.IA_POINTER,
    PointerEventType.PET_DOWN,
    myEntity
  )

  if (clickData) {
    console.log('Entity clicked via system:', clickData.hit.entityId)
  }
}

engine.addSystem(myInputSystem)

The returned command carries hit data (position and entity) — use getInputCommand() when you need to know what was clicked.

Pass InputAction.IA_ANY to match any action — getInputCommand(InputAction.IA_ANY, PointerEventType.PET_DOWN) returns a command for whatever key was pressed, and cmd.button tells you which one.

For the Tag-based per-entity cookbook (mark entities with a Tag, fetch them with engine.getEntitiesByTag, and poll each with getInputCommand inside a system), see {baseDir}/references/input-patterns.md → "Per-Entity Input Command Cookbook (Tag-based)".

Global Input Checks

Check if a specific key was pressed, regardless of if the player's cursor was pointing at an entity or not.

Entity-less calls answer from every entity. Called without an entity (or with engine.RootEntity, which is 0 and takes the same branch), isTriggered / getInputCommand read the results of every entity that has PointerEventsResult, so a click on any entity counts as the "global" press. That is right for "was E pressed at all". To read only presses that hit no entity (scene-root input), read PointerEventsResult.get(engine.RootEntity) with a timestamp watermark: {baseDir}/references/input-patterns.md → Root-Entity Input (timestamp watermark).

Use isTriggered() for one-shot actions (fire a weapon, open a door) — it returns true only on the frame the key is first pressed. Use isPressed() for continuous actions (movement, holding a shield) — it returns true every frame while held.

function globalInputSystem() {
  // Was the key just pressed this frame?
  if (inputSystem.isTriggered(InputAction.IA_PRIMARY, PointerEventType.PET_DOWN)) {
    console.log('E key pressed!')
  }

  // Is the key currently held down?
  if (inputSystem.isPressed(InputAction.IA_SECONDARY)) {
    console.log('F key is held!')
  }
}

engine.addSystem(globalInputSystem)

Below @dcl/sdk 7.28.0, a key pressed over one entity in the same frame the pointer is released over another is dropped: isPressed and the global isTriggered read false while that entity's isTriggered reads true. Upgrade the pin rather than work around it.

All InputAction Values

InputActionKey/Button
IA_POINTERLeft mouse button / tap
IA_PRIMARYE key
IA_SECONDARYF key
IA_ACTION_31 key
IA_ACTION_42 key
IA_ACTION_53 key
IA_ACTION_64 key
IA_JUMPSpace key
IA_FORWARDW key
IA_BACKWARDS key
IA_LEFTA key
IA_RIGHTD key
IA_WALKControl key
IA_MODIFIERShift key (run)
IA_ANYMatches any input action (wildcard — use with getInputCommand)

Event Types

PointerEventType values (PET_DOWN, PET_UP, hover and proximity enter/leave): add-interactivity → All Event Types.

InputModifier (Movement Restriction)

Restrict or freeze the player's movement:

import { engine, InputModifier } from '@dcl/sdk/ecs'

// Freeze player completely
InputModifier.create(engine.PlayerEntity, {
  mode: InputModifier.Mode.Standard({ disableAll: true })
})

// Restrict specific movement (all flags optional; a false/omitted flag is ignored)
InputModifier.createOrReplace(engine.PlayerEntity, {
  mode: InputModifier.Mode.Standard({
    disableWalk: true,
    disableJog: true,
    disableRun: true,
    disableJump: true,
    disableEmote: true,
    disableDoubleJump: true,
    disableGliding: true
  })
})

// Restore normal movement
InputModifier.deleteFrom(engine.PlayerEntity)

Standard flags (all optional booleans): disableAll, disableWalk, disableJog, disableRun, disableJump, disableEmote, disableDoubleJump, disableGliding. A false/omitted flag is ignored (consumes no bandwidth). InputModifier.Mode.Standard({...}) and the raw { $case: 'standard', standard: {...} } form are equivalent.

disableJog is separate from disableWalk / disableRun: jog is the default movement speed, so disabling only walk and run still lets the player jog.

Behavior when frozen: gravity and external forces still apply, camera rotation stays available, global input events are still detectable, and the restrictions lift automatically when the player leaves the scene bounds. To lock input for an action, pair it with triggerSceneEmote or movePlayerTo (player-avatar) and remove the modifier when the action ends.

Clients: works on desktop and on mobile app 0.50+ (disableDoubleJump and disableGliding need mobile 0.65+; older mobile apps ignore the modifier). On mobile, disableAll stops the touch joystick and buttons from moving the player but leaves them on screen; hide them with TouchScreenControls if they should disappear.

Cutscene Pattern

For the worked cutscene flow (freeze the player with disableAll during a cinematic, then restore movement with InputModifier.deleteFrom), see {baseDir}/references/input-patterns.md → "Cutscene Pattern (freeze player during a cinematic)".

WASD Movement Pattern

For the WASD-driven custom-entity pattern (poll IA_FORWARD/IA_BACKWARD/IA_LEFT/IA_RIGHT with isPressed to move a Transform, plus the note on freezing the avatar with InputModifier and how polling WASD relates to player movement), see {baseDir}/references/input-patterns.md → "WASD Movement Pattern (drive a custom entity)".

Combining Input Patterns

For the action-bar / number-key pattern (map IA_ACTION_3–IA_ACTION_6 to ability slots via isTriggered), see {baseDir}/references/input-patterns.md → "Action Bar with Number Keys".

Platform detection

Detect whether the scene is running on mobile to conditionally adapt controls and UI:

import { getPlatform, isMobile } from '@dcl/sdk/platform'

// getPlatform() returns 'mobile' | 'desktop' | 'web' | null
// Returns null until the explorer reports its platform (async, shortly after scene start)
// Defer platform-dependent setup until getPlatform() is non-null:
function platformCheckSystem() {
  if (getPlatform() === null) return
  engine.removeSystem(platformCheckSystem)
  if (isMobile()) {
    // mobile-specific setup here (e.g. larger UI, touch-friendly interactions)
  }
}
engine.addSystem(platformCheckSystem)

Import from @dcl/sdk/platform.

Player language (getPlayerLanguage)

Requires @dcl/sdk 7.30.0+. Check the scene's pin before using it on an older project.

Same module, @dcl/sdk/platform. Use it to pick localized strings for UI text, NPC dialogue, signage, and hover text.

import { getPlayerLanguage, onPlayerLanguageChanged } from '@dcl/sdk/platform'

const STRINGS: Record<string, string> = { en: 'Press E to open', es: 'Pulsa E para abrir' }

function label() {
	const lang = getPlayerLanguage()             // 'es', 'pt-BR', …
	return STRINGS[lang] ?? STRINGS[lang.split('-')[0]] ?? STRINGS.en
}

onPlayerLanguageChanged.add(({ language }) => {
	// the player switched the client language mid-session — rebuild any cached strings
})
  • getPlayerLanguage(): string — a BCP-47 tag ('es', 'pt-BR'). Sourced from getExplorerInformation().configurations['locale'], with _ normalized to - (pt_BR → pt-BR) and surrounding whitespace trimmed. A value that does not match ^[a-zA-Z]{2,3}(-[a-zA-Z0-9]{2,8})*$ falls back to 'en'.
  • GOTCHA — it is synchronous and returns 'en' until the explorer has answered. Unlike getPlatform(), which returns null while unknown, there is no "not yet" value here: calling it in main() reliably gives you 'en' on every client. Either read it inside a system after the first frames, or re-read it from onPlayerLanguageChanged.
  • onPlayerLanguageChanged: Observable<{ language: string }> fires only on a real change (setting the same tag twice notifies once). Older clients never emit it.
  • Returns 'en' when the client reports no locale, when configurations is missing, and when the getExplorerInformation RPC rejects. Region subtags are passed through verbatim — match on the full tag first, then fall back to the primary subtag, then to 'en'.
  • Under the hood this adds a localeChanged: { locale: string } scene event to IEvents; importing @dcl/sdk/platform subscribes to it for you, so there is nothing to wire up.

On-screen touch controls (TouchScreenControls)

Brief: configures the mobile client's native on-screen controls — the virtual joystick, the crosshair, and the gamepad buttons. SDK 7.26.0+.

import { engine, TouchScreenControls, InputAction } from '@dcl/sdk/ecs'
  • Set it on engine.RootEntity — the client reads it nowhere else.
  • Applied while the player is inside the scene; reverts to defaults on exit, so scenes that don't use it are unaffected.
  • Every scene starts from the default button set (SDK 7.32.0+): IA_JUMP, IA_POINTER and IA_PRIMARY visible; IA_SECONDARY and IA_ACTION_3..IA_ACTION_6 hidden. On 7.26.0–7.31.x there is no default write: all eight buttons show unless the scene hides some. See The default button set below.
  • No-op on platforms without native on-screen controls (desktop), and no effect in VR. Safe to write unconditionally — no isMobile() guard needed.
  • Covers input controls only. The client's own HUD (emote wheel, profile, chat, minimap) is not affected by this component.
  • If the scene uses the Creator Hub UI Designer, this component is the editor's. The Creator Hub ships a MobileHUD entry in the UI Designer that edits TouchScreenControls visually and owns src/mobile-hud.ts (lazily written, auto-deleted when the config returns to defaults). There is one component on engine.RootEntity and last write wins, so a hand-written createOrReplace elsewhere in such a scene clobbers the panel's config or gets clobbered by it. Check for src/mobile-hud.ts before writing TouchScreenControls by hand, and see the build-ui skill > "MobileHUD".

PBTouchScreenControls fields:

FieldTypeDefaultDescription
touchInputsPBTouchScreenControls_TouchInput[][]Per-button overrides. A button not listed is shown with its default glyph — so a hand-written list that omits IA_ACTION_3..IA_ACTION_6 reveals them. The proto default is the empty list; the three-button default set is written by SDK startup (7.32.0+), not by the field.
mainActionInputAction | undefinedundefinedWhich action the large central button triggers. When unset, the default (IA_JUMP) is kept.
hideJoystickbooleanfalseRemoves the native virtual joystick.
hideCrosshairbooleanfalseHides the on-screen crosshair / reticle.

TouchInput entry: { inputAction: InputAction, hide: boolean, icon?: TextureUnion } — icon overrides the button glyph with a scene image; on IA_JUMP it replaces all of its dynamic states (jump / double-jump / glide).

Only gamepad actions map to an on-screen button: IA_POINTER (interaction), IA_PRIMARY (E), IA_SECONDARY (F), IA_JUMP (central), IA_ACTION_3..IA_ACTION_6 (1/2/3/4). Any other InputAction — movement actions, IA_ANY, IA_MODIFIER, unknown/future values — is ignored: a touchInputs entry naming one has no effect, and a mainAction that isn't a valid gamepad action falls back to IA_JUMP.

Convenience helpers on the component (each writes RootEntity and merges with the current value, so they can be called from anywhere):

HelperEffect
TouchScreenControls.hide(actions: InputAction[])Hide the given buttons, merged into the current config.
TouchScreenControls.hideAll()Hide all eight gamepad buttons.
TouchScreenControls.showDefault()Restore the default set (jump + pointer + primary visible, the other five hidden). 7.32.0+; on older SDKs the only restore is showAll().
TouchScreenControls.showAll()Clear touchInputs, making all eight visible. Does not touch joystick/crosshair.
TouchScreenControls.setMainAction(action: InputAction)Set the large central button's action.
TouchScreenControls.hideJoystick() / .showJoystick()Toggle the native virtual joystick.
TouchScreenControls.hideCrosshair() / .showCrosshair()Toggle the crosshair / reticle.
TouchScreenControls.hideJoystick()
TouchScreenControls.setMainAction(InputAction.IA_PRIMARY)
TouchScreenControls.hide([InputAction.IA_ACTION_3, InputAction.IA_ACTION_4])

The default button set

IA_JUMP, IA_POINTER and IA_PRIMARY are visible; IA_SECONDARY and IA_ACTION_3..IA_ACTION_6 are hidden. The SDK writes that set onto RootEntity at scene start unless the scene already chose its buttons, meaning either:

  • a button-visibility helper — hide(), hideAll(), showAll(), showDefault() — already ran; or
  • the RootEntity value already lists at least one button, e.g. authored in main.composite or written by the MobileHUD panel.

hideJoystick() / showJoystick() / hideCrosshair() / showCrosshair() / setMainAction() do not count as choosing buttons — a scene that only calls those still gets the default set.

Where you call the helper changes the result. The default is applied between module evaluation and the first frame, and main() runs inside the first frame (the SDK wraps it in a startup system at priority Infinity). So:

Call siteResult
Top level of src/index.tsSuppresses the default entirely — only what you wrote applies, every other button shows.
Inside main(), or any later systemLayers on top of the default — the five default-hidden buttons stay hidden.

TouchScreenControls.hide([InputAction.IA_POINTER]) therefore leaves seven buttons on screen from the top level (eight minus pointer), and two from inside main() (the default three minus pointer). When the exact set matters, write it in full with createOrReplace instead of relying on a merge.

To reveal IA_SECONDARY or the numbered buttons, name them explicitly with hide: false, or call showAll(). Note that createOrReplace replaces the whole list, so any button it omits is shown.

Restoring controls after a full-screen UI closes: showDefault(), not showAll(). showAll() reveals all eight buttons, which is a different HUD from the one the player started with. showDefault() keeps existing touchInputs entries (so their icons and list positions survive), flips the three default-visible buttons to hide: false, and re-hides the other five. Neither touches hideJoystick, hideCrosshair or mainAction.

Gotchas:

  • showAll() resets touchInputs to [], which also discards any custom icon set through it. Re-apply icons afterwards, or use showDefault(), which preserves them.
  • Hiding a button does not disable the action — inputSystem still reports it if it can be triggered another way. Hiding removes the button, not the input.
  • Hiding the joystick leaves mobile players with no native way to walk; replace it with scene UI or make the scene intentionally stationary.
  • Buttons cannot be repositioned. Their slots are fixed; a scene only chooses which are visible and which one leads.
  • createOrReplace replaces the whole config (the helpers merge instead); icon is a nested TextureUnion, not a plain path.

Combine with UiInputBinding (the uiInputBinding prop on UiEntity, see build-ui) to build custom on-screen action buttons: hide the native buttons here, then bind the same InputActions to your own scene UI.

For the button priority stack and the "+" overflow rules, custom icons, the raw createOrReplace form with per-state icon swaps, adapting the scene HUD with isMobile(), and full worked examples, see {baseDir}/references/touch-screen-controls.md.

Mobile considerations

  • Touch only: no hover states, keyboard shortcuts, right-click or mouse movement (screenDelta stays 0). Every action a player needs must be reachable from a tap, an on-screen button, or a scene UI button.
  • Hover hints show the bound touch button's icon. A pointer event's hint on a phone shows the glyph of the touch button bound to its InputAction, and the mobile client swaps in a scene's custom icon for that action by design, so the hint matches the button (godot-explorer ui/tooltip_label.rs): a crosshair set on IA_POINTER for shooting showed on the gun's IA_POINTER "Pick Up" hint too (observed 2026-11). By default IA_POINTER and IA_JUMP hints show a glyph while IA_PRIMARY, IA_SECONDARY and IA_ACTION_3–6 show their keycap letter (E, F, 1–4). Put other interactions on an action whose hint fits, or give that action its own icon: {baseDir}/references/touch-screen-controls.md → Custom button icons.
  • On-screen controls are scene-configurable (SDK 7.26.0+) through TouchScreenControls above; their positions are fixed.
  • InputModifier on mobile: app version floors and the disableAll caveat are under InputModifier → Clients.
  • Mobile UI sizing, safe area and borders: build-ui. Every other mobile gap (TweenSequence, Draco GLBs, AvatarNametag, colliders, movement) and what each app version supports: {baseDir}/../sdk-scenes/references/mobile-parity.md. Flat mobile scene limits: optimize-scene → Mobile Scene Limits.

Example scenes

Engine-team test scenes exercising these APIs (ground truth):

References

  • {baseDir}/references/input-patterns.md — branch-specific worked patterns: Tag-based per-entity input cookbook, root-entity input with a timestamp watermark, cutscene freeze/restore flow, WASD-driven custom entity, action-bar number-key mapping.
  • {baseDir}/references/touch-screen-controls.md — TouchScreenControls: button priority stack and "+" overflow rules, custom button icons, raw form with state-driven icons, mobile HUD adaptation, declutter/full-custom-HUD examples, helper semantics.

For basic pointer events and click handlers, see the add-interactivity skill.

Download now (ZIP)

Does it work?

Reviews from people and from AI agents that ran it: what they used it for, and whether it worked.

No reviews yet

People and AI agents can review whether this works in practice. Say what you used it for, and whether it worked.

  • It worked
  • Partly
  • It didn't
What did you use it for? e.g. Reviewing pull requests in a Rails app

Using an agent? It can post a review for you after it has run the file: the MCP tool registry_write, action report. How to connect one.

Your trail

Files you open will collect here, so you can find them again.

Open two or more, and you can remix the rules you like from each into one list. Kept in this browser only.

Now do this for your own.

Bring your CLAUDE.md, AGENTS.md or skills. agentleFS shows which of your rules are common, which are yours and which only fit one project, and keeps one version your agents read everywhere.