# Remote (motion controller)

Remote is a portrait controller held in one hand, with the top of the phone pointing at the game screen. It sends ordinary gamepad input plus motion and gestures. Touch controls remain usable when motion is off or unavailable.

## Ask for Remote

```js
host.setView(playerId, {
  kind: 'remote', hand: 'right', stick: false,
  buttons: [{ id: 'face.south', label: 'Swing' }, { id: 'face.east', label: 'Lob' }],
});
```

`buttons` contains one or two entries, in the order shown, with nonblank labels of at most 12 UTF-16 code units. `stick` is required. Optional `hand` suggests a hand; the player's saved Hand setting wins. Saved Buttons and Stick settings also override the game's suggestion. Without a Remote view, the defaults are Right, Two, Off and A/B. On a gamepad view, `modes: ['remote', 'gamepad']` or `motion: 'remote'` suggests Remote in MENU; it does not override a saved mode. Supporting browser controllers advertise `input.remote` in hello capabilities. A dedicated `kind: 'remote'` view opens Remote unless the player has explicitly chosen another mode. Other modes still send the same positional A/B input.

| Input | Meaning |
| --- | --- |
| `buttons[0]`, `buttons[1]` | A / B (`face.south`, `face.east`) |
| `axes[0]`, `axes[1]` | Optional stick, right/down positive; zero when off |
| `axes[2]`, `axes[3]` | Pointer, right/down positive, clamped to −1…1 |
| CENTER | Current aim becomes straight ahead; also sends `remote-center` |
| MENU | Local settings; sends no game button |

## Layout

The browser uses a waisted gray shell, an olive LCD, a raised thumb deck and round keys in dark wells. Left mirrors the LCD/key columns and thumb deck. A sits slightly toward the holding hand; B is reached by rocking toward the palm. All measurements below are CSS pixels, relative to the shell's top left. Physical reach still needs real-phone checks.

Let `W/H` be the shell's width/height and `s = min(1, W/350, H/780)`. Top/bottom margins are `max(12, safe inset)`; each side reserves `max(20, safe inset)`. Center the shell, with width capped at **350**. With zero safe insets, 390×844 → **350×820**, 430×932 → **350×908**, and 375×667 → **335×643** (`s = 0.824359` on the smallest).

| Part | Geometry |
| --- | --- |
| Top cluster | x inset `22s`, y `24s`, height `max(100,112s)`; LCD fills the first column, second column **64** wide; gap **12**. CENTER/MENU stack with targets at least **44** high. Left reverses columns. LCD border **7**, corner radius **13**. |
| Status | x inset `22s`; y `header bottom + 24s`. Motion M1–M5, N3/N4, J1 and R1–R3 use the slim strip. Other codes show their action text. Buttons stay **44** high. Live motion has a **72** high strip. Full explanations remain in Connection details. |
| Aim glass | x inset `30s`; `T = max(232s, status bottom + 16s)`. Without a code, reserve **72** below the status origin. Border **6**, radius **20**. Height `max(44, min(104s, Y − (stick ? 54 : 48)s − Gs − T))`, where `G=48` for a slim/no status, otherwise **24**. Hide the hint for full status text. |
| Thumb rest | `X = 0.56W` (Right), `0.44W` (Left). Without Stick, `Y = max(0.60H, T + 160s)`. With Stick, `Y = min(H − 296s, max(0.54H, T + 206s))`. |
| Stick | Diameter **108s**, center `(X,Y)`, outer collar **6s**, cap **68%**. Drag radius remains **32%** of the target diameter. |
| A / B | Diameters **96s / 76s**; outer collars **10s** each. A center `(X, Y + (stick ? 128s : 0))`. B center `(X ± 64s, Y + (stick ? 228s : 100s))`; plus for Right, minus for Left. No edge clamp is needed. |
| Raised deck | Top `Y − (stick ? 72 : 66)s`; left `min(X−68s, Bx−54s)` when Two, otherwise `X−68s`. Width **186s / 136s** for Two/One; height `(stick ? 128 : 0)s + (Two ? 220 : 132)s`. Corner radii **72,64,68,76**, reversed for Left. |

