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
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 signsqand−qare 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 asinput-time, before binary wrapping. Hosts forward gesturetunchanged; it is not the host's converted inputtime. 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 (in the SDK download: src/remote.mjs). The portable JSON vectors (in the SDK download: testing/motion-vectors/vectors.json) and runner contract (in the SDK download: testing/motion-vectors/) 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.
- Rotate device forward
[0,1,0]by raw q to get f. Headingh = atan2(f.x,f.y), elevationp = atan2(f.z,hypot(f.x,f.y)). If the horizontal length is below 0.1, retain the previous heading (initially 0). - 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. - Centered output quaternion is
qZ(h0) × rawQ, normalized. It keeps world Z up. Pointer target isx = clamp(wrap(h−h0)/(25π/180)),y = clamp(−(p−p0)/(18π/180)). Wrap isatan2(sin(angle),cos(angle)). - 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
swingwithpower = clamp((peak−3)/9,0,1),speed = peak, peakdir, currenthand, peakt. - 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
flickwithpower = clamp((peak−0.8)/2.2,0,1), peakdirand peakt. - 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.
{ 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 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:
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 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.