Living spec — this is the product we're building

dropcontroller 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

dropcontroller is an SDK for turning any phone into a game controller. You integrate dropcontroller 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 with no cloud hop on the request path. When the local network can't establish a direct path, dropcontroller 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

JavaScript / TypeScript is the only shipped host SDK today — Unity and Unreal are planned. Grab an appId and an API key from the dashboard, then:

JavaScript / TypeScript

host.ts
import { DropController } from "@dropcontroller/sdk";

const room = await DropController.host({
  appId: "app_xxxxxxxxxxxx",
  apiKey: "bk_xxxxxxxxxxxx",         // keep server-side in prod
  controllerTemplate: "gamepad",
});

console.log(`Join code: ${room.code}`);
displayQR(room.qrUrl);

room.onPlayerJoined((player) => {
  console.log(`${player.name} joined — transport=${player.transport}`);
});

room.onControllerInput((player, input) => {
  if (input.type === "button" && input.pressed) {
    handlePress(player.id, input.button);
  }
});

The API key authenticates room creation — never ship it in a client-side bundle you can't trust. For browser-hosted games, proxy DropController.host() through a small server endpoint that holds the key.

Core concepts

Room
A host-side session phones connect to. Has a 4-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 session id (stable across reconnects within the session), a display name, a transport (local / direct-wan / relay), and optionally a profile.
Profile
Persistent player identity that follows a phone across every dropcontroller game they join — handle, avatar, and a stable id. Enables the host's getPlayerData / setPlayerData per-player store. See Player profiles.
Controller template
The UI that renders on the phone plus the schema of input events the host receives. Templates are either built-in or — in v2 — custom layouts published from the controller designer.
Input event
A typed message emitted by a controller. Payload shape is determined by the active template. Events are ordered per-player and flow over a direct WebRTC data channel once ICE completes.
Game state
A free-form keyed record the host broadcasts to every phone via setGameState. Phones merge partial updates so you can push { paused: true } without clobbering other fields. Templates can paint from it — the gamepad mirrors paused in its pause button.
Question
A discrete prompt the host asks via ask(). Comes in two flavours — choice (pick from options) and text (free-form typed response) — and renders as a modal overlay on the phone above whatever template is active. See Game state & questions.

Controllers

Built-in controllers 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.

Game controller

Fullscreen Xbox-style gamepad: thumbstick, D-pad, face buttons (A/B/X/Y), L/R triggers, pause. Takes over the whole viewport in landscape; pause button mirrors the host's game state.

gamepad

Input payload

  | { type: "stick", stick: "left" | "right", x: number /* -1..1 */, y: number /* -1..1 */ }
  | { type: "dpad", direction: "up" | "down" | "left" | "right" | "none" }
  | { type: "button", button: "a" | "b" | "x" | "y", pressed: boolean }
  | { type: "trigger", side: "left" | "right", pressed: boolean }
  | { type: "pause" }

Big button

One giant tap surface. Each press emits a timestamped event — useful for tap-to-interact prototypes before you pick a richer template.

button

Input payload

{ type: "tap", ts: number /* ms since epoch */ }

Trivia buzzer

Big red buzzer. Phone debounces double-fires and timestamps the press client-side; use the ts for first-to-buzz arbitration.

trivia-buzzer

Input payload

{ type: "buzz", ts: number /* ms since epoch */ }

Drawing pad

Freeform canvas with color + brush-size controls. Host can push a new palette / brush set / clear command via host_message.

drawing-pad

Input payload

  | { type: "stroke_start", x: number /* 0-1 */, y: number /* 0-1 */, color: string, brushSize: number }
  | { type: "stroke_point", x: number /* 0-1 */, y: number /* 0-1 */ }
  | { type: "stroke_end" }

Voting

Poll-style multiple choice. Host pushes { prompt, options, allowMultiple } over host_message; phone renders buttons and emits one vote per selection.

voting

Input payload

{ type: "vote", optionId: string }

Game state & questions

Two APIs on every Room for talking back to phones in structured ways: setGameState for continuous UI state the controller reflects (pause indicator, score, round), and ask for discrete prompts the player has to answer.

