Living spec — this is the product we're building

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

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

host.ts
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, a qrUrl, 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 display name, 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

  1. Open the designer in the dashboard.
  2. Drag components onto the phone-shaped canvas. Bind each component to a name.
  3. Preview live on your phone via QR code.
  4. Publish — the layout gets a stable template ID (e.g. custom_acme_dartboard).
  5. 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
};
Vanity domains: by default the QR code opens 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. 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. 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. 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.id re-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

v1

Asset Store distribution. Primary SDK — all new templates ship here first.

JavaScript / TypeScript

v1

npm package for browser and Node.js game hosts.

Web controller runtime

v1

The phone-side player. Works in any mobile browser, no app install.

Unreal Engine

v2

C++ SDK with equivalent API surface.

Native iOS / tvOS

v2

Swift package, optimized for AppleTV living-room hosts.

Native Android TV

v2

Kotlin package.

macOS / Windows

v2

Native hosts for kiosks, arcades, and laptop-driven parties.

WebSocket protocol

v1

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

Sign Up