Open-source DevTool · MIT v2.0.0 · snap-pair-core

snap-pair

Phone as controller. Big screen as host.

Build multi-screen interactive web experiences. People pair by scanning a QR code, typing a 6-digit PIN, or (experimentally) hearing an ultrasonic chirp in the browser they already have, and every device in the room shares live input and state. Nothing to install.

  • Firebase RTDB
  • PartyKit
  • WebRTC
  • BroadcastChannel
$npx snap-pair init
A phone draws a stroke that appears live on a big host screen showing a QR code and PIN.

01What it is

Three layers, one small API

You bring the product: a quiz, a drawing wall, a game, a light show, a showroom. snap-pair handles pairing, the realtime transport and the phone-side details that are easy to get wrong.

Layer 1

Pairing

How a device finds the room: a QR code that carries the join URL, a 6-digit PIN, a look-alike-free room code, local broadcast between tabs, or experimental ultrasonic sound (Proximity).

Layer 2

Transport

One Transport API with four backends: Firebase Realtime Database, PartyKit, WebRTC DataChannel and BroadcastChannel. Switch with one line.

Layer 3

Client utilities

Make phones good controllers: screen wake lock, device orientation and motion (including the iOS permission prompt) and screen orientation lock.

Architecture: your app on top of presets and components, built on Pairing, Transport and Client utility layers.
Fig. 1Architecture

02Quick start

Up and running in a minute

Scaffold a new app with the wizard, or add the library to an existing React app.

New app

bash
npx snap-pair init
# non-interactive (CI, AI agents)
npx snap-pair init --yes --preset room-quiz-poll --out my-quiz

The wizard asks a few questions (in English or Japanese), writes snap-pair.config.json and scaffolds a runnable Vite + React template for the preset.

Existing app

npm i snap-pair-core

Peer: react 18.2+. Optional: qrcode (QR in HostHUD), partysocket (sturdier PartyKit socket), react-dom (templates only).

The smallest multi-screen app: no server at all

Two tabs in the same browser, paired with a PIN. Swap in PartyKitTransport (or WebRTCTransport) and the same code works across the internet. FirebaseTransport shares room state but has no ephemeral messaging, so broadcast is not available there.

ts
import { BroadcastChannelTransport } from 'snap-pair-core';

// Tab 1: the host (big screen)
const host = new BroadcastChannelTransport({ pairing: 'pin' });
await host.connect();
const { pairing } = await host.createRoom({ initialState: { strokes: [] } });
console.log('PIN', pairing.pin); // e.g. '042917'
host.onMessage((m) => draw(m.payload)); // m.type === 'stroke'

// Tab 2: the controller
const ctrl = new BroadcastChannelTransport({ pairing: 'pin' });
await ctrl.connect();
await ctrl.joinRoom('042917');
await ctrl.broadcast('stroke', { x: 0.42, y: 0.17 });
Live demo

Pair two tabs right now

  1. Open the demo and choose Host. A 6-digit PIN appears.
  2. Open it again in another tab or window and choose Controller.
  3. Enter the PIN, then drag on the pad. Your strokes appear on the host canvas.
Open the demo

The demo is a few hundred lines of plain JavaScript on top of BroadcastChannel, the same medium BroadcastChannelTransport uses. No server, no account, works offline.

03How devices connect

Scan, type, or just open a tab

The host creates a room and shows how to join. Guests use the camera or a keyboard. Then messages flow both ways in realtime.

01

QR code

The URL carries ?room= or ?pin=. Works with every transport.

useQrRendererparseJoinUrl

02

6-digit PIN

Easy to read aloud. Full-width digits and dashes are normalized. PartyKit, WebRTC, BroadcastChannel.

generatePinverifyPin

03

Room code

Six characters like ABC 234, with no look-alike characters. Firebase's default.

generateRoomCode

04

Broadcast

Open another tab or window on the same machine. No network involved.

BroadcastChannelTransport

05

Proximity sound

Experimental. The host emits an inaudible ~18–20 kHz tone; a nearby phone listens on the mic and joins. Needs mic permission; noisy rooms and some browsers are unreliable. QR and PIN stay primary.

useSoundPairingencodeSoundToken

Sound pairing limitations: ambient noise, microphone permission, and uneven browser support (Chrome/Edge best). The tone is not a secret — use admit for gated rooms.

Sequence: host creates room, shows QR and PIN, controller scans or enters PIN, joins, host admits, realtime messages flow both ways.
Fig. 2Pairing sequence

Show it all with one component

tsx
import { HostHUD, useQrRenderer } from 'snap-pair-core';

const renderQr = useQrRenderer(); // undefined without `qrcode`: the HUD shows the code only
<HostHUD pairing={pairing} renderQr={renderQr} peerCount={peers.length} status={status} />