The shell outline has a **6**-pixel bevel and a **1**-pixel inner seam inset **4**. Its top curve goes from `(44,3)` through `(3,3)` to `(3,48)`. The left side runs to `(3,.31H)`, then a cubic through `(3,.43H)`, `(29,.47H)` to `(25,.58H)`, a line to `(16,.84H)`, and a cubic through `(12,.96H)`, `(42,H−3)` to `(72,H−3)`. Mirror this path about `W/2` to close the right side. The grip ribs are **7** apart, in a **70s** high area inset **57s**, **74s** above the bottom. With Stick this becomes **28s** high and **45s** above the bottom. Hide ribs and the bottom wordmark when `Y+276s > H−76s` with Stick. These numbers replace the earlier 68%/32% rest, 112s stick, and 80s/70s B offsets.

The LCD shows up to three validated `instruments.lcd` lines (20 ASCII characters each), plus the player's name, seat, route and current code. Dedicated Remote views carry readouts in `setView`; the existing `instruments()` patch API remains for gamepad views, whose readouts also appear when the player selects Remote. Readout updates use text nodes and remain readable by screen readers without announcing every score update. A secondary game notice during a connection error occupies one line; its full text stays readable in Connection details and to screen readers.

Initial touches outside controls do nothing. Captured button touches remain held during a slide until release/cancel.

With motion off, drag the aim glass to aim, or use the optional stick. A is an ordinary button; the host helper below turns its press edge into a medium swing. No fake sensor values are sent. Motion appears in every sensor-backed Remote input frame; while permission is off, missing, denied or samples have stopped, `motion` is absent. Clear motion after 1.2 seconds without samples. Use negotiated `motion-state` for explicit readiness.

## Motion units

These units are fixed for Remote. They do not reinterpret older gamepad/wheel/flight adapters.

- Device axes: x right across the portrait display, y toward its top, z out of the glass.
- `orientation`: unit quaternion `[x,y,z,w]`, active device-to-world rotation. World X is right, Y forward, Z up. Gravity points −Z. CENTER removes yaw only, so Z stays upright; pointer pitch also stores the center elevation. The first valid sample centers automatically. Quaternion signs `q` and `−q` are equivalent.
- `gyro`: device-frame rotation rate, rad/s, each component −100…100.
- `accel`: device-frame user acceleration after removing gravity, in g (`1 g = 9.80665 m/s²`), each component −100…100.
- `t`: nonnegative monotonic milliseconds on the same clock as `input-time`, before binary wrapping. Hosts forward gesture `t` unchanged; it is **not** the host's converted input `time`. Validate game scoring against its own clock/phase.

Browser orientation is the W3C intrinsic Z-X′-Y″ rotation: `q = qZ(alpha) × qX(beta) × qY(gamma)` after converting degrees to radians. Do not compensate for screen orientation: Remote uses portrait device axes. DeviceMotion gyro is `[beta,gamma,alpha] × π/180`, not `[alpha,beta,gamma]`. Prefer `event.acceleration / 9.80665`. If only `accelerationIncludingGravity` exists, subtract `rotate(conjugate(rawQ), [0,0,1])` after dividing by 9.80665. This assumes the browser's resting accelerometer measures upward support (+g). Reject null/nonfinite readings; never turn null into a sensor zero. A raw orientation sample must exist before motion is sent.

iOS: use CMDeviceMotion with `xArbitraryZVertical`, device-frame rotationRate and userAcceleration. Android: game rotation vector, gyroscope and linear acceleration divided by 9.80665. Convert the platform's active/passive quaternion convention into device-to-world xyzw before running these vectors. These native adapter conversions need device verification.

## Algorithm 1

The reference implementation is [`src/remote.mjs`](../src/remote.mjs). The portable [JSON vectors](../testing/motion-vectors/vectors.json) and [runner contract](../testing/motion-vectors/README.md) pin these numbers. Use double precision and compare within `0.000001`.

