Beacon documentation
Everything the SDK does and everything it will do. Where an API is marked Planned it's not built yet — but this page is the spec we're shipping against.
Introduction
Beacon is an SDK for turning any phone into a game controller. You integrate Beacon into your game (host), players scan a QR code on their phones (controllers), and you receive typed input events without writing a line of networking code.
The SDK is local-first: once connected, inputs travel device-to-device over the local network at under 20ms. When the local network can't establish a direct path, Beacon falls back transparently to a cloud relay — same API, same event shapes.
Mental model: your game is the host. Each phone is a controller running a template you pick (or a custom layout you design). Templates define the phone UI and the typed input events the host receives.
Quickstart
Below is the shortest possible integration. In production you'll pass an appId
from the dashboard. Without one, Beacon runs in anonymous mode — fine for local development,
capped at short sessions.
Unity / Swift
let room = try await Beacon.host(
appId: "app_xxxxxxxxxxxx",
controllerTemplate: "trivia-buzzer"
)
print("Join code: \(room.code)")
displayQR(room.qrUrl)
room.onPlayerJoined { player in
print("\(player.name) joined")
}
room.onControllerInput { player, input in
if case .buzz(let timestamp) = input {
handleBuzz(player: player, at: timestamp)
}
} JavaScript / TypeScript
import { Beacon } from "@beacon/sdk";
const room = await Beacon.host({
appId: "app_xxxxxxxxxxxx",
name: "Drawing Game",
controllerTemplate: "drawing-pad",
});
console.log(`Join code: ${room.code}`);
displayQR(room.qrUrl);
room.onPlayerJoined((player) => {
console.log(`${player.name} joined`);
});
room.onControllerInput((player, input) => {
if (input.type === "stroke_point") {
drawPoint(player.id, input.x, input.y);
}
}); Core concepts
- Room
-
A host-side session that phones connect to. Has a four-character
code, aqrUrl, an active controller template, and a list of players. Rooms are lightweight — create and close freely between matches. - Player
-
A connected phone. Has a stable
id(persists across reconnects within the session), a displayname, and connection state. Identity survives brief disconnects so a screen-lock or WiFi hop doesn't drop the player out of the game. - Controller template
- The UI that renders on the phone and the schema of input events the host receives. Templates are either built-in (catalog below) or custom layouts published from the controller designer.
- Input event
- A typed message emitted by a controller. The payload shape is determined by the active template. Events are ordered per-player and arrive with sub-20ms latency on local networks.
Controller templates
Built-in templates cover the common cases. Each one renders a complete phone UI — you don't touch HTML, CSS, or mobile layout. The host receives typed input events in the shapes documented below.
Trivia buzzer
Big tap-to-buzz surface. The phone debounces and timestamps the press.
trivia-buzzer Input payload
{ type: "buzz", timestamp: number /* ms since epoch */ } Drawing pad
Freeform drawing surface with color and brush controls.
drawing-pad Input payload
{ type: "stroke_start" | "stroke_point" | "stroke_end",
x: number /* 0-1 */,
y: number /* 0-1 */,
color?: string,
brushSize?: number /* 1-20 */ } Steering wheel
Tilt-based steering, pedal for throttle, button for brake.
steering-wheel Input payload
{ type: "steer",
angle: number /* -1 (full left) .. 1 (full right) */,
throttle: number /* 0-1 */,
brake: number /* 0-1 */ } Voting
Poll-style multiple choice. Host declares the options, phone renders them.
voting Input payload
{ type: "vote", optionId: string } Game pad
D-pad / joystick and face buttons. Classic controller layout.
game-pad Input payload
| { type: "button", button: "a" | "b" | "x" | "y" | "start" | "select", pressed: boolean }
| { type: "stick", stick: "left" | "right", x: number, y: number }
| { type: "trigger", side: "left" | "right", value: number /* 0-1 */ } Motion sensor
Streams normalized accelerometer and gyroscope at 60 Hz.
motion Input payload
{ type: "motion",
accel: { x: number, y: number, z: number },
gyro: { x: number, y: number, z: number } } Media remote
Transport controls for video or music playback.
media-remote Input payload
| { type: "play" | "pause" | "stop" }
| { type: "seek", position: number /* 0-1 */ }
| { type: "volume", level: number /* 0-1 */ } Trackpad & keyboard
Phone as touch surface and text input. Useful for presentations and kiosks.
trackpad Input payload
| { type: "pointer", event: "move" | "tap" | "down" | "up", x: number, y: number }
| { type: "key", key: string } Custom
Anything you build in the controller designer. Payload shape is defined by your layout.
custom Input payload
{ type: "custom", components: Record<string, ComponentValue> } Controller designer
Planned · v2
A drag-and-drop web tool for building custom phone UIs. Every layout you publish
becomes a controllerTemplate you can pass to Beacon.host()
by ID. Phones render the layout; the host receives a type: "custom" input
event carrying the current value of each component.
Components
Button
{ pressed: boolean } Toggle
{ on: boolean } Slider
{ value: number /* 0-1 */ } Touch area
{ x: number, y: number, pressed: boolean } Drawing surface
stroke events (see drawing-pad) Gyroscope
{ accel, gyro } at 60 Hz Text input
{ value: string } Label / Image
(display only, no events) Publishing a layout
- Open the designer in the dashboard.
- Drag components onto the phone-shaped canvas. Bind each component to a name.
- Preview live on your phone via QR code.
- Publish — the layout gets a stable template ID (e.g.
custom_acme_dartboard). - Pass the ID to
Beacon.host(). Phones download it automatically on join.
API reference
Beacon.host(options)
type HostOptions = {
appId: string; // from dashboard
name?: string; // shown on the phone join screen
controllerTemplate: string; // built-in id or custom layout id
maxPlayers?: number; // default 32, max 32
branding?: BrandingOverride; // overrides dashboard theme for this room
allowRelay?: boolean; // default true. set false to require local-only
roomCode?: string; // optional fixed code (validated against collisions)
}; Room
| Member | Type |
|---|---|
Beacon.host(options) Create a room on the host device. Returns once the room is discoverable. | async (options: HostOptions) => Room |
room.code Four-character join code (e.g. "ABCD"). Players can also scan room.qrUrl. | string |
room.qrUrl URL encoded in the QR code. Opens the branded controller in the phone's browser. | string |
room.players Snapshot of currently connected players. Re-read after events, don't cache. | Player[] |
room.onPlayerJoined(fn) Fires when a phone connects and the controller is ready for input. | (player: Player) => void |
room.onPlayerLeft(fn) Fires on clean departures and on connection loss past the reconnection window. | (player: Player, reason: "left" | "disconnected" | "kicked") => void |
room.onControllerInput(fn) Fires for every input event. Payload shape depends on the active template. | (player: Player, input: ControllerInput) => void |
room.broadcast(message) Send a message to every connected phone. Delivered in order. | (message: JsonValue) => void |
room.sendTo(playerId, message) Send a message to one phone. Useful for private hands, targeted prompts. | (playerId: string, message: JsonValue) => void |
room.setTemplate(templateId) Swap the active controller template mid-session. All phones update in place. | (templateId: string) => Promise<void> |
room.kick(playerId) Remove a player from the room and invalidate their controller. | (playerId: string) => void |
room.close() End the session. Invalidates the room code and closes every connection. | async () => void |
Player
type Player = {
id: string; // stable for the session, survives reconnects
name: string; // player-entered or auto-assigned
avatarColor: string; // hex, assigned on join, stable for the session
isConnected: boolean;
joinedAt: number; // ms since epoch
transport: "local" | "relay";
}; Branding & white-label
Phones never see the Beacon brand. The controller URL, the join screen, and every
template render with your app's branding. Set defaults in the dashboard; override
per-room via HostOptions.branding.
type BrandingOverride = {
primary?: string; // hex, drives buttons and accents
background?: string; // hex, controller background
text?: string; // hex, foreground text
logoUrl?: string; // PNG or SVG, shown on join + header
fontFamily?: string; // any Google Font or self-hosted woff2 URL
vanityDomain?: string; // e.g. play.yourgame.com — set up in dashboard
}; play.usebeacon.dev/ABCD. Configure a vanity domain in the dashboard
and it becomes play.yourgame.com/ABCD. Required on Commercial tier and above.
Connectivity model
Beacon prefers the lowest-latency transport available and falls through automatically. Your code doesn't change based on which transport is used.
- 1
Local discovery (mDNS)
Host advertises itself on the LAN. Phones on the same WiFi discover it under 1 second. Direct socket connections, sub-20ms round-trips, no cloud hop.
- 2
Direct WAN via signaling
If phones are on a different network (remote players joining via room code), Beacon's signaling server helps both sides punch through NATs and establish a direct connection.
- 3
Cloud relay fallback
When NAT traversal fails (symmetric NATs, restrictive corporate WiFi), Beacon routes through the relay. This is the only path that incurs device-minute charges — see pricing.
Reconnection
Phones drop sockets constantly — screen lock, WiFi roam, 2.4 ↔ 5 GHz switch, backgrounding. Beacon handles all of them without the host having to care:
- Inputs are buffered on the controller during a disconnect.
- The same
Player.idre-attaches automatically when the phone reconnects within 60 seconds. - After 60 seconds the player is dropped with
onPlayerLeft(..., "disconnected"). - Queued inputs flush in order on reattach. The host sees them as if no gap occurred.
Security
- All traffic encrypted end-to-end, local and relay.
- Room codes are single-use and expire when the room closes.
- Each session gets an ephemeral keypair; relay traffic is opaque to our infrastructure.
Pricing & metering
Local sessions are free forever. You only pay when traffic routes through the relay. See the pricing page for tiers; this section covers how usage is counted.
Device-minutes
A device-minute is one connected device using the relay for one minute. Counted per-device, per-minute, rounded up. Local-only connections contribute zero device-minutes.
Example
A trivia night with 6 phones, 40 minutes long. 4 phones are on the local WiFi (0 minutes metered). 2 phones joined remotely via room code and routed through relay (2 × 40 = 80 device-minutes).
Forcing local-only
Pass allowRelay: false
in HostOptions to refuse the relay. Useful for offline deployments
(cruise ships, cabins, secure venues) or to hard-cap spend. Phones that can't reach the
host locally will see a clear "can't connect" screen instead of silently failing over.
Platforms & SDKs
Unity
v1Asset Store distribution. Primary SDK — all new templates ship here first.
JavaScript / TypeScript
v1npm package for browser and Node.js game hosts.
Web controller runtime
v1The phone-side player. Works in any mobile browser, no app install.
Unreal Engine
v2C++ SDK with equivalent API surface.
Native iOS / tvOS
v2Swift package, optimized for AppleTV living-room hosts.
Native Android TV
v2Kotlin package.
macOS / Windows
v2Native hosts for kiosks, arcades, and laptop-driven parties.
WebSocket protocol
v1Drop to the wire protocol for any language or platform.
Developer dashboard
The control plane for everything your SDK connects to. Sign in, register an app,
and get an appId.
- Apps & API keys — register apps, rotate keys, scope keys by environment.
- Branding — upload logo, set colors and fonts, configure vanity domain.
- Controller designer (v2) — build and publish custom controller layouts.
- Analytics — session counts, concurrent players, transport mix (local vs relay), device-minute usage.
- Billing — current tier, usage against limits, overage forecast.
Ready to try it?
Sign up and we'll get you access as soon as v1 ships.