04Transports

Pick the transport that fits the room

All four implement the same Transport interface, so switching is a one-line change. Check transport.capabilities when your UI needs to degrade gracefully.

The default for useSnapPair. Firebase Auth identifies each browser, Cloud Functions create rooms and admit guests, and RTDB rules limit what members can write. Rooms of up to 300. It shares state only: there is no ephemeral messaging (capabilities.messaging === false), so none of the presets runs on it. The CLI offers a config-only Firebase app, or Firebase plus PartyKit for a preset's realtime messages.

Best for
Shared-state apps (turn-based games, checklists, lobbies), auth, persistence
Server
Managed by Google + Cloud Functions
Cost
Emulator and Spark are free; secure mode needs Blaze (card)
Trust
Server-authoritative joins
ts
import { FirebaseTransport } from 'snap-pair-core';
import { getAuth } from 'firebase/auth';
import { getDatabase } from 'firebase/database';
import { getFunctions } from 'firebase/functions';

const fb = new FirebaseTransport({
  db: getDatabase(), auth: getAuth(), functions: getFunctions(),
  maxPlayers: 300,
});
await fb.connect();
const { pairing } = await fb.createRoom({ initialState: { turn: 0 } });
await fb.setState({ turn: 1 }); // replaces the whole state; send/broadcast are rejected
Transport comparison: same device, internet, latency, server needed, cost, offline.
Fig. 3Transport comparison

05Presets

Seven UX patterns, ready to scaffold

Each preset ships as a runnable template with a recommended transport, message shapes and a rate limit (all in PRESETS). None runs on Firebase, which has no ephemeral messaging. Select a preset to see the core of its message flow.

Stroke Stream: draw on your phone; strokes stream live onto the big screen. Recommended: WebRTC or PartyKit.
ts · stroke-stream
// Controller: batch normalized points every 33 ms (≤30 msg/s, ≤64 points), addressed to the host
pad.addEventListener('pointermove', (e) => points.push([e.offsetX / w, e.offsetY / h]));
setInterval(() => {
  if (!points.length) return;
  const to = transport.room.hostId;
  transport.send({ type: 'stroke', payload: { id, color, width: 4, points: points.splice(0, 64) }, to });
}, 33);

// Host: draw every peer's strokes (clamp points to 0..1 first)
transport.onMessage((m) => m.type === 'stroke' && drawPath(m.from, m.payload));

06CLI

npx snap-pair init: four ways in

The wizard speaks English or Japanese (from LANG / LC_ALL, or --lang en|ja). Pick whichever path matches how you think about the project (--path skips the menu). Every path writes snap-pair.config.json and scaffolds a Vite + React template, plus a PartyKit relay when the transport needs one.

  1. 01

    By experience

    Choose one of the 7 presets, then one of the transports it supports (recommended first). --path ux

  2. 02

    By architecture

    Same device, realtime, P2P, or managed? You get a transport, then the presets that fit it. --path architecture

  3. 03

    By stack

    Start from what you already have: Firebase, Cloudflare/PartyKit, or no backend. Firebase offers a config-only app, or Firebase plus PartyKit for a preset's realtime messages. --path stack

  4. 04

    Describe it

    Write a sentence in English or Japanese, like "audience votes on a stage screen". A rule-based recommender suggests a preset, transport and pairing you can accept or adjust. --path consult

Terminalsnap-pair init
$ npx snap-pair init --out my-quiz
snap-pair init: set up a multi-device experience

How do you want to choose?
  1) By experience: pick one of 7 presets (default)
  2) By architecture: same device, realtime, P2P, or managed
  3) By stack: Firebase, Cloudflare/PartyKit, or no backend
  4) Describe your idea and get a recommendation
Choose 1-4 [1]: 1

Which experience?
  1) Stroke Stream (stroke-stream)
       Transports: webrtc*, partykit, broadcast · Pairing: qr, code, pin
  …
  4) Room Quiz / Poll (room-quiz-poll) (default)
       The host shows a question; everyone in the room answers on their phone and the tally updates live.
       Transports: partykit*, broadcast, webrtc · Pairing: pin, qr, code
       Messages: vote · Rate limit: ≤1/s (once)
  …
Choose 1-7 [4]: 4

Which transport?
  1) PartyKit (Cloudflare relay) (recommended, default)
       + Works across networks and NATs (it is a server)
       - One relay hop of latency (usually 20–80 ms)
       $ Cost: Cloudflare has a free tier …
  2) BroadcastChannel (same device)
  3) WebRTC DataChannel (P2P)
Choose 1-3 [1]: 1

How do guests join?
  1) Numeric PIN (recommended, default)
  2) QR code
  3) Room code
Choose 1-3 [1]: 1

