EngineIntermediate

Player input and touch controls

What produces movement and look on each device, how touch sticks behave, and how authored action buttons reach Lua.

What produces look

The first_person and third_person camera modes consume input.lookDeltaX and input.lookDeltaY automatically — you do not write look handling in Lua. But they can only turn the camera if something is producing those deltas, and what produces them depends on the device:

  • Arrow keys — always available on a keyboard.
  • Mouse — only when the scene enables Desktop mouse look. Off by default; see Cameras and follow modes.
  • Touch look stick, or a drag anywhere that is not the movement stick or an action button — only on a touch device with mobile controls enabled. The drag is not restricted to one half of the screen, so a player can aim with whichever thumb is free.

So on a desktop scene that has not enabled mouse look, the arrow keys are the only way to turn. If a follow camera seems to position correctly but never rotates, this is almost always why. There is no gamepad support.

Pitch is clamped to just under ±90°. Base sensitivity is fixed and is not a scene property, but players can calibrate it themselves in the Preview look-settings panel (0.5x to 2.0x, with independent invert toggles per axis), stored per viewer.

A touch drag is measured against the viewport width, not in pixels. A drag across the full width of the screen turns 320°, so the same swipe turns the same amount on a phone and on a tablet. Mouse look stays pixel-based, which is what a mouse expects. Vertical look runs at 0.78x the horizontal rate, because pitch is bounded to a quarter turn either way while yaw is not.

Look rotates the camera only, not the character. The camera's orientation is composed as the character's rotation, then the accumulated look rotation, then the half-turn that reconciles Three.js's -Z view direction with the engine's +Z forward. A script that also writes character yaw to match the camera's forward direction will fight the engine, because that character rotation feeds back into the camera's basis and compounds. Drive character facing from movement input instead.

In Lua, read lookDeltaX/lookDeltaY and not lookAxisX/lookAxisY. The engine integrates the held axis into the deltas once per frame, so a script that reads both turns twice as fast as it should.

Mobile touch controls

A scene's player controls overlay can provide one movement stick plus one look stick. The movement control can be a joystick or D-pad; a look control is always a joystick. Each stick can be placed on the left or right side of the screen, and each chooses independently how it positions itself.

Stick placement modes

  • Drift (the default, and the recommended one) — the ring is always on screen, stays wherever the player last let go of it, and migrates most of the way toward each new touch. Over a few touches it settles where that player's thumb actually lands, on their device and in their grip, without a settings screen.
  • Dynamic — nothing is drawn until the player touches, then the ring appears under the thumb and disappears on release. The most screen space, but a new player sees no controls at all.
  • Fixed — the ring is pinned to one spot and always returns to it. Predictable and learnable, but less forgiving: a touch that lands off-centre deflects the stick immediately.

Drift and dynamic also drag the ring along once the thumb passes its rim, so a long push never runs out of travel.

The overlay sizes itself to the device. The ring's radius is a fixed number of CSS pixels, because a thumb is the same size on a phone and on a tablet, while its resting position is a fraction of the viewport, because a tablet is held with the hands further apart. Both respect the display's safe-area insets.

What a stick feeds your script

input.moveAxisX and input.moveAxisY carry the stick's actual deflection, already through the player's dead zone and response curve. input.moveForward, input.moveBackward, input.moveLeft and input.moveRight are a convenience for scripts that only need a direction, and a stick lights one only past 0.4 of deflection in that direction.

That threshold matters because the booleans carry no magnitude, so a script reading only them treats each as full input. Lighting them on any deflection at all made a thumb three degrees off centre indistinguishable from a deliberate push — which is invisible on a character but made vehicles weave badly, since no thumb holds an exact heading. Read the axes wherever proportional control matters, and see Vehicles, constraints, and navigation for what a driving script needs on top of them.

Action buttons

You can add up to six action buttons and place each one on the left, center, or right. Give every authored action a stable snake_case ID; Lua reads its state at input.actions.<id>. The reserved action IDs jump and primary_fire also maintain the compatibility aliases input.jump and input.primaryFire.

For a conventional first-person setup, choose the FPS controls preset. It places a drifting movement stick on the left, look on the right, and the Fire and Jump buttons in the center.

Every action must be authored. An action must be included among the scene's authored buttons to receive either a touch button or a desktop digit binding. jump (Space) and primary_fire (left mouse) retain their fixed desktop bindings; custom authored actions map to digits 1 through 9 in author order, skipping those two reserved actions. Scenes authored before action buttons existed may have no buttons set at all.

Authored actions are how a scene gets verbs beyond the built-in two. Melee, grenade, reload, weapon-swap and crouch are all ordinary authored actions read from input.actions; there is no separate binding to request for each one. Buttons are authorable through scenes_update as well as the editor, so an agent can define them headlessly.

Keys in input.actions appear only once a button has been pressed, so an untouched action reads nil rather than false. Test it for truth, not against false.

Crouching in particular has no built-in binding — the input bus has no crouch boolean — so drive setCrouched() from an authored action or from your own controller logic. See Characters.