setGameState

// Broadcast partial state. Phones merge it into a running record, so
// sending just { paused: true } doesn't wipe other fields you've set.
room.setGameState({ paused: true });
room.setGameState({ round: 3, scoreLeader: "Alice" });

Today the gamepad template reflects paused in its pause button (icon flips ⏸ ↔ ▶, background tints blue when paused). Other templates receive the state through the SDK's onGameState callback on the player side but don't paint it by default — we'll extend each template as the need shows up.

ask(target, question, opts?)

// Ask everyone a multiple-choice question.
const answers = await room.ask("all", {
  type: "choice",
  prompt: "Which movie should we play?",
  options: [
    { id: "a", label: "Alien" },
    { id: "b", label: "Blade Runner" },
  ],
}, {
  onAnswer: (a) => showLiveTally(a),
});

// answers: Array<{ questionId, playerId, value: string[] }>
// Ask a single player to type a response.
const [answer] = await room.ask(player, {
  type: "text",
  prompt: "Name your character",
  maxLength: 24,
}, { timeoutMs: 30_000 });

if (answer) setCharacterName(player.id, answer.value as string);
  • The Promise resolves when every target answers or timeoutMs fires (default 60s), whichever is first.
  • Phones that never answered get an automatic question_cancel so the dialog doesn't linger.
  • Players who disconnect mid-question are dropped from the expected set — the Promise won't hang.
  • The phone shows a modal overlay regardless of which controller template is active; tapping an option (single-choice) submits immediately.

Player profiles

Each phone has a persistent profile: a handle, a generated SVG avatar, and a stable id that follows the player into every dropcontroller game they join. Players set it up once, then join with a tap. The host SDK surfaces it on every Player object.

Player.profile

room.onPlayerJoined((player) => {
  if (player.profile) {
    renderSeat({
      id: player.profile.id,           // stable across sessions
      name: player.profile.displayName,
      avatar: renderAvatarSvg(
        player.profile.avatarStyle,
        player.profile.avatarSeed,
        player.profile.displayName,
      ),
    });
  } else {
    // Guest — no profile, can't use setPlayerData
    renderSeat({ id: player.id, name: player.name });
  }
});

renderAvatarSvg comes from @dropcontroller/sdk/avatar — a dependency-free SVG generator so your game renders the same avatar the phone does.

Persistent per-player data

Every app gets a key-value store scoped to (app, player.profile.id, key). Use it to persist stats, progress, unlocks, or preferences without standing up your own backend or auth.

const stats = await room.getPlayerData(player, "stats") ?? { wins: 0 };
await room.setPlayerData(player, "stats", { ...stats, wins: stats.wins + 1 });
  • Free tier: 10KB per value, 100 keys per (player, app). Overages return HTTP 413 with a tier-upgrade hint.
  • Values are JSON-serialised server-side; any structured-cloneable shape is fine.
  • Data is scoped per-app — one game can't read another game's data for the same player.
  • A player without a profile (guest join) will make setPlayerData reject. Prompt them to set up a profile on their phone first.

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 DropController.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 DropController.host(). Phones download it automatically on join.

API reference

DropController.host(options)

type HostOptions = {
  appId: string;                                       // from dashboard
  apiKey: string;                                      // bk_… key scoped to that app
  controllerTemplate?: string;                         // overrides the app's default template
  transport?: "auto" | "local-only" | "relay-only";          // default "auto"
  apiUrl?: string;                                     // override API origin (self-host / testing)
  webUrl?: string;                                     // override the /play origin in the QR URL
};

maxPlayers, per-room branding overrides, and reserved room codes are roadmap items; the shipped SDK caps out at whatever the server allows (32 today) and uses your app's dashboard template / branding defaults.

Room

Member Type
DropController.host(options)

Create a room. Allocates a 4-char code, mints short-lived TURN credentials, opens the host signaling WebSocket.

async (options: HostOptions) => Room
room.code

4-char join code (e.g. "ABCD"). Drawn from a 31-char alphabet that skips look-alikes (no 0/O/1/I).

