# Recipes

Use these patterns with the [browser helper](web-games.md) or [direct host](quickstart.md). Each recipe keeps player identity, input and private information separate. Start from the complete examples, then connect their state to your game loop.

## Party game lobby

The lobby below supports names, ready buttons, reconnecting seats and a host-controlled start. Only connected players count as ready. The host decides when to call `startRound`; players cannot start it with an arbitrary event. Export the invitation to your game UI as in the web guide.

<!-- example: lobby -->
```js
import { createRoom } from '@controller-plus/sdk/relay-host';
export const room = await createRoom({ game: { id: 'lobby-example', name: 'Party lobby' }, maxPlayers: 8, localControllers: false });
export const seats = new Map();
export let phase = 'lobby';
function show(id) {
  if (phase !== 'lobby') return room.view(id, { kind: 'gamepad', title: 'Round started' });
  room.view(id, { kind: 'choices', title: 'Party lobby', text: 'Ready to play?',
    submitted: seats.get(id)?.ready || false, status: 'Ready. Waiting for the host.',
    choices: [{ id: 'ready', label: 'Ready', event: 'ready' }],
  });
}
function publish() { room.state({ phase, players: [...seats].map(([id, p]) => ({ id, name: p.name, ready: p.ready, connected: p.connected })) }); }
room.on('join', ({ playerId, name }) => {
  const p = seats.get(playerId) || { ready: false }; Object.assign(p, { name, connected: true });
  seats.set(playerId, p); show(playerId); publish();
});
room.on('leave', ({ playerId }) => { const p = seats.get(playerId); if (p) p.connected = false; publish(); });
room.on('event', ({ playerId, event }) => {
  const p = seats.get(playerId);
  if (phase !== 'lobby' || event !== 'ready' || !p?.connected) return;
  p.ready = true; show(playerId); publish();
});
export function startRound() {
  const active = [...seats.values()].filter(p => p.connected);
  if (phase !== 'lobby' || active.length < 2 || !active.every(p => p.ready)) return false;
  phase = 'play'; for (const id of seats.keys()) show(id); publish(); return true;
}
```

