Controls
controls is a global singleton which provides an interface for VR, desktop and mobile inputs. These are the default controls and provide your standard third/first person controls for mobile/desktop and VR locomotion. This also stores controller state e.g. mouse interactions of VR hand/controller interactions and state.
Built-in player movement
The editor enables a complete local-player controller by default. Game scripts normally extend this controller instead of creating another player movement system.
On desktop, the built-in controller:
- uses WASD or the arrow keys for movement, Shift to run, Space to jump, and the mouse to rotate the camera;
- performs floor and wall collision detection;
- moves an internal camera target and copies that target to
world.avatar.positionevery frame; - places
world.camerabehind that target according tocontrols.zoom,controls.xShift, andcontrols.yShift; - sets
controls.moving,controls.falling,controls.floorHeight, and other movement state used by the avatar animation system.
Do not move world.avatar.position directly to implement normal local-player locomotion. The desktop controller will overwrite it on its next update. To teleport the local player and its camera target together, use:
world.avatar.teleport(new U.Vector3(0, 0, 5))
world.avatar.teleport() uses avatar root/feet coordinates, so y = 0 places the avatar on a floor at y = 0. The engine handles the private desktop controller eye-height offset internally.
To turn the equipped avatar and the desktop camera toward a world-space point together, use:
world.avatar.faceTowards(new U.Vector3(0, 0, 5))
The turn affects horizontal yaw and preserves the camera's current pitch.
world.avatar loads asynchronously and may not exist during a script's first frames. Guard access until it is available.
For a third-person shooter, retain the built-in movement and configure the camera rather than replacing the controller:
controls.zoomMin = 0
controls.zoomMax = 4
controls.setZoom(3, 0)
controls.lockZoom = true
controls.xShift = 0.15
controls.yShift = 0.1
controls.flyEnabled = false
While pointer lock is active, the player ray is cast through the center of the camera. controls.intersections contains hits against objects registered with world.addRaycastable, and controls.pressing represents the desktop primary mouse button. These are the normal starting points for camera-directed shooter aiming and firing.
Examples:
// set object's local postion to the left controller position (world space)
this.update = () => {
this.position.copy(controls.left.position);
};
// Vibrate the controller when it clicks an object
this.click = (evt) => {
// Pulse at max intensity, for 20ms
// evt.hand is 'left' or 'right'
controls[evt.hand].pulse(1.0, 20);
};
this.update = () => {
// If using hand tracking
if (controls.right.inputSource == 2) {
this.position.copy(controls.right.hand["index-finger-tip"].position);
}
};
// Set nested object's rotation/quaternion to controller quaternion
this.update = () => {
// get inverse of parent quaternion
const quaternion = new U.Quaternion();
this.parent.getWorldQuaternion(quaternion);
quaternion.invert();
// copy it so that objects world rotation is 0, 0, 0
this.quaternion.copy(quaternion);
// then multiply / set from world controller rotation
this.quaternion.multiply(controls.left.quaternion);
};
// Change walking speed on desktop, and locomotion speed in VR
controls.speed = 3;
// Set camera to first person
controls.setZoom(0, 0);
// Set camera to third person
controls.setZoom(2.5, 0);
controls.zoom : Number
Controls how far away the camera is from the player on mobile and desktop. Setting controls.zoom = 0 is first person and controls.zoom > 0.5 is third person, (3 is the default).
Prefer controls.setZoom(value, seconds) when changing zoom. It clamps the value to controls.zoomMin–controls.zoomMax and optionally transitions over the supplied duration. Mouse-wheel zoom uses the same limits. Set controls.lockZoom = true to prevent the mouse wheel from changing zoom during gameplay.
controls.desktop.cameraCollisionEnabled : Boolean
Defaults to true. The desktop/mobile camera sweeps a small sphere from its
focus to the requested camera position, including shoulder and height offsets.
It pulls inward immediately when blocked and eases outward when space clears.
The sphere also encloses the camera's near-plane corners. controls.zoom keeps
the requested distance, so collision can temporarily pull closer than zoomMin.
The check reuses player-collision bounds and triangle caches, respects
object.collider = false, and is skipped in first person and XR. It also applies
to fixed-angle cameras. Disable it with:
controls.desktop.cameraCollisionEnabled = false;
controls.zoomMin : Number
The minimum camera zoom accepted by controls.setZoom() and desktop mouse-wheel scrolling. The default is 0.
controls.zoomMax : Number
The maximum camera zoom accepted by controls.setZoom() and desktop mouse-wheel scrolling. The default is 4. Set the limits before calling setZoom():
controls.zoomMin = 1
controls.zoomMax = 8
controls.setZoom(5, 0)
controls.zoomMax should be greater than or equal to controls.zoomMin. If it is lower, the effective maximum is clamped to controls.zoomMin.
controls.yShift : Number
How far to move the camera above player eye height. This scales is multiplied by controls.zoom:
camera.position.y += controls.zoom * controls.yShift;
This is useful if you want to move the camera higher so the player's head doesn't overlap with the cursor. The default is 0.
controls.xShift : Number
Offset the avatar on the X axis, this can help make the cursor not overlap with the player's position
The value offsets the camera horizontally relative to the controller target. This is useful for an over-the-shoulder shooter camera.
controls.speed : Number
Multiplier for the built-in locomotion speed. The default is 1.
On desktop, the default grounded speeds are:
- walking:
2metres per second; - running with Left Shift:
5metres per second.
Both speeds are multiplied by controls.speed. For example,
controls.speed = 1.5 gives a walking speed of 3 metres per second and a
running speed of 7.5 metres per second. Movement accelerates toward these
speeds rather than reaching them immediately, and collisions can reduce the
actual distance travelled.
controls.intersections : Array
An array of the interesected raycastable objects
controls.jumpEnabled : boolean
Flag to enable or disable jumping capability.
controls.flyEnabled : boolean
Flag to enable or disable flying capability.
controls.pressing : Boolean
Left click on desktop
controls.grabbing : Boolean
Right click on desktop
controls.floor : U.Object3D | null
The current object below the player, useful for sound effects, collisions.
controls.floorHeight : Number
The floors y position in world space.
controls.falling : boolean
Whether player controls are falling
controls.startPosition : Vector3
The initial camera position when the scene is loaded. This is used for resetting the player position on fall timeout. You can over-ride this. This can be useful for resetting to a point on a map when the player dies or falls for too long
controls.fallResetTime : Number : 2
The time before the player controls resets to the original startPostion when falling for x seconds. The default is 2 (seconds).
To disable this use: controls.fallResetTime = -1
Desktop
Explicit pointer lock from UI
For a button that deliberately enters mouse-look gameplay, request lock directly in its click handler, before awaiting asset loading:
controls.desktop.lock({ userGesture: true })
Call this from a native click, pointer, or keyboard event handler, before
awaiting anything. The flag does not manufacture browser user activation.
Viewport clicks and desktop 3D UI button releases are dispatched synchronously
inside native pointer events so Safari receives the gesture too. On Chromium,
a click during the Escape cooldown is remembered until that cooldown ends,
provided transient user activation is still active. Blur and Stop cancel that
queued click. Safari requests remain on the native event stack; after a browser
denial there, click again with a fresh gesture.
Ordinary lock() calls retain a short post-Escape guard. Explicit gesture
requests otherwise go directly to the browser, which enforces its own recapture rules.
cancelPendingLock(), input reset, Stop, and unlock cancel requests still in
flight; a cancelled request that later succeeds is immediately unlocked. Both
forms respect disabled controls and fixed-angle camera mode.
Camera modes
The default follow mode retains first/third-person mouse look and player
movement. Use setCameraMode() to opt a desktop game into a camera with a fixed angle:
controls.desktop.setCameraMode('fixedAngle', {
pitch: -Math.PI / 3, // radians, looking down
yaw: 0,
panSpeed: 24, // world units per second
edgeSize: 18, // CSS pixels; 0 disables edge scrolling
bounds: { minX: -100, maxX: 100, minZ: -100, maxZ: 100 },
})
controls.zoomMin = 12
controls.zoomMax = 30
controls.setZoom(30, 0)
controls.yShift = 0
world.camera.fov = 40
world.camera.updateProjectionMatrix()
Fixed angle mode disables pointer lock and mouse-look rotation. WASD still moves the
local player through the built-in collision controller. Arrow keys, moving the
pointer to the canvas edges, or dragging with the middle mouse button pan the
camera independently of the player. The camera initially follows the player;
panning detaches it. Press Home, or call
controls.desktop.cameraRig.recenter(), to follow again. Space retains its
existing jump behavior. Mouse-wheel zoom uses the configured zoom limits.
While moving, the avatar turns toward its actual horizontal travel direction,
including strafing, reversing, and wall sliding. Its neck does not follow the
overhead camera. An active world.avatar.faceTowards() request takes priority
over movement facing and turns the avatar without rotating the camera.
Camera focus is stored in controls.desktop.cameraRig.focus; panning never
changes the player target. Bounds constrain the focus on the horizontal plane.
pitch, yaw, panSpeed, edgeSize, and bounds can be changed through
controls.desktop.cameraRig.configure(options). Panning pauses for UI, locked
translation, or hidden tabs; blur clears held input. This mode is desktop
only and does not take over mobile or XR controls.
For a temporary fixed-angle camera hold, call
controls.desktop.cameraRig.setFocusOverride(position) with a world-space
focus point. It copies the point and suspends following and panning until
setFocusOverride(null) clears it. Call recenter() afterward to resume
following the player. This changes camera focus only; avatar movement and
respawn remain under their existing systems.
The override also updates the camera while controls.desktop.enabled is
false, allowing death cameras while locomotion is disabled.
Selection, unit orders, and button meanings remain the game's responsibility.
Use controls.intersections for the object under the free cursor. For custom
aiming, controls.desktop.getPointerRay(origin, direction) fills two supplied
vectors using the same cursor coordinates as viewport interaction and returns
whether a valid ray was available. It does not perform a raycast.
Call controls.desktop.setCameraMode('follow') to restore the previous camera
orientation, pointer-lock setting, and avatar-facing setting. Stop Play also
resets the mode. Games should restore any zoom/FOV settings they change in
onStop. Independently of camera mode, controls.desktop.rotationEnabled = false
disables mouse look without disabling movement or changing pointer lock.
The camera input regression checks can be run from u/ with:
node --test tests/desktop-controls.test.mjs
// Disable or enable using pointer lock. true by default
controls.desktop.pointerLockEnabled = true | false
controls.desktop.enabled = true | false
// Show or hide the shader cursor while pointer lock is active.
// This does not affect the operating-system pointer or XR hand/controller pointers.
controls.desktop.cursorEnabled = true | false
// Keep the local avatar facing the camera's horizontal direction.
// Camera pitch is ignored so the avatar remains upright. false by default.
controls.desktop.avatarFollowsCamera = true | false
// Actual world-space player displacement this update, after collision (metres).
// Reused by the controller; treat it as read-only. Cleared when movement is locked.
controls.desktop.worldMovement // U.Vector3
// Add an immediate world-space change in player velocity (metres/second).
controls.desktop.addImpulse(new U.Vector3(0, 8, 0))
// Add world-space acceleration for the next movement update
// (metres/second squared). Call each frame for sustained thrust.
controls.desktop.addAcceleration(new U.Vector3(0, 20, 0))
// Copy the current externally applied velocity into an existing vector.
controls.desktop.getExternalVelocity(result)
// Remove queued acceleration and externally applied velocity.
controls.desktop.clearExternalMotion()
controls.cursor // ShaderCursor; alias of controls.desktop.cursor (no longer a U.Mesh)
// You can create your own desktop specific controls, e.g.
controls.desktop = {
update: (delta)=>{ /* your logic '*/ }
}
Shader cursor
The desktop center cursor is a small procedural quad drawn after the completed
scene, UI overlays, and selection outlines. It uses one draw call and a reused
128-byte uniform buffer; it has no scene mesh, raycast registration, or world
position. Size is in CSS pixels and remains constant across camera distance,
FOV, display pixel ratio, and render resolution. It appears only while the
viewport owns pointer lock and controls.desktop.cursorEnabled is true.
Mobile touch and XR pointers are unchanged.
Use the built-in styles without writing a shader:
this.init = () => {
controls.cursor.configure({
shape: 'ring', // 'dot', 'ring', or 'cross'
size: 24, // CSS pixels; includes transparent antialiasing margin
color: [1, 1, 1, 1], // straight sRGB RGBA, each component 0–1
outline: [0, 0, 0, 1],
outlineWidth: 2, // CSS pixels
hoverScale: 1.2, // multiplier while pointing at an interaction target
});
};
this.onStop = () => controls.cursor.reset();
For custom shapes, provide WGSL defining
fn cursor(p: vec2f) -> vec4f. The engine supplies the vertex stage,
bindings, blend state, and final pass. p runs from -1 to +1 across the
square, with +Y pointing up. Return straight sRGB RGBA; transparent pixels
preserve the image behind the cursor. Keep the shape inside the square,
leaving room for antialiasing. Use fwidth/smoothstep for smooth edges.
this.init = async () => {
const cursor = controls.cursor;
cursor.configure({ size: 28, color: [0.2, 0.9, 1, 1] });
try {
await cursor.setShader(/* wgsl */ `
fn cursor(p: vec2f) -> vec4f {
let radius = 0.55 + settings.parameters[0].x * 0.15;
let distance = abs(length(p) - radius) - 0.07;
let aa = max(fwidth(distance), 0.001);
let alpha = 1.0 - smoothstep(-aa, aa, distance);
return vec4f(settings.color.rgb, settings.color.a * alpha);
}
`);
} catch (error) {
console.error('Cursor shader failed:', error);
}
};
this.update = () => {
// Update values without compiling a pipeline or allocating new buffers.
controls.cursor.parameters[0] = 0.5;
};
this.onStop = () => controls.cursor.reset();
Available uniforms:
settings.viewport: framebuffer width/height, current CSS size (including hover scale), time in seconds.settings.color/settings.outline: configured RGBA colors.settings.style: outline width, built-in shape index, framebuffer-to-CSS pixel ratio, reserved.settings.parameters: fourvec4fvalues backed bycursor.parameters, a reusableFloat32Array(16).
configure() changes uniforms; it does not compile shaders. setShader()
compiles asynchronously and resolves to true when installed, or false if
superseded by another shader, a reset, disposal, or renderer replacement.
Compilation errors reject the promise and leave the previous cursor intact.
Call it during setup or a style change, never every frame. reset() restores
the default dot and clears custom parameters. Scene clearing recreates the
cursor, and GPU device loss releases its buffer. Restore game customizations
in init() and reset them in onStop.
Optional target-specific styles
Most games can keep the default cursor. Games that need different appearances
can opt in with controls.cursor.resolveStyle. The engine passes the closest
current desktop raycast hit, or null, once per viewport update. The callback
runs synchronously; return a reusable handle from await cursor.createStyle().
Returning null or undefined keeps the configured base cursor and its normal
hover feedback. cursor.target exposes the latest hit; treat it as read-only.
Return a style created with { visible: false } to hide the cursor temporarily
(for example, while dragging an object). Hidden styles skip the cursor draw pass.
let cursor, previousResolver, resolver;
this.init = async () => {
cursor = controls.cursor;
const normal = await cursor.createStyle({ hoverScale: 1 });
const interactive = await cursor.createStyle({ shape: 'ring', size: 24 });
const heart = await cursor.createStyle({
size: 28,
color: [1, 0.25, 0.6, 1],
hoverScale: 1,
code: /* wgsl */ `
fn cursor(p: vec2f) -> vec4f {
let q = p * 1.5;
let base = dot(q, q) - 1.0;
let distance = base * base * base - q.x * q.x * q.y * q.y * q.y;
let aa = max(fwidth(distance), 0.001);
let alpha = 1.0 - smoothstep(-aa, aa, distance);
return vec4f(settings.color.rgb, settings.color.a * alpha);
}
`,
});
previousResolver = cursor.resolveStyle;
resolver = hit => {
if (hit?.object.userData?.pet) return heart;
if (!hit || hit.object.userData?.placementFloor) return normal;
return interactive;
};
cursor.resolveStyle = resolver;
};
this.onStop = () => {
if (cursor?.resolveStyle === resolver) cursor.resolveStyle = previousResolver;
};
The metadata in this example is game-defined; the engine does not assign pet or floor categories. Your resolver can use object identity, ancestors, game state, or your own registries instead. Avoid allocations, scene traversal, asset loading, and shader compilation inside the resolver.
Styles snapshot size, shape, colors, outline width, and hover scale at creation.
They have their own mutable parameters array for animation. Supplying code
precompiles WGSL during setup. Identical code shares a cached pipeline, even
when colors or sizes differ. Switching styles selects the cached pipeline and
updates the shared uniform buffer: one quad, one draw, one cursor pass.
Unused styles never draw. Built-in styles compile lazily on first use; custom
styles recompile asynchronously after device loss, using the base cursor until
ready. Resolver errors fall back to the base cursor and are reported once per
failing callback. reset() removes the resolver and clears cached styles and
parameters. Existing handles can be reused but their pipelines must recompile.
Legacy scripts that manipulated controls.desktop.cursor.position,
geometry, material, or scale must migrate to configure() and WGSL.
cursorEnabled, setVisible(), and createCursor() remain available;
controller updates own automatic visibility. This API styles the pointer
indicator only and does not change aiming, raycasts, or input handling.
External motion is combined with ordinary player movement and passed through the
built-in swept capsule collision. addAcceleration() accumulates calls until the
next controller update and is then cleared automatically. Impulses persist as
velocity; gravity affects vertical external motion, ground friction slows
horizontal external motion, and blocked velocity is removed at collisions.
Mobile
controls.mobile.enable();
controls.mobile.disable();
controls.mobile = {
update: (delta) => {
/* your logic '*/
},
};
XR
controls.locomotion : Locomotion
Handles locomotion within the VR environment.
controls.locomotion.enabled : boolean
Flag to enable or disable locomotion.
controls['left' or 'right'] : Hand
Left/right controller object, containing state and interaction data for that hand.
Hand : Object
Represents the right control interface, including position, rotation, and interaction states.
| Name | Type | Description |
|---|---|---|
| position | U.Vector3 |
The current position in 3D space. |
| rotation | U.Euler |
The current rotation using Euler angles. |
| quaternion | U.Quaternion |
The current orientation using quaternion. |
| pressing | boolean |
Indicates if the control is currently being pressed. |
| grabbing | boolean |
Indicates if the control is currently grabbing an object. |
| gamepad | Gamepad |
The associated gamepad device, if any. |
| pulse | function |
Function to trigger haptic feedback. Takes: (intensity, duration) |
| hand | {} |
Object/Dict storing references to each hand/finger joint |
| raycaster | U.Raycaster |
The raycaster for detecting intersections. |
| ray | U.Ray |
The current ray used for intersection tests. |
| intersections | Array |
A list of current intersections detected by the raycaster. |
| inputSource | number |
0 = not connected, 1 = controller, 2 = hand tracking |
Hand joints:
'wrist'
'thumb-metacarpal'
'thumb-phalanx-proximal'
'thumb-phalanx-distal'
'index-finger-phalanx-proximal'
'index-finger-phalanx-intermediate'
'index-finger-phalanx-distal'
'middle-finger-phalanx-proximal'
'middle-finger-phalanx-intermediate'
'middle-finger-phalanx-distal'
'ring-finger-phalanx-proximal'
'ring-finger-phalanx-intermediate'
'ring-finger-phalanx-distal'
'pinky-finger-phalanx-proximal'
'pinky-finger-phalanx-intermediate'
'pinky-finger-phalanx-distal'
// Three.Group storing bone position, rotation
controls.left.hand['pinky-finger-phalanx-distal'] -> U.Group