Summary:
  Preset: Room Quiz / Poll (room-quiz-poll)
  Transport: PartyKit (Cloudflare relay)
  Pairing: Numeric PIN
  Cost: Cloudflare has a free tier …
Wrote my-quiz/snap-pair.config.json
Scaffolded the room-quiz-poll template into my-quiz

Next steps:
  1. cd my-quiz && npm install
  2. Local relay: npx partykit dev party/server.ts (port 1999) in another terminal
  3. npm run dev, open the URL on the big screen (host), and scan the QR code with a phone
  …

Real output of snap-pair init (English), shortened where marked with …. With --lang ja every prompt is in Japanese.

Non-interactive, for CI and AI agents

bash
npx snap-pair init --yes --preset room-quiz-poll --out my-quiz
npx snap-pair init --yes --preset virtual-controller --transport webrtc --json   # JSON on stdout: config, files, nextSteps
npx snap-pair init --yes --architecture managed --no-scaffold                    # Firebase config only (preset: null)
npx snap-pair init --yes --stack firebase --preset type-throw                    # Firebase app + PartyKit for the preset
npx snap-pair presets --json                                                     # the preset registry
npx snap-pair recommend "a tilt racing game for 4 friends"                       # → motion-sensor over WebRTC, QR

Flags: --path, --preset <id|none>, --transport broadcast|partykit|webrtc|firebase, --pairing qr|code|pin|broadcast, --architecture same-device|realtime|p2p|managed, --stack firebase|cloudflare|none, --describe, --out, --partykit-host, --max-players, --no-scaffold, --force, -y, --yes, --json, --lang en|ja. --transport firebase with a preset is rejected: no preset fits Firebase.

07API

API overview

Everything is exported from snap-pair-core. Transports, pairing helpers and client utilities are plain TypeScript; only the hooks and components need React.

React: useSnapPair

tsx
import { useState } from 'react';
import { PartyKitTransport, useSnapPair } from 'snap-pair-core';

// Firebase (default): server-assisted rooms
const sp = useSnapPair({ db, auth, functions, guest: { id: '', name: 'Ada' }, maxPlayers: 8 });

// Any other transport: an instance (you own it) or a factory (the hook owns it)
const [party] = useState(() => new PartyKitTransport({ host: 'my-relay.me.partykit.dev', pairing: 'pin' }));
const sp = useSnapPair({ transport: party, guest: { id: '', name: 'Ada' } });

const { room, authReady, createRoom, joinRoom, updateState, updateOwnPlayer, updateRoomStatus, leaveRoom } = sp;

Both modes return the same shape. With a transport, authReady follows the connection and localGuest.id becomes the peer id; use the instance's send / broadcast / onMessage for ephemeral input. A factory (transport: () => new X()) is disconnected on unmount.

Import paths

