Touch / Mobile Controls

On mobile, the ship is driven by a virtual joystick anchored to wherever the player first puts their finger down. There is no fixed on-screen stick — the joystick origin recenters on every touchstart, so the player can place their thumb anywhere comfortable on the canvas.

Recenter on touchstart

When a touchstart fires and playerInput.isMobile is true, the touch’s clientX / clientY is recorded as touchStartX / touchStartY and joystickActive flips on. That point becomes the joystick origin for the duration of the touch. Lifting the finger (touchend) clears joystickActive and zeroes joystickMagnitude.

This means there is no concept of “drift” or “needing to find the stick” — every fresh touch resets the origin under the thumb.

Magnitude saturates at 60 px

During touchmove, the engine computes dx, dy from the touch origin and derives:

  • joystickAngle = atan2(dy, dx) — direction from origin to current finger position
  • joystickMagnitude = min(1, hypot(dx, dy) / 60) — normalized 0..1, saturating at 60 px radius

So 60 px of finger travel from the origin produces full magnitude (1.0). Pulling further does nothing extra — the stick is already maxed. This keeps the usable joystick area thumb-sized rather than screen-sized.

Thrust threshold: 10 px

isThrusting is set to hypot(dx, dy) > 10. Below 10 px of travel, the ship coasts; above 10 px, thrusters fire. This gives a small dead zone around the origin so micro-movements don’t cause unintended thrust pulses.

Bridge has authority while joystick is active

The on-screen React joystick (rendered by the metagame shell) calls bridge.setInput() to drive ship.targetAngle directly. While playerInput.joystickActive is true, Input.update() short-circuits — it does not override targetAngle from raw DOM mouse coordinates.

The contract:

  • React on-screen joystick owns input on mobile. It sets joystickActive = true and writes ship.targetAngle via the bridge.
  • input.ts DOM mouse path is the fallback for desktop. It only runs when joystickActive is false and isMobile is false.

This split means the React UI layer can render a styled joystick (with its own visual feedback) and the engine’s raw touch handling sits behind it as a backstop. The two never fight for targetAngle because the engine yields whenever the bridge has claimed the input.

See also