string
room.qrUrl

URL to encode into the QR code — opens /play?c=CODE in the phone's browser.

string
room.players

Snapshot of currently connected players. Re-read after events; don't cache.

readonly Player[]
room.onPlayerJoined(fn)

Fires when a phone connects and its WebRTC channel is ready for input.

(player: Player) => void
room.onPlayerLeft(fn)

Fires on clean departures and on connection loss past the 60s grace window.

(player: Player, reason: "left" | "disconnected" | "kicked") => void
room.onControllerInput(fn)

Every template input event. Payload shape depends on the active template.

(player: Player, input: ControllerInput) => void
room.onTransportChanged(fn)

Fires when a player's effective transport flips (e.g. direct-wan → relay on a WiFi change).

(player: Player, t: Transport) => void
room.onAnswer(fn)

Low-level subscription to every question answer, independent of a specific ask() call.

(answer: Answer) => void
room.broadcast(payload)

Send a JSON payload to every phone. Delivered in order over each phone's data channel.

(payload: unknown) => void
room.sendTo(playerId, payload)

Send a payload to a single phone. Useful for per-player prompts or private hand updates.

(playerId: string, payload: unknown) => void
room.setTemplate(templateId)

Swap the active template mid-session. Every phone re-renders.

(templateId: string) => Promise<void>
room.setGameState(state)

Broadcast partial game state (e.g. { paused: true }). Phones merge into a running record; gamepad template mirrors the paused flag in its pause button.

(state: GameState) => void
room.ask(target, question, opts?)

Ask a question of one player or everyone. Promise resolves when every target answers or timeoutMs fires (default 60s); unanswered dialogs auto-cancel.

(target: "all" | Player | string, q: Question, opts?: AskOptions) => Promise<Answer[]>
room.getPlayerData(player, key)

Read a persisted per-player value (scoped to this app). Rejects if the player has no profile.

(player: Player, key: string) => Promise<unknown>
room.setPlayerData(player, key, value)

Persist a per-player value. Free-tier caps: 10KB per value, 100 keys per (player, app).

(player: Player, key: string, value: unknown) => Promise<void>
room.deletePlayerData(player, key)

Remove a persisted value. Idempotent.

(player: Player, key: string) => Promise<void>
room.kick(playerId)

Remove a phone from the room. Fires onPlayerLeft with reason "kicked".

(playerId: string) => void
room.close()

End the session. Invalidates the room code and closes every connection.

async () => void

Player

type Transport = "local" | "direct-wan" | "relay";

type Player = {
  id: string;                  // plr_… session id. Stable across reconnects, not across sessions.
  name: string;                // profile.displayName if one exists, else what the player typed
  joinedAt: number;             // ms since epoch
  transport: Transport;          // "local" (same LAN) / "direct-wan" (P2P over WAN) / "relay" (TURN)
  profile?: PlayerProfile;      // present when the phone has set up a persistent profile
};

type PlayerProfile = {
  id: string;                  // ppf_… stable across every dropcontroller game this phone joins
  displayName: string;
  avatarSeed: string;
  avatarStyle: string;          // "initials" | "pixels" | "rings" | "shards"
};

Question & Answer

type Question =
  | { type: "choice"; prompt: string;
      options: { id: string; label: string }[];
      allowMultiple?: boolean; }
  | { type: "text"; prompt: string;
      placeholder?: string; maxLength?: number; };

type Answer = {
  questionId: string;
  playerId: string;
  value: string | string[];  // string[] for choice (one id unless allowMultiple), string for text
};

type AskOptions = {
  timeoutMs?: number;          // default 60000
  onAnswer?: (a: Answer) => void;  // fires per-answer as they arrive
};

Branding & white-label

Phones never see the dropcontroller 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.usedropcontroller.dev/ABCD. Configure a vanity domain in the dashboard and it becomes play.yourgame.com/ABCD. Required on Pro tier and above.

Connectivity model

