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
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, 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 session
id(stable across reconnects within the session), a displayname, atransport(local / direct-wan / relay), and optionally aprofile. - 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/setPlayerDataper-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 mirrorspausedin 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
timeoutMsfires (default 60s), whichever is first. - Phones that never answered get an automatic
question_cancelso 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
setPlayerDatareject. 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
- 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
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
}; 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
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
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
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(noonPlayerLeft) 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
requestAnimationFrameso 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
ShippedThe 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 · v1C# package + Asset Store listing. Port of the JS host API; WebRTC via com.unity.webrtc. Tracked in §7 of the roadmap.
Unreal Engine
Planned · v2C++ SDK with equivalent API surface.
Native iOS / tvOS
Planned · v2Swift package, optimised for Apple TV living-room hosts.
Native Android TV
Planned · v2Kotlin package.
macOS / Windows
Planned · v2Native 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.