# Add phone controllers to a web game

Your game runs in a browser tab. Players scan a QR code and their phone becomes a gamepad for your game. You need no server, no account and no API key: the Controller+ relay at controller.plus passes messages between the phones and your page. Players need no app either; the controller opens in the phone's browser.

## The whole integration

```html
<div id="qr"></div>
<script type="module">
  import { createRoom, movement } from "https://controller.plus/sdk/src/relay-host.mjs";

  const room = await createRoom({ game: { id: "space-race", name: "Space Race" } });
  document.getElementById("qr").innerHTML = room.qrSVG; // players scan this

  const ships = new Map();
  room.on("join", ({ playerId, name }) => ships.set(playerId, { name, x: 400, y: 300, firing: false }));
  room.on("leave", ({ playerId }) => ships.delete(playerId));
  room.on("input", ({ playerId, input }) => {
    const ship = ships.get(playerId);
    if (!ship) return;
    const [x, y] = movement(input); // left stick and D-pad, each -1..1
    ship.vx = x * 300;
    ship.vy = y * 300;
    ship.firing = input.buttons[0] > 0.5; // bottom face button
  });
</script>
```

This works from a page on any https site, from `http://localhost` and `.test`/`.local` development hosts, and from an HTML file opened straight from disk.

`room.code` is a six-character room code and `room.joinURL` is the link inside the QR. Show the link as text too if players might join from another room. A room lasts six hours. Create a new one for a new session.

## What a phone sends

Every `input` event carries the phone's full controller state, not a change. Read it in your game loop or store it per player.

- `input.axes` is `[leftX, leftY, rightX, rightY]`, each from -1 to 1. Right and down are positive.
- `input.buttons` has 16 values from 0 to 1 in the standard gamepad order. The D-pad is separate from the stick; `movement(input)` combines both into one `[x, y]`.

| Index | Control | Index | Control |
| --- | --- | --- | --- |
| 0 | Bottom face button | 8 | Select |
| 1 | Right face button | 9 | Start |
| 2 | Left face button | 10 | Left stick press |
| 3 | Top face button | 11 | Right stick press |
| 4 | Left shoulder (L) | 12 | D-pad up |
| 5 | Right shoulder (R) | 13 | D-pad down |
| 6 | Left trigger | 14 | D-pad left |
| 7 | Right trigger | 15 | D-pad right |

The printed letters depend on the skin the player picked (the Classic gray layout prints B at the bottom, an Xbox-style layout prints A). Your game reads positions, so describe controls by position ("the bottom button jumps").

A phone that stops sending (screen locked, network lost) is reset to neutral, and `leave` fires when it disconnects. If it comes back within five minutes it keeps the same `playerId` and `join` fires again.

## Show something on one phone

Send a private screen to one player. Everyone else keeps their own.

```js
// A question with buttons. A press arrives as an "event".
room.view(playerId, {
  kind: "choices",
  title: "Round 3",
  text: "Which planet has the most moons?",
  choices: [
    { id: "saturn", label: "Saturn" },
    { id: "jupiter", label: "Jupiter" },
  ],
});
room.on("event", ({ playerId, event, payload }) => {
  if (event === "choice") answer(playerId, payload.target); // "saturn" or "jupiter"
});

// A secret role, revealed only while the player holds the button.
room.view(playerId, { kind: "secret-role", title: "Night 1", role: "vampire", text: "Pick a target.", choices: [] });

// Back to the gamepad.
room.view(playerId, { kind: "gamepad", title: "Space Race" });
```

| Kind | Shows | Fields |
| --- | --- | --- |
| `gamepad` | The controller (default) | `title`, `hint` |
| `choices` | A message and up to 12 buttons | `title`, `text`, `choices: [{id, label, event?}]`, `submitted` |
| `secret-role` | A hold-to-reveal secret and optional vote buttons | `title`, `role`, `clue`, `text`, `choices` |
| `waiting` | A message | `title`, `text` |

Set `submitted: true` to lock the buttons after a player answers. A view is limited to about 14 KB of JSON.

## Sound and vibration

```js
room.feedback(playerId, "game.win");  // built-in sound and vibration
room.rumble(playerId, { low: 0.8, high: 0.4, durationMs: 200 });
```

Built-in cues: `ui.tap`, `ui.confirm`, `ui.cancel`, `game.ready`, `game.countdown`, `game.jump`, `game.hit`, `game.pickup`, `game.boost`, `game.charge`, `game.explosion`, `game.unlock`, `game.warning`, `game.heartbeat`, `game.dial`, `game.win`, `game.lose`. Players can mute sound or vibration on their phone; iPhones do not vibrate from a web page.

## Shared state for the phones

`room.state(value)` sends the same small JSON value (up to about 14 KB) to every phone, for example the current score. The built-in controller does not display it; it is for custom clients.

## Limits

- Up to 16 players per room (8 by default; pass `maxPlayers`).
- Each phone can send up to 180 messages a second. The controller sends changes only.
- Room creation is limited per network to stop abuse. A game that needs many rooms at once should run its own server: see [Add phone controllers to a JavaScript game](quickstart.md).

## When to use your own server instead

The relay adds a trip through controller.plus. That is fine for party and casual games. If your game already has a server, or it runs on a computer on the same network as the players, connect the phones directly with `ControllerHost` from [the quickstart](quickstart.md). Direct connections have lower latency and do not depend on our service.

## Node, Deno and engines

`createRoom` also runs in Node 22 or later. Pass `WebSocketImpl` from the `ws` package if your Node version has no global WebSocket. Engine games (Godot, Unity, Unreal, C/C++) can use the same relay API: `POST https://controller.plus/relay/api/rooms` with `{"game": {"id": "my-game", "name": "My Game"}}` returns `room`, `endpoint`, `hostToken`, `joinURL` and `qrSVG`; connect to `endpoint` with the host token as described in [the wire protocol](protocol.md).
