Controls (player-visible signs — do not ship inverted)
Read this end-to-end before writing movement/steer/flight code for any game
that uses WASD, arrows, a chase camera, or a flying craft. Do not skip this
and only open racing-kart or fps — those genre files assume you already
know these signs. Inverted A/D is the most common ship-blocker in vehicle and
flight demos.
Pair with building-games (loop, camera, 3D orientation) and
building-games/references/input.md (keydown state, gamepad, touch). This
skill owns what left/right/up mean to the player and how you prove it.
0. Hard rules (fail the build if broken)
- Player-visible left/right is law. From a chase / behind camera while
the craft moves forward:
- A / ← → nose (or bank) turns left on screen
- D / → → nose (or bank) turns right on screen
- Never reuse FPS strafe as vehicle steer. FPS “D → +right vector” is position on the ground plane. Vehicle A/D is yaw (or roll) rate, not a strafe offset. Mixing them is the #1 cause of inverted A/D.
- You must run a control self-test (§5) before saying done. Screenshot-only QA is not enough. If A turns right, flip the steer/roll sign, retest, then ship — do not invent a new coordinate story.
1. Shared 3D basis (use this everywhere)
three.js: right-handed, +Y up, meshes face +Z, cameras look −Z.
Yaw-only heading on XZ (ground vehicles, walkers, most arcade craft):
// yaw = 0 faces world −Z; +yaw is CCW about +Y (nose moves toward −X)
forward = (-sin(yaw), 0, -cos(yaw))
right = ( cos(yaw), 0, -sin(yaw)) // = normalize(cross(forward, worldUp))
With a chase cam behind the craft (camera near position - forward * dist):
| Player sees | World (this basis) | Input |
|---|---|---|
| Nose left | +yaw | A / ← must produce +yaw (or equivalent bank-left for planes) |
| Nose right | −yaw | D / → must produce −yaw |
If your basis differs, keep one consistent pair — but the player-visible row above is mandatory.
2. Genre maps
2a. FPS / on-foot (strafe, not steer)
W = +forward, S = −forward, D = +right, A = −right // position, not yaw
mouse: yaw -= movementX * sens; pitch -= movementY * sens; clamp pitch
Body yaw for look; movement uses yaw only (do not apply pitch to walk).
2b. Ground / water vehicle (kart, bike, jetski, boat, tank, rover, snowmobile)
Arcade body with heading/yaw and forward speed:
// Input (held keys → actions once per frame)
let steer = 0; // -1..+1, player-visible
if (keys.has('KeyA') || keys.has('ArrowLeft')) steer += 1; // LEFT
if (keys.has('KeyD') || keys.has('ArrowRight')) steer -= 1; // RIGHT
// Optional: steer = clamp(steer + gamepadX, -1, 1) with same sign convention
// Integrate (speedFactor ~ 0..1 from |speed|)
const reverse = speed >= 0 ? 1 : -1; // wheel-left still feels left in reverse
yaw += steer * turnRate * speedFactor * reverse * dt;
// Move along heading
const fx = -Math.sin(yaw), fz = -Math.cos(yaw);
position.x += fx * speed * dt;
position.z += fz * speed * dt;
Canonical bug (do not copy):
// WRONG — this is what ships inverted A/D in the wild
if (KeyA) steer -= 1;
if (KeyD) steer += 1;
yaw += steer * turnRate * dt; // A → −yaw → nose RIGHT on chase cam
If you already wrote KeyA → steer--, either swap the key mapping or
negate once at integrate (yaw += -steer * …) — then run §5. Do not flip
twice (keys + integrate + bank mesh).
2c. Fixed-wing flight (airplane, glider, RC plane)
| Input | Action | Player expectation |
|---|---|---|
| A / ← | Roll left (aileron) | Left wing down / bank left |
| D / → | Roll right | Right wing down / bank right |
| W / ↑ | Pitch (pick one scheme and label HUD) | Usually nose down or pull-up — be consistent |
| S / ↓ | Opposite pitch | |
| Q / E | Yaw / rudder (optional) | Q left, E right |
- A/D are not strafe and not “ground steer with FPS signs.”
- Apply roll in the craft’s local forward axis with a sign that matches bank left on A. If the mesh banks the wrong way, flip one sign on the roll apply (or on the A/D → roll mapping), not the whole basis.
- Coordinated turn: positive bank should produce a turn that matches the bank direction under the chase/external cam.
2d. Heli / drone / 6DOF
Document the scheme on a start overlay. Minimum:
- Throttle/altitude on discrete keys must not stick “always up” after one press (use held state or explicit up/down).
- Strafe/yaw: A left, D right in the craft’s horizontal frame (player-visible).
2e. 2D side-scroller / platformer
D / → moves right on screen; A / ← moves left. Gravity only inverted if the genre is explicitly upside-down.
3. Camera must agree
- Chase cam:
desired = craftPos + up*height + forward*(-followDist); lerp;lookAt(craft). - Compute
forwardonce for both movement and camera — do not rebuild with opposite yaw sign in the camera path. - Debug order: (1) keys register → (2) signs correct (§5) → (3) camera agrees.
4. Input plumbing (brief)
- Track keys with a
Setonkeydown/keyupusingevent.code; clear onblur/visibilitychange. Move in the game loop with dt, not in the key handler. - Unify keyboard + touch + gamepad into actions (
throttle,steer,pitch,roll, …). Seebuilding-games/references/input.md. - Touch: left stick = move/steer, right = actions; ≥44px targets.
5. Mandatory control self-test (before “done”)
Screenshot-only is not enough for any craft with A/D.
5a. Player-visible checklist (every vehicle / flight build)
While moving forward (speed > small epsilon), chase cam behind:
| Hold | Must observe within ~0.5s |
|---|---|
| A | Nose or bank moves left on screen |
| D | Nose or bank moves right on screen |
| W (ground) | Speed increases / moves along facing |
| S (ground) | Brakes or reverse (as designed) |
If A fails: flip steer/roll sign once, retest both A and D.
5b. Minimal test interface (implement this)
Expose a tiny hook so you (and automated QA) can prove signs without guessing private closures:
// e.g. src/game/controlsTest.ts — dev/QA only is fine
export type ControlsProbe = {
getYaw: () => number; // radians; or getHeading()
getSpeed: () => number;
/** Inject held actions for one frame path; clear after test */
setSteer?: (v: number) => void; // -1..1, same sign as production
setKeys?: (codes: string[]) => void;
};
declare global {
interface Window {
__controlsTest?: ControlsProbe;
}
}
Wire window.__controlsTest from the game loop when import.meta.env.DEV or a
?qa=1 flag is set.
5c. Automated smoke (run it)
In a short Playwright / agent script against the local preview:
- Start / click-to-play so the sim runs.
- Set forward throttle; wait until
getSpeed() > threshold. - Record
y0 = getYaw(). - Hold A (or
setSteer(+1)if that is your left convention) for 500ms. - Assert
deltaAngle(getYaw(), y0)has the left sign for your basis (with the §1 basis: left ⇒ yaw increased). - Repeat for D with opposite sign.
- Fail the build if either assert fails; fix signs; re-run.
Pseudo-assert for the §1 basis:
const wrap = (a) => Math.atan2(Math.sin(a), Math.cos(a));
// After holding A (left) while moving forward:
expect(wrap(yawAfter - yawBefore)).toBeGreaterThan(0.05);
// After holding D:
expect(wrap(yawAfter - yawBefore)).toBeLessThan(-0.05);
For planes, assert roll (or on-screen bank) the same way: A ⇒ bank left.
5d. What not to do
- Do not only test “D increases some internal variable.”
- Do not use FPS “D → +X when facing −Z” as the vehicle pass condition.
- Do not flip mesh bank, camera, and steer all at once when fixing — change one sign, retest.
6. Finish criteria (controls)
- Opened this skill before writing movement/steer/flight.
- Genre map chosen (§2) and start-screen / HUD labels match it.
- Chase-cam A/D player-visible test passed (§5a).
-
window.__controlsTest(or equivalent) available in dev/QA and used once. - No inverted bank mesh relative to roll/steer input.
- Keys are held-state + dt-scaled; no sticky thrust from a single Space tap unless intentional and labeled.
If any box is unchecked, the game is not done.