PathContents
snap-pair-coreEverything: transports, pairing, client utilities, components, presets, i18n (ESM + CJS, typed)
snap-pair-core/hooks/useSnapPairuseSnapPair only; the old snap-pair-core/src/hooks/useSnapPair path still works
snap-pair-core/transports/*base, firebase, partykit, webrtc, broadcast; same classes as the root
snap-pair-core/config.schema.jsonJSON Schema for snap-pair.config.json
npx snap-pairThe CLI (bin): init, presets, recommend

Transports

ExportNotes
TransportAbstract base: connect, rooms, state, send/broadcast, on* subscriptions, capabilities
FirebaseTransportRTDB + callable Cloud Functions; server-authoritative joins
PartyKitTransporthost, party, socketFactory; WebSocket fallback with backoff
WebRTCTransportsignaling, iceServers, reconnect, maxMessageBytes; DataChannel star with auto-reconnect and chunking
BroadcastChannelTransportSame-origin tabs and windows
RelayTransportShared engine: pairing, maxPlayers, admit, namespace, heartbeats

Pairing

ExportNotes
generatePin verifyPinUniform 6-digit PINs; constant-time compare; normalizePin, isValidPin, formatPin
deriveRoomIdHashes a code or PIN into a namespaced room key (works on plain-HTTP LAN too)
buildPairingJoinUrl parseJoinUrl?pin= / ?room= join links
useQrRendererQR rendering via the optional qrcode package; also toQrDataUrl
generateRoomCode6-character room codes without look-alike characters

Client utilities

ExportNotes
useWakeLockKeeps the controller screen on; re-acquires on visibility change
requestOrientationPermissioniOS 13+ prompt from a tap; resolves granted / denied / unsupported
subscribeOrientation subscribeMotionOrientation angles and acceleration readings
lockScreenOrientationScreen orientation lock where supported

Components

ExportNotes
HostHUDQR, room code, PIN, copyable join link, peer count, status; locale en/ja/auto, every label overridable
ControllerWrapperPhone-side shell: status bar, reconnect banner (onReconnect), wake lock, iOS motion button (motion), fullscreen + orientation lock. Props: transport or status, roomCode, locale, labels; children may be a function of { status, motionPermission }

Full reference and the Firebase data layout: README on GitHub.

08For everyone

Not an engineer? Pick one path

For people who run events, venues, classes, streams, showrooms or exhibitions and want the audience to join with their own phones. These are separate paths, not steps.

Path A

A. Just see it work

Open the live demo in two tabs. Nothing to install, no account, no card. For two phones, try snap-pair-lite.html.

Path B

B. Have an AI build my app

No download needed. Paste the prompt below into Claude Code, Cursor, Codex, Gemini CLI or a similar tool. The only manual step is a one-time Firebase sign-in click.

Path C

C. Work with the source

Clone the repository, run npm install and npm test, then read the API overview and the design notes in docs/.

Prompt for your AI coding tool

prompt
Fetch https://raw.githubusercontent.com/takaoumehara/snap-pair-skill/main/SKILL.md
and use it as your build instructions.

Connect these two MCP servers if they aren't connected yet:
- firebase: npx -y firebase-tools@latest mcp
- snap-pair-provisioner: npx -y snap-pair-provisioner

Then help me build: [describe what you want, e.g. "a live quiz game where
guests join by QR code and answer on their phones"].

Firebase plans at a glance

GoalUseCredit card?Others join by URL?
Learn, experiment, let a child buildFirebase EmulatorNoNo, local only
Free public demo, small groupsSpark + Lite modeNoYes, up to 100 connections
Real guests, server-checked roomsBlaze planYesYes

PartyKit, WebRTC and BroadcastChannel don't need Firebase at all. On Blaze, always set a budget alert.

09Security model

A pairing code locates a room. It is not a password.

Firebase

Firebase: server-authoritative

Auth identifies each browser, Cloud Functions admit participants, and RTDB rules let only members update narrowly scoped fields. Roles, membership, capacity and codes stay server-only.

Relays

Relays: host-authoritative

With PartyKit, WebRTC and BroadcastChannel the host's browser owns the roster and state. Gate entry with admit(peer) and maxPlayers. Room keys are hashed and namespaced.

PIN

PINs are short

A 6-digit PIN has 10⁶ values. Rate-limit your public relay, validate payloads, throttle writes, and keep payments in a separate trusted server.

10FAQ

Frequently asked questions

Do guests need to install an app?
No. Guests open a URL in the browser they already have, by scanning a QR code or typing a code.
Which transport should I start with?
Prototyping on one machine: BroadcastChannel. Phones over the internet: PartyKit (WebRTC for the lowest latency in small rooms). Shared-state apps with server-checked joins: Firebase, which has no ephemeral messaging, so pair it with PartyKit for streamed input. Or run npx snap-pair init (or npx snap-pair recommend "your idea") and let the CLI decide.
Do I need a credit card?
Not for BroadcastChannel, the Firebase Emulator, or Firebase Spark Lite mode. Firebase's secure mode needs the Blaze plan, which needs a card, though small events usually stay within the free quota.
How many people can join one room?
Firebase secure rooms are designed for up to 300. Relay transports are limited by maxPlayers and the host browser. WebRTC's star topology suits small groups best.
Why doesn't motion work on my iPhone?
iOS 13+ needs a permission prompt triggered by a tap, over HTTPS. Call requestOrientationPermission() from a click handler, or use ControllerWrapper, which adds the button for you.
Can I use it without React?
Yes. Transports, pairing helpers and client utilities are plain TypeScript. The live demo on this site is plain JavaScript.
Does it work with Next.js and SSR?
Yes. No module touches window, document or navigator at import time. Create transports inside effects or client components.

11Roadmap

Where snap-pair is going

  • DonePhase 1: Transport abstraction, FirebaseTransport, HostHUD
  • DonePhase 2: PartyKit, WebRTC and BroadcastChannel transports; PIN and QR helpers; wake lock and orientation
  • DonePhase 3: npx snap-pair init (4 paths, en/ja) with presets and recommend, 7 preset templates, ControllerWrapper, useSnapPair({ transport }), i18n, ESM/CJS build with an exports map, WebRTC auto-reconnect and chunking
  • DoneProximity: Web Audio ultrasonic pairing (experimental)
  • NextWebRTC: renegotiation (extra channels, media), binary payloads, backpressure (bufferedAmount), mesh topology, host migration, real-browser e2e tests
  • NextBundle size: the root useSnapPair imports firebase/auth statically; a Firebase-free hook entry or lazy loading
  • LaterFirebase: numeric PINs and ephemeral messaging, so presets can run on it. More locales, TURN guidance