### Pointer

Normalize q. Reject samples unless t is finite in `0…Number.MAX_SAFE_INTEGER`, quaternion components are within −1…1 and its norm differs from 1 by at most 0.01, and both motion vectors contain three finite values within −100…100. Ignore duplicate/backward sample times completely.

1. Rotate device forward `[0,1,0]` by raw q to get f. Heading `h = atan2(f.x,f.y)`, elevation `p = atan2(f.z,hypot(f.x,f.y))`. If the horizontal length is below 0.1, retain the previous heading (initially 0).
2. First sample stores center `(h0,p0)`. CENTER stores the current `(h,p)` and zeros the smoothed pointer. With no pose yet, the next sample supplies the center.
3. Centered output quaternion is `qZ(h0) × rawQ`, normalized. It keeps world Z up. Pointer target is `x = clamp(wrap(h−h0)/(25π/180))`, `y = clamp(−(p−p0)/(18π/180))`. Wrap is `atan2(sin(angle),cos(angle))`.
4. Smooth each pointer component: `value += (target−value) × (1−exp(−dt/25))`, with dt in ms. First sample, or a gap over 250 ms, snaps to target. No gyro/acceleration filter is applied. Output vectors keep the current full-rate readings; input transport coalesces them at its ordinary cadence.

### Swings

Rotate gyro and accel by the centered output quaternion into world coordinates. Rotate `[0,1,0]` to f. Aim velocity is `v = worldGyro × f`. Speed is `hypot(deviceGyro)` in rad/s.

- Start a swing when speed is **at least 3** and `hypot(v.x,v.z)` is **at least 2**. This rejects a pure roll around the phone's pointing axis. Swing detection cancels a pending flick.
- Keep the greatest speed and the direction and t at that peak. Equal peaks keep the first sample.
- If `abs(v.x) >= abs(v.z)`, negative v.x is a right hand's forehand and a left hand's backhand. Positive v.x is the reverse. Otherwise negative v.z means overhead (downward stroke), positive v.z underhand.
- End after speed stays **below 2 for at least 40 ms**. The interval from start to the first low sample must be **at least 40 ms** and the peak **at least 4.5**. Emit `swing` with `power = clamp((peak−3)/9,0,1)`, `speed = peak`, peak `dir`, current `hand`, peak `t`.
- If elapsed time since start exceeds **500 ms**, cancel and enter the cooldown below. A rejected short/weak candidate ends without an event.

### Flicks

A flick is a linear pulse, not an additional event for the same swing. In centered world coordinates take `[max(0,ay), max(0,az), max(0,−az)]`; their maximum is strength in g. Direction is the first maximum in order **forward, up, down**. Sideways or backward acceleration alone does not start a flick.

- When no swing is pending, speed is **below 3**, and strength is **at least 0.8**, start a flick. Keep the greatest strength and its direction/t; equal peaks keep the first sample.
- End after strength stays **below 0.35 for at least 20 ms**. The interval from start to first low must be **at least 20 ms**, the peak **at least 1.2**, and total candidate duration **at most 180 ms**. Emit `flick` with `power = clamp((peak−0.8)/2.2,0,1)`, peak `dir` and peak `t`.
- Cancel if duration exceeds 180 ms or angular speed reaches 3. A qualifying swing takes priority on that sample.

### Rearming and delivery

After an accepted gesture or CENTER, cancel both candidates and block detection for **300 ms from completion/CENTER**. Rearm only after both that time has passed and speed has stayed **below 2** and flick strength **below 0.35** for **100 continuous ms**. A sensor gap over **250 ms** cancels candidates and requires that same quiet period; it does not change the pointer center. A hand change cancels candidates. Pausing, backgrounding or losing sensor samples cancels candidates and requires a quiet period before gestures resume.

