joinstick 0.0.0-stage → 0.1.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.
@@ -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>. */
94
+ qr(n: number): string;
95
+ /** The URL inside the QR. Show it as text under the QR. */
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>;
package/public/host.js ADDED
@@ -0,0 +1,195 @@
1
+ // Joinstick host SDK: create a room, show one QR per player slot, poll input.
2
+ // Plain ESM, no dependencies. Types: host.d.ts. Protocol: PROTOCOL.md.
3
+ import { link } from './link.js';
4
+
5
+ const STORE_KEY = 'joinstick:host';
6
+
7
+ function storage() {
8
+ try {
9
+ return globalThis.sessionStorage ?? null;
10
+ } catch {
11
+ return null;
12
+ }
13
+ }
14
+
15
+ export function host({ slots = 2, layout, server } = {}) {
16
+ const base = (server ?? new URL('..', import.meta.url).href).replace(/\/+$/, '');
17
+ const store = storage();
18
+ let saved = null;
19
+ try {
20
+ saved = JSON.parse(store?.getItem(STORE_KEY) ?? 'null');
21
+ } catch {}
22
+ // A stored room is resumed only by the same server and slot count.
23
+ if (saved?.server !== base || saved?.slots !== slots) saved = null;
24
+
25
+ let code = saved?.room ?? null;
26
+ let hostToken = saved?.hostToken ?? null;
27
+ let joinBase = base;
28
+ let buttonIds = [];
29
+ let status = 'connecting';
30
+ let ready = false;
31
+ let resumedOnce = false;
32
+ const listeners = {};
33
+ const padState = {};
34
+ const state = Array.from({ length: slots }, () => ({ connected: false, name: '', x: 0, y: 0, buttons: {}, pressed: {}, baseline: true }));
35
+
36
+ function emit(ev, arg) {
37
+ for (const fn of listeners[ev] ?? []) {
38
+ try {
39
+ fn(arg);
40
+ } catch (err) {
41
+ console.error(err);
42
+ }
43
+ }
44
+ }
45
+
46
+ function setStatus(s) {
47
+ if (s !== status) emit('status', (status = s));
48
+ }
49
+
50
+ function at(n) {
51
+ if (!Number.isInteger(n) || n < 1 || n > slots) throw new RangeError(`slot must be an integer 1-${slots} (slots are 1-based)`);
52
+ return state[n - 1];
53
+ }
54
+
55
+ function neutral(s) {
56
+ s.x = 0;
57
+ s.y = 0;
58
+ s.buttons = Object.fromEntries(buttonIds.map((id) => [id, false]));
59
+ s.pressed = {};
60
+ s.baseline = true; // the next input sets held state without "pressed" edges
61
+ }
62
+
63
+ function leave(n, reason) {
64
+ const s = state[n - 1];
65
+ neutral(s);
66
+ if (!s.connected) return;
67
+ s.connected = false;
68
+ emit('leave', { slot: n, reason });
69
+ }
70
+
71
+ function join(n, name, rejoin) {
72
+ const s = state[n - 1];
73
+ neutral(s);
74
+ s.connected = true;
75
+ s.name = name;
76
+ emit('join', { slot: n, name, rejoin });
77
+ }
78
+
79
+ return new Promise((resolve, reject) => {
80
+ const conn = link(base.replace(/^http/, 'ws') + '/joinstick/ws', {
81
+ // The first create of this host() call resets pad state: a reloaded game
82
+ // starts from scratch, while in-session reconnects keep it.
83
+ open: () => conn.send({ t: 'create', v: 1, slots, layout, room: code, hostToken, resetPads: !resumedOnce }),
84
+ down() {
85
+ for (let n = 1; n <= slots; n++) leave(n, 'disconnected');
86
+ if (!ready) return fail(new Error(`Cannot reach the Joinstick server at ${base}`));
87
+ setStatus('reconnecting');
88
+ conn.retry();
89
+ },
90
+ message(m) {
91
+ try {
92
+ handle(m);
93
+ } catch (err) {
94
+ if (!ready) return fail(err);
95
+ console.error(err);
96
+ }
97
+ },
98
+ });
99
+
100
+ function handle(m) {
101
+ if (m.t === 'created') {
102
+ resumedOnce = true;
103
+ code = m.room;
104
+ hostToken = m.hostToken;
105
+ joinBase = m.joinBase;
106
+ buttonIds = m.layout.buttons.map((b) => b.id);
107
+ try {
108
+ store?.setItem(STORE_KEY, JSON.stringify({ server: base, slots, room: code, hostToken }));
109
+ } catch {}
110
+ conn.up();
111
+ for (const s of state) neutral(s);
112
+ for (const s of m.slots) if (s.connected) join(s.slot, s.name, true);
113
+ // The server forgets pad state if it restarted; send ours again.
114
+ for (const [n, patch] of Object.entries(padState)) conn.send({ t: 'pad', slot: Number(n), patch });
115
+ setStatus('connected');
116
+ if (!ready) resolve(room);
117
+ ready = true;
118
+ } else if (m.t === 'join') {
119
+ join(m.slot, m.name, m.rejoin);
120
+ } else if (m.t === 'leave') {
121
+ leave(m.slot, m.reason);
122
+ } else if (m.t === 'input') {
123
+ const s = state[m.slot - 1];
124
+ if (!s) return;
125
+ const buttons = {};
126
+ for (const id of buttonIds) buttons[id] = Object.hasOwn(m.b, id) && m.b[id] === true;
127
+ if (!s.baseline) for (const id of buttonIds) if (buttons[id] && !s.buttons[id]) s.pressed[id] = true;
128
+ s.baseline = false;
129
+ s.x = m.x;
130
+ s.y = m.y;
131
+ s.buttons = buttons;
132
+ } else if (m.t === 'message') {
133
+ emit('message', { slot: m.slot, data: m.data });
134
+ } else if (m.t === 'error') {
135
+ if (!ready) return fail(Object.assign(new Error(`Joinstick: ${m.code}${m.message ? ` (${m.message})` : ''}`), { code: m.code }));
136
+ emit('error', { code: m.code, message: m.message });
137
+ if (m.code === 'replaced') return close(); // another tab took this room
138
+ if (!listeners.error?.size) console.warn('joinstick:', m.code, m.message ?? '');
139
+ }
140
+ }
141
+
142
+ function fail(err) {
143
+ close();
144
+ reject(err);
145
+ }
146
+
147
+ function close() {
148
+ conn.stop(1000);
149
+ for (let n = 1; n <= slots; n++) leave(n, 'disconnected');
150
+ setStatus('closed');
151
+ }
152
+
153
+ const room = {
154
+ get code() {
155
+ return code;
156
+ },
157
+ get slots() {
158
+ return slots;
159
+ },
160
+ get status() {
161
+ return status;
162
+ },
163
+ input(n) {
164
+ const s = at(n);
165
+ const out = { x: s.x, y: s.y, buttons: { ...s.buttons }, pressed: s.pressed };
166
+ s.pressed = {};
167
+ return out;
168
+ },
169
+ connected: (n) => at(n).connected,
170
+ qr: (n) => at(n) && `${base}/joinstick/qr.svg?room=${code}&slot=${n}`,
171
+ joinUrl: (n) => at(n) && `${joinBase}/j/${code}/${n}`,
172
+ pad(n, patch) {
173
+ at(n);
174
+ const { vibrate, ...keep } = patch;
175
+ padState[n] = { ...padState[n], ...keep };
176
+ conn.send({ t: 'pad', slot: n, patch });
177
+ },
178
+ kick: (n) => at(n) && conn.send({ t: 'kick', slot: n }),
179
+ send: (n, data) => at(n) && conn.send({ t: 'send', slot: n, data }),
180
+ broadcast: (data) => conn.send({ t: 'send', slot: null, data }),
181
+ on(ev, fn) {
182
+ (listeners[ev] ??= new Set()).add(fn);
183
+ return () => listeners[ev].delete(fn);
184
+ },
185
+ close() {
186
+ try {
187
+ store?.removeItem(STORE_KEY);
188
+ } catch {}
189
+ close();
190
+ },
191
+ };
192
+
193
+ conn.connect();
194
+ });
195
+ }
package/public/link.js ADDED
@@ -0,0 +1,73 @@
1
+ // Shared WebSocket plumbing for host.js and client.js:
2
+ // JSON messages, ping/pong, a silence watchdog and reconnect backoff.
3
+
4
+ const SILENCE = 5000;
5
+
6
+ export function link(url, { open, message, down }) {
7
+ let ws = null;
8
+ let lastMsg = 0;
9
+ let attempts = 0;
10
+ let retryTimer;
11
+ let stopped = false;
12
+
13
+ function connect() {
14
+ clearTimeout(retryTimer);
15
+ detach(); // never two sockets at once
16
+ lastMsg = Date.now();
17
+ ws = new WebSocket(url);
18
+ ws.onopen = open;
19
+ ws.onmessage = (e) => {
20
+ lastMsg = Date.now();
21
+ let m;
22
+ try {
23
+ m = JSON.parse(e.data);
24
+ } catch {
25
+ return;
26
+ }
27
+ if (m.t === 'ping') send({ t: 'pong' });
28
+ else message(m);
29
+ };
30
+ // Some runtimes (Node 22) fire 'error' without 'close' on a refused connection.
31
+ ws.onclose = ws.onerror = () => drop();
32
+ }
33
+
34
+ function detach(code) {
35
+ if (!ws) return false;
36
+ ws.onopen = ws.onmessage = ws.onclose = ws.onerror = null;
37
+ try {
38
+ ws.close(code);
39
+ } catch {}
40
+ ws = null;
41
+ return true;
42
+ }
43
+
44
+ // Close the socket right away (a dead connection can take long to report
45
+ // 'close') and tell the owner it is down. Also cancels a pending retry.
46
+ function drop(code) {
47
+ clearTimeout(retryTimer);
48
+ if (detach(code) && !stopped) down();
49
+ }
50
+
51
+ function send(m) {
52
+ if (ws?.readyState === 1) ws.send(JSON.stringify(m));
53
+ }
54
+
55
+ const watchdog = setInterval(() => {
56
+ if (ws && Date.now() - lastMsg > SILENCE) drop();
57
+ }, 1000);
58
+ watchdog.unref?.();
59
+
60
+ return {
61
+ send,
62
+ connect,
63
+ drop,
64
+ up: () => (attempts = 0),
65
+ retry: () => (retryTimer = setTimeout(connect, Math.min(5000, 250 * 2 ** attempts++))),
66
+ stop(code) {
67
+ stopped = true;
68
+ clearInterval(watchdog);
69
+ clearTimeout(retryTimer);
70
+ drop(code);
71
+ },
72
+ };
73
+ }