Signaling is always in the cloud — a Cloudflare Durable Object brokers the handshake between host and each phone. Once ICE completes, inputs travel peer-to-peer over a WebRTC data channel and never round-trip through our infrastructure. Your code doesn't branch on transport; the SDK tells you which path landed via player.transport.

  1. 1

    Signaling handshake (Cloudflare edge)

    Host opens a WebSocket to a per-room Durable Object. Each phone opens its own. The DO forwards SDP offers / answers / ICE candidates between the pair and mints short-lived TURN credentials. This happens on every join, regardless of transport.

  2. 2

    Direct peer-to-peer data channel

    Best case: ICE picks a host-candidate pair on the same LAN → transport reports "local" and round-trips ride the LAN RTT directly, with zero cloud hop. Remote pairs land on "direct-wan" — still P2P, still free, just across the public internet via NAT traversal.

  3. 3

    TURN relay fallback

    When NAT traversal fails (symmetric NATs, restrictive corporate WiFi), WebRTC falls through to a TURN relay. The SDK mirrors bytes through the signaling Durable Object during the handshake window so inputs never block on ICE. Transport reports "relay" — the only path that counts against the device-minute meter.

Forcing a transport

Pass transport: "local-only" to refuse the relay — the host and phone both skip the TURN credentials fetch, so the room genuinely works offline. "relay-only" forces the opposite (handy for testing the relay path end-to-end). Default is "auto".

Zero-loss reconnection

Phones drop sockets constantly — screen lock, WiFi roam, 2.4 ↔ 5 GHz switch, tab backgrounding. The SDK hides all of it:

  • Every phone gets a rotating resume token on join. On reconnect it re-attaches the same server-side slot and the same Player.id.
  • The server holds the slot for a 60-second grace window. During that window the host sees a transient player_disconnected (no onPlayerLeft) and can show a reconnecting indicator.
  • Game-state messages buffer locally on the phone during the gap and replay in order once the channel comes back.
  • If the phone never returns, onPlayerLeft(..., "disconnected") fires at the grace deadline.

Wire efficiency

  • Input events are msgpack-encoded on the data channel — ~22 bytes per stick sample versus ~55 as JSON.
  • Joystick samples are coalesced with requestAnimationFrame so fast thumbstick drags produce one send per display frame. Discrete events (button press, buzzer tap) flush immediately.
  • Each phone owns one ordered data channel. Inputs from that phone arrive in emit order — high-frequency streams never reorder against discrete events.

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

JavaScript / TypeScript

Shipped

@dropcontroller/sdk — ESM package for browser and Node.js game hosts. Source of truth for every API shape on this page.

Web controller runtime

Shipped

The phone-side player served from /play. Works in any mobile browser, no app install. PWA manifest lets iOS players add to Home Screen for a chrome-less experience.

Unity

Planned · v1

C# package + Asset Store listing. Port of the JS host API; WebRTC via com.unity.webrtc. Tracked in §7 of the roadmap.

Unreal Engine

Planned · v2

C++ SDK with equivalent API surface.

Native iOS / tvOS

Planned · v2

Swift package, optimised for Apple TV living-room hosts.

Native Android TV

Planned · v2

Kotlin package.

macOS / Windows

Planned · v2

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

Developer dashboard

The control plane for everything your SDK connects to. Sign in at /app, register an app, and get an appId plus API keys.

Shipped

  • Apps & API keys — register apps, rotate keys, scope keys by environment. Plaintext shown once at create.
  • Default controller template — pick which template phones render when the SDK doesn't override.
  • Usage card — current month's relay device-minutes + session count, near-real-time (past days rolled up nightly, today queried live).
  • Test room/app/demo spins up a live room in your browser so you can scan a QR and exercise the full host → controller loop without writing a line of game code.

Planned

  • v2 Branding — logo, colors, fonts, vanity domain (per the roadmap).
  • v2 Controller designer — drag-and-drop builder for custom templates.
  • v1 Billing — Stripe self-serve for the Pro tier, upgrade flow, overage enforcement against the free-tier caps.
  • v2 Session analytics — per-template event volume, transport mix, retention, p95 latency.

Ready to try it?

Sign up, create an app, grab a key, drop the SDK in.

Sign Up