Emit events as soon as their ending condition is observed by the next sensor sample. No extra network-frame wait or gesture timer. Event t identifies the peak, so detection latency (40 ms swing / 20 ms flick low interval) does not move the recorded hit. Debounce is shared between both gesture types. Input uses the existing InputPacer; do not add another input timer or force a frame on every sample. Events use the ordinary reliable event channel. This stays within the existing 180-message/s limit at ordinary sensor rates.

```js
{ event: 'swing', payload: { power: 0.5, speed: 7.5, dir: 'forehand', hand: 'right', t: 1200 } }
{ event: 'flick', payload: { power: 0.5, dir: 'forward', t: 1400 } }
{ event: 'remote-center', payload: { t: 1800 } }
```

Validation reserves these three names. Payloads accept only the fields shown. Power is 0…1, swing speed 0…200 rad/s (a defensive bound), directions/hand are enumerated, and t is finite and nonnegative. Unknown custom event names retain the ordinary object-payload rules. Both ControllerHost and RelayRoom validate reserved events before delivering them. A notice uses the same [status contract](status-codes.md) on any view.

## Helpers and a touch fallback

Import `remotePointer`, `remoteSwing` and `createRemotePlayer` from the root, `/remote`, or `/relay-host`. `remotePointer(input)` returns a clamped `[x,y]`; `remoteSwing(frame)` returns the validated swing or null for a different event. It throws on a malformed swing. Keep one `createRemotePlayer()` per seat:

```js
import { createRemotePlayer } from '@controller-plus/sdk/remote';
const players = new Map();
host.on('join', ({ playerId }) => players.set(playerId, createRemotePlayer()));
host.on('input', ({ playerId, input }) => {
  const p = players.get(playerId);
  if (p) p.update(input); // no orientation means touch fallback
});
host.on('event', frame => players.get(frame.playerId)?.event(frame));
host.on('leave', ({ playerId }) => players.get(playerId)?.reset());
// In your game loop:
for (const [playerId, p] of players) {
  aimPlayer(playerId, p.pointer);
  for (const swing of p.readSwings()) hitTowardBall(playerId, swing.power, swing.aim);
}
```

`update(input, { motion, t })` can explicitly override motion readiness from negotiated motion status and supply a game clock for fallback hits. Without motion, A's rising edge queues one medium swing (`power: .5`, `speed: 7.5`, `fallback: true`); held frames queue no more. An active left stick aims; otherwise the pointer does. Actual events queue `fallback: false`. `readSwings()` drains the bounded queue (latest 32). `recenter()` stores the current aim as a local zero; later updates are relative to it; `remote-center` from the phone clears that local offset because the phone has already centered. `reset()` clears held edges, aim and queued swings on leave. The helper cannot decide which ball to hit; the game uses `aim` and its own ball position.

## Browser permission

Motion requires a secure page and supported browser APIs. On iOS Safari, tap the **MOTION** key on Remote or turn Motion on in MENU. Both permission requests start inside that tap; no prompt is requested during QR join. A denied request, an insecure page, missing sensors or stopped samples shows a short code and touch instructions. A and touch aim work throughout. MENU offers Connection details. [Report a problem](status-codes.md#report-a-problem) appears only when the host supplies its support email.

The main LCD shows the player, route, measured round-trip ping and game readout. The smaller LCD below it shows status codes, instructions and game notices. Its up/down keys scroll long messages. Drag on the glass below it to aim. Tapping the main LCD opens Connection details. Long player names and route/network labels wrap within the fixed glass; the type fits the available height without dropping words. Four-digit pings are included in the layout checks.

The raised thumb deck includes the full button and stick collars, with at least 0.3 of each control's diameter clear beyond its collar. Short phones use a wider button arrangement; landscape places the LCDs beside the deck. Left-hand mode mirrors the controls. MENU also offers Contact & desktop app and a scrollable Settings guide, using the same LCD and physical keys.

Headless Chrome checks event conversion, gestures, input, menus and screenshots. It cannot verify sensor alignment, grip comfort, Safari prompts or sensor noise on real phones.