Keep ready state under the stable seat ID when a phone reconnects. Decide whether a late join enters this round or watches until the next one; this simple example switches late joiners to the active gamepad. Show their name and connection status on the shared screen using text nodes. For typed game nicknames, use [Name entry](custom-controllers.md#name-entry).

## Local multiplayer with eight phones

Run the [complete direct server](quickstart.md#complete-server) with `maxPlayers: 8` on a computer reachable by the phones. Serve its `/join` and `/sdk/` assets; put the current QR on the shared screen. No hosted relay is required.

1. Set the advertised origin to the computer's LAN address and port. Join two phones first and verify distinct IDs.
2. Add the remaining phones. Allocate a game seat or viewport per ID; names alone do not identify seats.
3. Keep simulation and scores on the host. Store current input per player and release it on disconnect.
4. Resume a phone using its latest ticket. The direct host keeps the seat and private view but does not emit connected again.
5. An offline reservation still occupies a slot. If the owner removes it, regenerate the QR because the join token rotates.

Eight networked SDK players do not imply eight XInput devices: this recipe reads input inside your game and is separate from desktop driver limits. Use a network without client isolation. Show waiting/reconnecting labels and allow touch controls when motion permission is unavailable.

## Racing with the wheel

Suggest wheel mode and read steering plus two pedals. The built-in wheel sends ordinary input; the game chooses its throttle/brake model. This example also keeps a face-button brake for gamepad players. Physical pads can join, but do not receive phone views.

<!-- example: racing -->
```js
import { createRoom, movement, NEUTRAL } from '@controller-plus/sdk/relay-host';
export const room = await createRoom({ game: { id: 'racing-example', name: 'Wheel racer' } });
export const cars = new Map();
room.on('join', ({ playerId, source }) => {
  cars.set(playerId, { steering: 0, gas: 0, brake: 0, input: NEUTRAL() });
  if (source === 'phone') room.view(playerId, { kind: 'gamepad', title: 'Wheel racer', completeControls: true,
    modes: ['wheel', 'gamepad'], motion: 'steering', hint: 'Turn to steer. Right pedal accelerates; left pedal brakes.' });
});
room.on('input', ({ playerId, input }) => {
  const car = cars.get(playerId); if (!car) return;
  car.input = input; car.steering = movement(input)[0];
  car.gas = input.buttons[7]; car.brake = Math.max(input.buttons[6], input.buttons[1]);
});
function release(id) { const c = cars.get(id); if (c) Object.assign(c, { input: NEUTRAL(), steering: 0, gas: 0, brake: 0 }); }
room.on('leave', ({ playerId }) => release(playerId));
room.session.on('disconnected', () => { for (const id of cars.keys()) release(id); });
export function instruments(playerId, rpm, shift) {
  if (room.players.get(playerId)?.source !== 'phone') return;
  room.view(playerId, { kind: 'gamepad', title: 'Wheel racer', completeControls: true,
    modes: ['wheel', 'gamepad'], motion: 'steering',
    instruments: { rpm: Math.max(0, Math.min(1, rpm)), shift: !!shift, stall: false } });
}
```

Ask players to select Wheel and CENTER it in their playing position. Touch steering is the fallback. Send instruments only on meaningful changes, not every rendered frame; RPM lamps use normalized engine RPM, not raw revolutions/minute. Shift is a boolean prompt. Current browser and native controllers render these instruments. Keep a shift indicator on the game screen too, so it remains visible when a player uses another mode or an older client.

## Flight with instruments

Read roll and pitch from the left axes. Right-stick-up or right trigger provides throttle. This example uses the Sky Slalom convention, with south firing and east braking. It clears controls during connection loss and exports telemetry sending separately from input.

<!-- example: flight -->
```js
import { createRoom } from '@controller-plus/sdk/relay-host';
export const room = await createRoom({ game: { id: 'flight-example', name: 'Flight controls' } });
export const planes = new Map();
const neutral = () => ({ roll: 0, pitch: 0, throttle: 0, fire: false, brake: false });
function show(id, instruments = { rpm: 0, shift: false, stall: false }) {
  if (room.players.get(id)?.source !== 'phone') return;
  room.view(id, { kind: 'gamepad', title: 'Flight controls', completeControls: true,
    modes: ['flight', 'gamepad'], motion: 'flight', instruments,
    hint: 'Bank to turn. Pull back to climb. LEVEL centers the phone.' });
}
room.on('join', ({ playerId }) => { planes.set(playerId, neutral()); show(playerId); });
room.on('input', ({ playerId, input }) => {
  if (!planes.has(playerId)) return;
  planes.set(playerId, { roll: input.axes[0], pitch: input.axes[1],
    throttle: Math.max(0, -input.axes[3], input.buttons[7]),
    fire: input.buttons[0] >= 0.5, brake: input.buttons[1] >= 0.5 });
});
room.on('leave', ({ playerId }) => planes.set(playerId, neutral()));
room.session.on('disconnected', () => { for (const id of planes.keys()) planes.set(id, neutral()); });
export function instruments(playerId, { rpm = 0, shift = false, stall = false } = {}) {
  show(playerId, { rpm: Math.max(0, Math.min(1, rpm)), shift: !!shift, stall: !!stall });
}
```

`instruments` is valid on gamepad views. Current iOS, Android and browser controllers use RPM/shift for wheel lamps and stall for the flight warning. Missing telemetry clears these game-driven flags; do not infer a stall from controller pitch. Supply finite RPM and booleans. Render warnings on the game screen too, since older clients or other controller modes may not display them. Send a stall cue on transition into the warning, not continuously. Players may mute it.

## Turn-based game

A turn lamp guides the active player, but the host enforces whose turn it is. This example rotates through connected seats after a valid action; an offline seat is skipped. A returning player keeps the same ID and rejoins the order.

<!-- example: turns -->
```js
import { createRoom } from '@controller-plus/sdk/relay-host';
import { surface, column, lcd, keys } from '@controller-plus/sdk/surface';
export const room = await createRoom({ game: { id: 'turn-example', name: 'Take a turn' }, localControllers: false });
export let active = null, turnNumber = 1;
const connected = () => [...room.players.values()].filter(p => p.connected);
function show() {
  const players = connected();
  if (!players.some(p => p.id === active)) active = players[0]?.id || null;
  for (const p of players) room.view(p.id, { ...surface({ id: `turn/${turnNumber}`, turn: {
    you: p.id === active, name: room.players.get(active)?.name || '', color: '#5ee08a',
  }, root: column([
    lcd({ size: 100, title: `Turn ${turnNumber}`, text: p.id === active ? 'Your turn' : 'Wait for your turn' }),
    keys([{ id: 'play', label: 'PLAY', event: 'play', disabled: p.id !== active }]),
  ]) }), legacy: { kind: 'choices', title: 'Take a turn', text: p.id === active ? 'Your turn' : 'Wait',
    submitted: p.id !== active, choices: [{ id: 'play', label: 'Play', event: 'play' }] } });
}
room.on('join', show); room.on('leave', show);
room.on('event', ({ playerId, event }) => {
  if (event !== 'play' || playerId !== active || !room.players.get(playerId)?.connected) return;
  const players = connected(), index = players.findIndex(p => p.id === active);
  active = players[(index + 1) % players.length]?.id || null; turnNumber++; show();
  room.state({ turn: turnNumber, active });
});
```

Freeze the eligible roster at a round boundary if joining mid-round would be unfair. Use a private LCD for a hand or clue, and only publish public progress. Validate target/cost/action rules before advancing a real game.

## Desktop and Steam games

For an installed game that already supports controllers or keys, use the [desktop app](https://controller.plus/docs/desktop.html). This path needs no game SDK integration. It does not provide game-specific private screens unless the game integrates them.

1. Install the matching [desktop download](https://controller.plus/downloads), run it and scan its player QR.
2. Check the companion's input test before opening the game. A responsive phone test and a working OS gamepad are separate checks.
3. On Windows, gamepad mode needs the bundled Xbox-controller driver. XInput has four slots shared with other controllers. Linux needs access to `/dev/uinput`. The public macOS beta uses game keys rather than a virtual gamepad.
4. In Steam/the game, select the intended controller or key mapping and assign a local player. Avoid double mapping the same action through both Steam Input and a second key profile.
5. For keyboard-only games, choose Arrow keys or WASD and map the game's actions. Required platform permissions must be granted by the computer owner.
6. If the phone reconnects, verify controls release and the game keeps the intended seat. Mouse/keyboard system-control approval is separate and must be renewed for the connection.

Do not promise compatibility with every game, anti-cheat system or platform. Check the particular game's input screen. SDK events, custom trackpads and game views do not bypass desktop permission prompts. See [developer troubleshooting](troubleshooting.md#troubleshooting-for-developers) for distinguishing a game integration problem from a companion/driver problem.
