Input
The engine's controls (src/engine/input/) turn fingers into numbers your game loop reads every frame. Use them instead of Pressable buttons for anything held: React Native gives a touch to one view at a time, so two Pressables cannot be held together (a player could not steer while holding gas). Each control tracks every finger in one gesture and writes into a plain object, without re-rendering React per frame. In a desktop browser the same controls also answer to the keyboard.
| Control | For | Writes |
|---|---|---|
TouchControls | Racing: steer left and right, gas, brake | InputState |
Joystick | A floating thumb stick alone (top-down, survivors, a kingdom sortie) | CharacterInput and/or onChange |
CharacterControls | Stick, look pad and action buttons together (third person, platformers, shooters) | CharacterInput |
All three need the app root to be inside GestureHandlerRootView. The template's src/App.tsx already does that.
Smallest working example
A character game's controls: a floating stick on the left half, a look pad and a jump button on the right. The loop reads them once per frame with consumeInput.
import React, { useMemo, useRef } from 'react';
import { View } from 'react-native';
import { useFrame } from '@react-three/fiber';
import {
CharacterControls,
EngineCanvas,
consumeInput,
createCharacterInput,
createCommand,
type CharacterInput,
type GameScreenProps,
} from '@/engine';
const Loop = ({ input }: { input: CharacterInput }) => {
const cmd = useRef(createCommand());
useFrame(() => {
const c = consumeInput(input, cmd.current);
// c.moveX / c.moveY: the stick on the unit disc (+y = up on screen)
// c.lookDX / c.lookDY: radians to turn this frame
// c.jump: true for the one frame the button went down
if (c.jump) console.log('jump');
});
return null;
};
export const Screen = (_props: GameScreenProps) => {
const input = useMemo(() => createCharacterInput(), []);
return (
<View style={{ flex: 1 }}>
<EngineCanvas>
<Loop input={input} />
</EngineCanvas>
<CharacterControls input={input} />
</View>
);
};
The controls are absolutely positioned over the whole screen, so render them after the canvas. Put HUD buttons in a later sibling with pointerEvents="box-none" so they sit on top. To move a character with this command, pass it to stepCharacter (Character and cameras).
TouchControls (racing)
Four zones in a bar at the bottom: steer left and right on one side, BRAKE and GAS on the other. It writes an InputState:
interface InputState {
steer: number; // -1 (left) .. 1 (right)
throttle: number; // 0 .. 1
brake: number; // 0 .. 1
action: boolean; // a rising-edge flag the game loop sets back to false
}
import React, { useMemo } from 'react';
import { View } from 'react-native';
import { TouchControls, createInputState } from '@/engine';
export const Controls = ({ finished }: { finished: boolean }) => {
const input = useMemo(() => createInputState(), []);
return (
<View style={{ position: 'absolute', left: 0, right: 0, bottom: 0 }}>
<TouchControls input={input} disabled={finished} layout="classic" />
</View>
);
};
| Prop | Type | Default | What it does |
|---|---|---|---|
input | InputState | required | The object it writes |
disabled | boolean | false | Ignore touches and keys |
layout | 'classic' or 'mirrored' | 'classic' | classic: steering left, pedals right. mirrored swaps them (left-handed players) |
onTouchCount | (count: number) => void | none | Fingers down, for development telemetry |
Zones have testIDs btn-left, btn-right, btn-gas, btn-brake and accessibility labels Steer left, Steer right, Accelerate, Brake, so device automation can hold them. The racing module (defineRaceGame) uses this control.
createInputState() makes a fresh InputState. InputContext and useInputState() share one through React context: useInputState() returns the provided object, or a component-local one when there is no provider.
Joystick
A floating virtual stick: the base appears where the thumb lands and stays there, the knob follows the thumb inside the rim. The reading uses a scaled radial dead zone (output starts at 0 at the dead zone's edge and reaches 1 at the rim, so slow movement is possible) over a travel measured in millimetres, so it feels the same on every phone. The knob is drawn with Reanimated shared values. One gesture tracks the stick's finger and ignores the rest, so HUD buttons above it keep working.
import React, { useMemo } from 'react';
import { View } from 'react-native';
import { Joystick, createCharacterInput } from '@/engine';
export const Sortie = ({ paused }: { paused: boolean }) => {
const input = useMemo(() => createCharacterInput(), []);
// read input.moveX / input.moveY in your useFrame
return (
<View style={{ flex: 1 }}>
<Joystick input={input} zone="full" disabled={paused} />
</View>
);
};
| Prop | Type | Default | What it does |
|---|---|---|---|
input | CharacterInput | none | Gets moveX and moveY on every move |
onChange | (reading: JoystickReading) => void | none | Called on every change, and once with an idle reading on release |
zone | 'full', 'left', 'right' or { left?, top?, right?, bottom? } | 'full' | Where a touch starts the stick. full: anywhere below the top 14 percent. left and right: that half, below the top 30 percent. A rect is fractions of the screen ({ top: 0.3 } is everything below 30 percent) |
radiusMm | number | 11 | Travel to full deflection in millimetres, clamped to 7 to 16 |
deadZone | number | 0.12 | Fraction of the travel ignored around the centre (0 to 0.5) |
dynamic | boolean | false | Drag the base along when the thumb passes the rim (instant reversals) |
stayVisible | boolean | false | Keep the base on screen between touches |
disabled | boolean | false | Ignore touches and keys |
color | string | '#FFFFFF' | Ring and knob tint |
onTouchCount | (count: number) => void | none | Development telemetry |
testID | string | 'joystick' | On the root view (accessibility label Move joystick) |
JoystickReading is { x, y, magnitude, angle, active }: x and y on the unit disc (+y up on screen), magnitude 0 to 1 after the dead zone, angle in radians (0 is right, counter-clockwise, NaN when idle).
The maths is exported for your own sticks and for bots that pretend to hold one: readStick(dx, dy, radius, deadZone, out?), followTouch(center, tx, ty, radius, dynamic, out?), inZone(zone, x, y, w, h), mmToPoints(mm), idleReading() and DEFAULT_JOYSTICK ({ radius: 71, deadZone: 0.12, dynamic: false }, radius in points: 11 mm).
CharacterControls
Stick, look pad and buttons in one multi-touch gesture, so moving, looking and jumping happen at once and fingers may slide between buttons (except a look button, which keeps its finger). The stick floats where the thumb lands on its half; dragging on the other half turns the camera; buttons sit in the bottom corner opposite the stick.
| Prop | Type | Default | What it does |
|---|---|---|---|
input | CharacterInput | required | The object it writes |
disabled | boolean | false | Ignore touches and keys |
stick | 'left' or 'right' | 'left' | Which half the stick lives in |
look | boolean | true | Dragging on the other half turns the camera. false hides the look zone and touches there do nothing; buttons with look: true still aim |
buttons | ControlButton[] | one Jump button (id: 'jump', size 92) | Action buttons |
stickRadius | number | 56 | Stick travel in points, clamped to 40 to 90 |
deadZone | number | 0.12 | Fraction of the radius ignored |
secondThumb | boolean | false | No fixed buttons: the first touch anywhere is the stick, a second touch while it is held presses the first button where that thumb lands, and dragging it turns the camera |
lookArea | { left, top, right, bottom } | { left: 0.26, top: 0.32, right: 0.74, bottom: 0.72 } | With secondThumb: where a first touch looks around instead of moving, as screen fractions (the middle, where the character stands) |
onTouchCount | (count: number) => void | none | Development telemetry |
A ControlButton is:
| Field | Type | What it is |
|---|---|---|
id | string | jump, sprint, a and b are understood by consumeInput; other ids are read from input.held and input.pressed |
label | string | The text on the button |
accessibilityLabel | string, optional | Defaults to label |
size | number, optional | Diameter in points. Default 76 |
color | string, optional | Border and fill tint |
look | boolean, optional | The finger on this button also turns the camera while it drags (a FIRE button: hold to shoot, drag to aim). The button stays held until that finger lifts, wherever it slides |
<CharacterControls
input={input}
buttons={[
{ id: 'jump', label: 'JUMP', accessibilityLabel: 'Jump', size: 92 },
{ id: 'a', label: 'GRAB', accessibilityLabel: 'Grab' },
]}
/>
Automation handles: the root is character-controls, the stick zone stick-zone (label Move stick), the look zone look-zone (label Look), each button btn-<id>; with secondThumb, the floating button is btn-thumb.
CharacterInput and consumeInput
CharacterInput is what the stick and the thumbs did since the loop last looked:
| Field | What it is |
|---|---|
moveX, moveY | The stick on the unit disc, +y up on screen |
lookDX, lookDY | Look-pad drag since the last consume, in pixels |
held | Buttons under a finger right now, by id |
pressed | Buttons that went down since the last consume, by id |
| Function | What it does |
|---|---|
createCharacterInput() | A fresh, zeroed input |
resetCharacterInput(input) | Zero everything (a respawn) |
consumeInput(input, out, lookSensitivity?) | Fill a CharacterCommand for this frame and clear what was consumed (look deltas, rising edges). Returns out |
DEFAULT_LOOK_SENSITIVITY | 0.0048 radians per pixel of drag |
consumeInput maps pressed.jump to jump, pressed.a to actionA, pressed.b to actionB and held.sprint to sprint. Dragging right turns the view right; dragging down looks down. createCommand() makes the CharacterCommand to fill.
Keyboard in a desktop browser
A mouse is one finger: it cannot hold gas and steer, or move and jump. So in a browser each control also listens to the keyboard (input/keyboard.ts) and feeds the same input the thumbs do. A game built on these controls plays with keys without knowing it. On the phones no listener is added.
| Control | Keys |
|---|---|
TouchControls | A or Left steer left, D or Right steer right, W or Up gas, S, Down or Space brake |
Joystick | WASD or the arrows push the stick to full tilt in eight directions |
CharacterControls | WASD or the arrows move. By position in buttons: the first is Space, the second J, E or F, the third K or Q, the fourth L. A button with id sprint is Shift wherever it is listed. Dragging the mouse on the look half turns the camera |
Keys are KeyboardEvent.code values (the physical key), so WASD stays under the left hand on any layout. A thumb on the stick wins over the keys. Keys are ignored while an input field has focus or Cmd, Ctrl or Alt is held, and the browser's scrolling on arrow keys and Space is cancelled while a control listens. Leaving the tab releases every key.
Gotchas
- Touches are mapped in the control's own coordinates (measured with
measureLayout), never window coordinates. The Moonspell app shows games scaled in its feed and zooms them; window positions measured once go stale. Follow the same rule in your own controls. - Keep zones at least 72 points and 12 points apart. Thumbs are imprecise.
- Show pressed state from React state that changes only when the set of pressed buttons changes, as these controls do. Never per frame.
- A finger that lands on a plain button holds whichever button is under it as it slides, and releases when it leaves them all. A finger on a
look: truebutton keeps that button until it lifts, and its drag adds tolookDXandlookDYlike the look pad. useKeys,keyAxisandkeysHoldButton(ininput/keyboard.tsandinput/keyMap.ts) are not exported from@/engine. Import them by path if you build your own control.- For menu and HUD buttons that are tapped, not held, use
PressScalefrom@/engine/ui(HUD and UI). For taps and drags in a flat game,GameCanvas2Dhas its own pointer events (The 2D layer).