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

The example below is JavaScript, but the same host API ships for many engines (Phaser, Unity, Godot, Rust/Bevy, LÖVE, Construct 3, Cocos Creator, Defold, tvOS/macOS). 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. npm install @dropcontroller/sdk

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.

Phaser 3

Shipped

Scene-binding on top of the JS SDK — forwards Room events onto scene.events. npm install @dropcontroller/sdk-phaser @dropcontroller/sdk

Unity

Shipped

C# package. WebRTC via com.unity.webrtc. Unity 2022.3+, non-WebGL. Install via Package Manager git URL (dropcontroller-unity).

Godot 4.x

Shipped

Drop addons/dropcontroller/ into your project — Godot ships WebRTC, no native plugin. From the dropcontroller-godot repo / Asset Library.

Rust / Bevy

Shipped

Engine-agnostic core + Bevy plugin. cargo add dropcontroller bevy_dropcontroller

LÖVE (LOVE2D)

Shipped

Pure Lua. Relay-only (no native WebRTC) — read the pricing note before shipping. luarocks install dropcontroller

Construct 3

Shipped

Event-sheet plugin (actions / conditions / expressions). Install the .c3addon via Construct's Addon Manager.

Cocos Creator 3.x

Shipped

DropControllerHost component. H5/Web build. npm install @dropcontroller/sdk-cocos (or drop into assets/).

Defold

Shipped

Lua, relay-only. Add the release zip (+ extension-websocket) to game.project dependencies; attach the dropcontroller.script component.

tvOS / macOS (Swift)

Shipped

Swift host SDK for Apple TV (SpriteKit/SceneKit) + macOS. Native WebRTC. SPM: the DropControllerHost product. The iOS controller app uses DropControllerPlayer.

Unreal Engine

Planned · v2

C++ SDK with equivalent API surface.

GameMaker

Planned · v2

GML extension. Tracked in the roadmap.

MonoGame / .NET

Planned · v2

C# host SDK for the MonoGame / FNA ecosystem.

Native Android TV

Planned · v2

Kotlin package for Android TV living-room hosts.

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