Controller+BETADownload

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

<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.

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.

// 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

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

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. 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.