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.
input.axesis[leftX, leftY, rightX, rightY], each from -1 to 1. Right and down are positive.input.buttonshas 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.
// 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
- 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.
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.