joinstick 0.0.0-stage → 0.2.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/llms.txt ADDED
@@ -0,0 +1,102 @@
1
+ # Joinstick
2
+
3
+ > Turn phones into gamepads for browser games. The game page (host) shows one QR code for everyone (or one per slot); scanning it opens a controller page on the phone in the first free slot. Self-hosted Node server (`npx joinstick`), plain ESM browser SDKs, JSON over WebSocket.
4
+
5
+ Full guide: AGENTS.md. Wire format: PROTOCOL.md. Types: /joinstick/host.d.ts, /joinstick/client.d.ts (all in the npm package and at https://github.com/raprav/joinstick).
6
+
7
+ ## Run
8
+
9
+ `npx joinstick --static ./my-game` serves the game and Joinstick on http://localhost:3000 (or `npm i joinstick`, then `npx joinstick`). Node 22+. Phones must be on the same Wi-Fi. Flags: `--port` (PORT, default 3000), `--static <dir>`, `--public-url <url>` (PUBLIC_URL; base URL phones open, an origin without a path, for tunnels/Docker/proxies), `--client-ip-header <name>` (CLIENT_IP_HEADER; behind a trusted proxy, the header with the visitor's address for the per-address limits).
10
+
11
+ ## Integrate
12
+
13
+ ```js
14
+ import { host } from '/joinstick/host.js';
15
+ const room = await host({ slots: 2, layout: { stick: 'dpad', buttons: [{ id: 'a', label: 'A' }, { id: 'b', label: 'B' }] } });
16
+ // show <img src={room.qr()}> and the text room.joinUrl(): each phone that scans it gets the first free slot (room.qr(n) for a fixed slot)
17
+ function tick() {
18
+ for (let n = 1; n <= room.slots; n++) {
19
+ const { x, y, buttons, pressed } = room.input(n); // once per frame per slot
20
+ }
21
+ requestAnimationFrame(tick);
22
+ }
23
+ tick();
24
+ ```
25
+
26
+ If host.js is bundled or the game is served from another origin: `import { host } from 'joinstick/host'` (package installed from git or a checkout) and pass `server: 'http://<host>:3000'`.
27
+
28
+ ## Input contract
29
+
30
+ - Slots are 1-based. `room.input(0)` throws RangeError.
31
+ - `room.input(n)` is never undefined: `{ x, y, buttons, pressed }`. Neutral when the slot is empty and after leave. After a join or game reload, input already held shows in x/y/buttons but never in pressed.
32
+ - `x`: -1 left .. 1 right. `y`: -1 up .. 1 down. The d-pad gives -1/0/1 (8-way; up needs the thumb clearly above horizontal).
33
+ - `buttons`: held state of every layout button. Use for movement, block, charge.
34
+ - `pressed`: buttons that went down since the previous `input(n)` call, latched and cleared on read. Use for attacks, jumps, confirm. Never use held state to trigger one-shot actions.
35
+ - No input events: polling is the only way to read input.
36
+ - Keep calling input(n) every frame while paused (ignore the result), or presses made during the pause fire on resume.
37
+
38
+ ## Pause recipe
39
+
40
+ ```js
41
+ const paused = ![1, 2].every((n) => room.connected(n)); // every frame: simplest and always right
42
+ room.on('leave', ({ slot, reason }) => pause(slot)); // reason: left | disconnected | timeout | kicked
43
+ room.on('join', ({ slot, name, rejoin }) => maybeResume());
44
+ ```
45
+
46
+ A locked/backgrounded phone leaves immediately and rejoins its slot when back (join with rejoin: true). Game page reload: host() resumes the same room; pads already connected are visible via connected(n) when host() resolves and get NO join event, so check connected(n) at start; held input is restored; every pad's state (title/color/highlight/disabled) is reset. Host Wi-Fi blip: leave (disconnected) for all, then join with rejoin: true. Server restart: same room code; phones rejoin with rejoin: false. A stored room is only resumed with the same slots count. A duplicated browser tab takes over the room: the original gets room.on('error') with code 'replaced' and status 'closed'.
47
+
48
+ ## Layout
49
+
50
+ `{ stick: 'dpad' | 'none', orientation?: 'landscape' (default) | 'portrait' | 'any', arrangement?: 'grid' (default) | 'diamond' | 'arc', haptics?: true, buttons: [{ id, label?, color?, size?: 'large' | 'wide', haptic?: ms 0-100, system?: true }] }`, max 8 buttons (system ones included). id `/^[A-Za-z0-9_-]{1,16}$/` unique, not an Object.prototype name (constructor, toString...); label ≤ 8 chars; color `/^#[0-9a-f]{3,8}$/i`. Default: d-pad + a, b. Invalid layout → host() rejects with code 'bad-message'.
51
+ - orientation 'landscape': a phone held upright shows the pad rotated 90° (works with the rotation lock on); Android Chrome also goes fullscreen and locks the orientation on the first tap.
52
+ - arrangement: 'diamond' = first 4 buttons as face buttons (bottom, left, right, top), the rest beside them; 'arc' = first button big under the right thumb, the rest fanned around it.
53
+ - size 'wide': a long bar under the other buttons (good for a held action like block).
54
+ - system: true renders a small pill in the pad's top bar (START/MENU); it reports in input(n).buttons/pressed like any other button.
55
+ - haptics: local tap feedback on press and d-pad direction change (Android: vibrate; iOS 18+: best-effort system tick; desktop: none). layout.haptics false turns it off; per-button haptic sets ms (0 = off).
56
+
57
+ ## Host API
58
+
59
+ room.code, room.slots, room.status ('connecting'|'connected'|'reconnecting'|'closed'), room.input(n), room.connected(n), room.qr(n?), room.joinUrl(n?) (no n: first free slot), room.pad(n, { title?, color?, highlight?: ids (glow and make the phone vibrate briefly (Android only); disabled wins over highlight), disabled?: ids, vibrate?: ms }), room.kick(n), room.send(n, data), room.broadcast(data), room.on('join'|'leave'|'message'|'status'|'error', fn) → unsubscribe, room.close().
60
+
61
+ ## Client API (custom controllers, remote players)
62
+
63
+ ```js
64
+ import { join } from '/joinstick/client.js';
65
+ const pad = await join({ room: 'KQZX', slot: 1, name: 'Ana' }); // slot omitted = first free
66
+ pad.setInput({ x, y, buttons: { a: true } }); pad.send(data); pad.on('message'|'pad'|'error'|'status', fn); pad.leave();
67
+ ```
68
+
69
+ ## Error codes
70
+
71
+ room-not-found, slot-taken (with free: [slots]), bad-slot, bad-message (with message), replaced, kicked, room-closed (host gone 60 s), version, server-full (1000 rooms, or 20 from one address), rate-limited (20 failed joins from one address in a minute); client-only: unreachable.
72
+
73
+ ## Pitfalls
74
+
75
+ - First run: allow Node through the OS firewall prompt.
76
+ - Guest/hotel Wi-Fi with client isolation blocks phones: use a hotspot or a tunnel.
77
+ - Plain HTTP: no wake lock, no iOS fullscreen. Host-driven vibrate (room.pad vibrate/highlight) works only on Android.
78
+ - An HTTPS game page cannot reach a ws:// LAN server (mixed content): serve the game with --static or put both behind HTTPS.
79
+ - Docker breaks LAN IP detection: use --public-url.
80
+ - Remote play: `cloudflared tunnel --url http://localhost:3000`, then start Joinstick with `--public-url https://<name>.trycloudflare.com`. 4-letter room codes are guessable on a public URL; there is no other authentication.
81
+ - --static refuses dotfiles and has no HTTP Range support (Safari video/audio needs Range: serve media elsewhere).
82
+ - Embedding with attach(server): call it after registering your own 'request' listeners.
83
+ - A pad in a background browser tab leaves its slot (lock-screen behavior).
84
+
85
+ ## Testing without phones
86
+
87
+ In the game tab's console (or a Node 22+ script importing joinstick/public/client.js with `server: 'http://localhost:3000'`):
88
+
89
+ ```js
90
+ const { join } = await import('/joinstick/client.js');
91
+ const pad = await join({ room: room.code, slot: 1 });
92
+ pad.setInput({ x: 1 }); // hold right
93
+ pad.setInput({ x: 1, buttons: { a: true } }); // press A
94
+ pad.setInput({ x: 1, buttons: { a: false } }); // release: input(1).pressed.a seen once
95
+ pad.leave(); // game should pause
96
+ ```
97
+
98
+ ## Don't
99
+
100
+ - Don't use innerHTML with player names or network data; use textContent.
101
+ - Don't read held state for one-shot actions; use pressed.
102
+ - Don't call input(n) twice per frame for the same slot.
package/package.json CHANGED
@@ -1,6 +1,81 @@
1
1
  {
2
2
  "name": "joinstick",
3
- "version": "0.0.0-stage",
4
- "stub": true,
5
- "description": "Temporary package placeholder for staged publishing"
6
- }
3
+ "version": "0.2.0",
4
+ "description": "Use phones as gamepads for HTML5 browser games: scan a QR, play. Self-hosted and open source, no accounts, no app install.",
5
+ "license": "MIT",
6
+ "author": {
7
+ "name": "Rafa Prats",
8
+ "url": "https://github.com/raprav"
9
+ },
10
+ "homepage": "https://github.com/raprav/joinstick#readme",
11
+ "repository": {
12
+ "type": "git",
13
+ "url": "git+https://github.com/raprav/joinstick.git"
14
+ },
15
+ "bugs": {
16
+ "url": "https://github.com/raprav/joinstick/issues"
17
+ },
18
+ "keywords": [
19
+ "gamepad",
20
+ "controller",
21
+ "phone-controller",
22
+ "mobile-controller",
23
+ "phone-gamepad",
24
+ "virtual-gamepad",
25
+ "touch-controls",
26
+ "local-multiplayer",
27
+ "multiplayer",
28
+ "party-game",
29
+ "couch-coop",
30
+ "smartphone-controller",
31
+ "game-controller",
32
+ "qr",
33
+ "qr-code",
34
+ "websocket",
35
+ "html5",
36
+ "html5-game",
37
+ "browser-game",
38
+ "self-hosted"
39
+ ],
40
+ "type": "module",
41
+ "main": "src/server.js",
42
+ "types": "src/server.d.ts",
43
+ "exports": {
44
+ ".": {
45
+ "types": "./src/server.d.ts",
46
+ "default": "./src/server.js"
47
+ },
48
+ "./host": {
49
+ "types": "./public/host.d.ts",
50
+ "default": "./public/host.js"
51
+ },
52
+ "./client": {
53
+ "types": "./public/client.d.ts",
54
+ "default": "./public/client.js"
55
+ },
56
+ "./package.json": "./package.json"
57
+ },
58
+ "bin": {
59
+ "joinstick": "src/cli.js"
60
+ },
61
+ "files": [
62
+ "src",
63
+ "public",
64
+ "llms.txt",
65
+ "AGENTS.md",
66
+ "PROTOCOL.md",
67
+ "CHANGELOG.md",
68
+ "THIRD_PARTY_NOTICES.md"
69
+ ],
70
+ "engines": {
71
+ "node": ">=22"
72
+ },
73
+ "scripts": {
74
+ "start": "node src/cli.js",
75
+ "test": "node --test"
76
+ },
77
+ "dependencies": {
78
+ "uqr": "^0.1.3",
79
+ "ws": "^8.22.0"
80
+ }
81
+ }
@@ -0,0 +1,88 @@
1
+ import type { Layout } from './host.js';
2
+
3
+ export type { Layout } from './host.js';
4
+
5
+ export interface PadState {
6
+ title?: string;
7
+ color?: string;
8
+ highlight?: string[];
9
+ disabled?: string[];
10
+ }
11
+
12
+ export interface PadPatch extends PadState {
13
+ vibrate?: number;
14
+ }
15
+
16
+ export type ErrorCode =
17
+ | 'room-not-found'
18
+ | 'slot-taken'
19
+ | 'bad-slot'
20
+ | 'bad-message'
21
+ | 'replaced'
22
+ | 'kicked'
23
+ | 'room-closed'
24
+ | 'version'
25
+ | 'rate-limited'
26
+ | 'unreachable';
27
+
28
+ export interface JoinError {
29
+ code: ErrorCode;
30
+ message?: string;
31
+ /** With 'slot-taken': the slots that are free right now. */
32
+ free?: number[];
33
+ }
34
+
35
+ export type Status = 'connecting' | 'connected' | 'reconnecting' | 'closed';
36
+
37
+ export interface PadInput {
38
+ /** -1 (left) .. 1 (right). */
39
+ x?: number;
40
+ /** -1 (up) .. 1 (down). */
41
+ y?: number;
42
+ /** Full held state, e.g. { a: true, b: false }. */
43
+ buttons?: Record<string, boolean>;
44
+ }
45
+
46
+ export interface PadEvents {
47
+ /** Data sent by the host with room.send() or room.broadcast(). */
48
+ message: [data: unknown];
49
+ /** The host changed this pad. `state` is the merged state, `patch` what changed (may include vibrate). */
50
+ pad: [state: PadState, patch: PadPatch];
51
+ /** Fatal error after joining (kicked, room-closed, replaced, slot-taken on rejoin...). The pad is closed. */
52
+ error: [error: JoinError];
53
+ status: [status: Status];
54
+ }
55
+
56
+ export interface JoinOptions {
57
+ /** Room code, case-insensitive. */
58
+ room: string;
59
+ /** 1-based slot. Omit to take the first free one. */
60
+ slot?: number;
61
+ /** Player name shown to the host, at most 20 chars. */
62
+ name?: string;
63
+ /** Joinstick server URL. Default: where client.js was loaded from. */
64
+ server?: string;
65
+ }
66
+
67
+ export interface Pad {
68
+ readonly room: string;
69
+ readonly slot: number;
70
+ /** Layout chosen by the host. Render labels with textContent only. */
71
+ readonly layout: Layout & Required<Pick<Layout, 'stick' | 'buttons'>>;
72
+ readonly state: PadState;
73
+ readonly status: Status;
74
+ /** Send the full current state. Cheap to call often: unchanged state is not resent. */
75
+ setInput(input: PadInput): void;
76
+ /** Send any JSON value to the host (arrives as room.on('message')). */
77
+ send(data: unknown): void;
78
+ on<E extends keyof PadEvents>(event: E, fn: (...args: PadEvents[E]) => void): () => void;
79
+ /** Leave the slot for good (no auto-rejoin). The host sees reason 'left'. */
80
+ leave(): void;
81
+ }
82
+
83
+ /**
84
+ * Join a room. Reconnects and rejoins the same slot by itself (network blips,
85
+ * phone locked, server restart). Rejects with an Error carrying a JoinError's
86
+ * fields (`code`, `free`) if the first join fails.
87
+ */
88
+ export function join(options: JoinOptions): Promise<Pad>;
@@ -0,0 +1,182 @@
1
+ // Joinstick client SDK: join a room as a player from a phone (or any browser).
2
+ // The default pad page is built on this. Types: client.d.ts. Protocol: PROTOCOL.md.
3
+ import { link } from './link.js';
4
+
5
+ const REJOIN_WINDOW = 60_000;
6
+ const FATAL = new Set(['room-not-found', 'slot-taken', 'bad-slot', 'replaced', 'kicked', 'room-closed', 'version', 'rate-limited']);
7
+ const memory = new Map();
8
+
9
+ function storage() {
10
+ if (typeof window === 'undefined') return null;
11
+ try {
12
+ return window.localStorage;
13
+ } catch {
14
+ return null;
15
+ }
16
+ }
17
+
18
+ function getItem(key) {
19
+ try {
20
+ return storage()?.getItem(key) ?? memory.get(key) ?? null;
21
+ } catch {
22
+ return memory.get(key) ?? null;
23
+ }
24
+ }
25
+
26
+ function setItem(key, value) {
27
+ memory.set(key, value);
28
+ try {
29
+ storage()?.setItem(key, value);
30
+ } catch {}
31
+ }
32
+
33
+ // crypto.randomUUID() is missing on plain-HTTP LAN origins; getRandomValues is not.
34
+ function newToken() {
35
+ return Array.from(crypto.getRandomValues(new Uint8Array(16)), (b) => b.toString(16).padStart(2, '0')).join('');
36
+ }
37
+
38
+ const NEUTRAL = { t: 'input', x: 0, y: 0, b: {} };
39
+
40
+ export function join({ room: roomCode, slot, name, server } = {}) {
41
+ const base = (server ?? new URL('..', import.meta.url).href).replace(/\/+$/, '');
42
+ const room = String(roomCode ?? '').trim().toUpperCase();
43
+ let token = (slot && getItem(`joinstick:${room}:${slot}`)) || newToken();
44
+ let layout = null;
45
+ let padState = {};
46
+ let status = 'connecting';
47
+ let ready = false;
48
+ let closed = false;
49
+ let hidden = false;
50
+ let lostAt = 0;
51
+ let input = NEUTRAL;
52
+ let lastSent = '';
53
+ const listeners = {};
54
+
55
+ function emit(ev, ...args) {
56
+ for (const fn of listeners[ev] ?? []) {
57
+ try {
58
+ fn(...args);
59
+ } catch (err) {
60
+ console.error(err);
61
+ }
62
+ }
63
+ }
64
+
65
+ function setStatus(s) {
66
+ if (s !== status) emit('status', (status = s));
67
+ }
68
+
69
+ function sendInput(force) {
70
+ const s = JSON.stringify(input);
71
+ if (s === lastSent && !force) return;
72
+ lastSent = s;
73
+ conn.send(input);
74
+ }
75
+
76
+ let resolveJoin, rejectJoin;
77
+ const joined = new Promise((res, rej) => ((resolveJoin = res), (rejectJoin = rej)));
78
+
79
+ const conn = link(base.replace(/^http/, 'ws') + '/joinstick/ws', {
80
+ open: () => conn.send({ t: 'join', v: 1, room, slot, token, name }),
81
+ down() {
82
+ if (!ready) return end({ code: 'unreachable', message: `Cannot reach the Joinstick server at ${base}` });
83
+ lostAt ||= Date.now();
84
+ setStatus('reconnecting');
85
+ if (!hidden) conn.retry();
86
+ },
87
+ message(m) {
88
+ if (m.t === 'joined') {
89
+ slot = m.slot;
90
+ layout = m.layout;
91
+ padState = { ...m.pad };
92
+ setItem(`joinstick:${room}:${slot}`, token);
93
+ conn.up();
94
+ lostAt = 0;
95
+ // Always send the current state first: the host treats it as the
96
+ // baseline, so a button held through a rejoin is not a new press.
97
+ sendInput(true);
98
+ if (!ready) resolveJoin(pad);
99
+ ready = true;
100
+ emit('pad', padState, {});
101
+ setStatus('connected');
102
+ } else if (m.t === 'pad') {
103
+ const { vibrate, ...keep } = m.patch;
104
+ padState = { ...padState, ...keep };
105
+ emit('pad', padState, m.patch);
106
+ } else if (m.t === 'message') {
107
+ emit('message', m.data);
108
+ } else if (m.t === 'error') {
109
+ // After a server restart the host needs a moment to recreate the room.
110
+ if (m.code === 'room-not-found' && ready && Date.now() - lostAt < REJOIN_WINDOW) return conn.drop();
111
+ if (FATAL.has(m.code)) return end(m);
112
+ console.warn('joinstick:', m.code, m.message ?? '');
113
+ }
114
+ },
115
+ });
116
+
117
+ function end(err) {
118
+ if (closed) return;
119
+ closed = true;
120
+ conn.stop(1000);
121
+ setStatus('closed');
122
+ if (!ready) return rejectJoin(Object.assign(new Error(`Joinstick: ${err.code}`), err));
123
+ emit('error', err);
124
+ }
125
+
126
+ // A locked or backgrounded phone leaves at once (the game can pause) and
127
+ // rejoins the same slot when it comes back.
128
+ function suspend() {
129
+ if (!ready || closed || hidden) return;
130
+ hidden = true;
131
+ input = NEUTRAL;
132
+ conn.drop(4001);
133
+ }
134
+
135
+ function resume() {
136
+ if (!hidden || closed) return;
137
+ hidden = false;
138
+ conn.connect();
139
+ }
140
+
141
+ if (typeof document !== 'undefined') {
142
+ document.addEventListener('visibilitychange', () => (document.hidden ? suspend() : resume()));
143
+ addEventListener('pagehide', suspend);
144
+ addEventListener('pageshow', resume);
145
+ }
146
+
147
+ const pad = {
148
+ get room() {
149
+ return room;
150
+ },
151
+ get slot() {
152
+ return slot;
153
+ },
154
+ get layout() {
155
+ return layout;
156
+ },
157
+ get state() {
158
+ return padState;
159
+ },
160
+ get status() {
161
+ return status;
162
+ },
163
+ setInput({ x = 0, y = 0, buttons = {} } = {}) {
164
+ input = { t: 'input', x, y, b: buttons };
165
+ sendInput();
166
+ },
167
+ send: (data) => conn.send({ t: 'msg', data }),
168
+ on(ev, fn) {
169
+ (listeners[ev] ??= new Set()).add(fn);
170
+ return () => listeners[ev].delete(fn);
171
+ },
172
+ leave() {
173
+ if (closed) return;
174
+ closed = true;
175
+ conn.stop(1000);
176
+ setStatus('closed');
177
+ },
178
+ };
179
+
180
+ conn.connect();
181
+ return joined;
182
+ }
@@ -0,0 +1,116 @@
1
+ /** Controller layout rendered by the default pad page. Validated by the server. */
2
+ export interface Layout {
3
+ /** 'dpad' (default) shows an 8-way d-pad on the left; 'none' hides it. */
4
+ stick?: 'dpad' | 'none';
5
+ /** At most 8 buttons, system buttons included. */
6
+ buttons?: Button[];
7
+ /**
8
+ * 'landscape' (default): a phone held upright shows the pad rotated, so the
9
+ * player turns it sideways (works with the rotation lock on). 'portrait'
10
+ * does the opposite; 'any' follows the phone.
11
+ */
12
+ orientation?: 'landscape' | 'portrait' | 'any';
13
+ /**
14
+ * How the pad places the gameplay buttons. 'grid' (default): rows and columns.
15
+ * 'diamond': the first four as face buttons (bottom, left, right, top), the
16
+ * rest beside them. 'arc': the first button big under the thumb, the rest
17
+ * fanned around it.
18
+ */
19
+ arrangement?: 'grid' | 'diamond' | 'arc';
20
+ /** Short local vibration on every press and d-pad direction change. Default true. */
21
+ haptics?: boolean;
22
+ }
23
+
24
+ export interface Button {
25
+ /** Key in `input(n).buttons`. Must match /^[A-Za-z0-9_-]{1,16}$/, be unique and not be an Object.prototype name (e.g. 'constructor'). */
26
+ id: string;
27
+ /** Text on the button, at most 8 chars. Defaults to the id. */
28
+ label?: string;
29
+ /** Hex color, /^#[0-9a-f]{3,8}$/i. */
30
+ color?: string;
31
+ /** 'large': a bigger round button. 'wide': a long bar under the other buttons (good for a held action). */
32
+ size?: 'large' | 'wide';
33
+ /** Tap feedback length in ms (0-100, 0 = none). Default 10. */
34
+ haptic?: number;
35
+ /** A small pill in the pad's top bar (START, MENU...), away from the thumbs. Reports like any other button. */
36
+ system?: boolean;
37
+ }
38
+
39
+ /** Change what one pad shows. Every field is optional; omitted fields keep their value. */
40
+ export interface PadPatch {
41
+ /** Shown in the pad header, at most 20 chars. '' clears it. */
42
+ title?: string;
43
+ /** Accent color of the pad (hex). */
44
+ color?: string;
45
+ /** Button ids that glow and make the phone vibrate briefly (Android only). Disabled wins over highlight. [] clears. */
46
+ highlight?: string[];
47
+ /** Button ids that cannot be pressed. [] clears. */
48
+ disabled?: string[];
49
+ /** Vibrate the phone for this many ms (0-2000). One-shot, Android only. */
50
+ vibrate?: number;
51
+ }
52
+
53
+ export interface Input {
54
+ /** -1 (left) .. 1 (right). The d-pad gives -1, 0 or 1. */
55
+ x: number;
56
+ /** -1 (up) .. 1 (down). The d-pad gives -1, 0 or 1. */
57
+ y: number;
58
+ /** Held state of every layout button. Use for movement/charging, not one-shot actions. */
59
+ buttons: Record<string, boolean>;
60
+ /** Buttons that went down since the previous input(n) call. Latched and cleared on read: use for attacks, jumps, menu selects. */
61
+ pressed: Record<string, true>;
62
+ }
63
+
64
+ export type LeaveReason = 'left' | 'disconnected' | 'timeout' | 'kicked';
65
+ export type Status = 'connecting' | 'connected' | 'reconnecting' | 'closed';
66
+
67
+ export interface HostEvents {
68
+ join: { slot: number; name: string; rejoin: boolean };
69
+ leave: { slot: number; reason: LeaveReason };
70
+ message: { slot: number; data: unknown };
71
+ status: Status;
72
+ /** Server errors after host() resolved. 'replaced' (another tab resumed this room, e.g. a duplicated tab) also closes this room object. */
73
+ error: { code: string; message?: string };
74
+ }
75
+
76
+ export interface HostOptions {
77
+ /** Number of player slots, 1-8. Default 2. */
78
+ slots?: number;
79
+ /** Default: d-pad plus buttons 'a' and 'b'. */
80
+ layout?: Layout;
81
+ /** Joinstick server URL, e.g. 'http://localhost:3000'. Default: where host.js was loaded from. Required when you bundle this file. */
82
+ server?: string;
83
+ }
84
+
85
+ export interface Room {
86
+ /** 4-letter room code, e.g. 'KQZX'. */
87
+ readonly code: string;
88
+ readonly slots: number;
89
+ readonly status: Status;
90
+ /** Current input of slot n (1-based). Never undefined: neutral when the slot is empty. Clears `pressed`. Throws RangeError for a bad slot. */
91
+ input(n: number): Input;
92
+ connected(n: number): boolean;
93
+ /** URL of an SVG QR code that opens the pad for slot n. Use as <img src>. Without n, every phone that scans it gets the first free slot. */
94
+ qr(n?: number): string;
95
+ /** The URL inside the QR. Show it as text under the QR. Without n: the first-free-slot link. */
96
+ joinUrl(n?: number): string;
97
+ pad(n: number, patch: PadPatch): void;
98
+ /** Disconnect the phone in slot n; the slot becomes free. */
99
+ kick(n: number): void;
100
+ /** Send any JSON value to the phone in slot n. */
101
+ send(n: number, data: unknown): void;
102
+ broadcast(data: unknown): void;
103
+ /** Returns a function that removes the listener. */
104
+ on<E extends keyof HostEvents>(event: E, fn: (arg: HostEvents[E]) => void): () => void;
105
+ /** Disconnect and forget the room (the server drops it 60 s later). */
106
+ close(): void;
107
+ }
108
+
109
+ /**
110
+ * Create a room, or resume the one stored in sessionStorage (same server and
111
+ * slot count) after a page reload. Resuming resets every pad's state (title,
112
+ * color, highlight, disabled). Pads already connected are visible through
113
+ * connected(n) when this resolves; no 'join' event is emitted for them.
114
+ * Rejects if the server is unreachable or refuses the options.
115
+ */
116
+ export function host(options?: HostOptions): Promise<Room>;