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.
package/AGENTS.md ADDED
@@ -0,0 +1,373 @@
1
+ # Joinstick for coding agents
2
+
3
+ Joinstick turns phones into gamepads for browser games. The game page (the
4
+ **host**) shows one QR code per player slot; a player scans it and the phone
5
+ becomes a controller. Read this file before integrating; it is the whole
6
+ contract. Wire format: [PROTOCOL.md](PROTOCOL.md). Types:
7
+ [`public/host.d.ts`](public/host.d.ts), [`public/client.d.ts`](public/client.d.ts).
8
+
9
+ ## 1. Run the server
10
+
11
+ Node 22+:
12
+
13
+ ```sh
14
+ npx joinstick --static ./my-game # serves the game AND Joinstick on :3000
15
+ # or add it to your project: npm i joinstick, then npx joinstick --static ./my-game
16
+ ```
17
+
18
+ Open `http://localhost:3000/` on the computer. Phones must be on the same
19
+ Wi-Fi. Without `--static`, Joinstick runs alone and your game is served by
20
+ something else (see "Game served elsewhere" below).
21
+
22
+ ## 2. Integrate (copy-paste)
23
+
24
+ ```js
25
+ import { host } from '/joinstick/host.js';
26
+
27
+ const room = await host({ slots: 2, layout: { stick: 'dpad', buttons: [{ id: 'a', label: 'A' }, { id: 'b', label: 'B' }] } });
28
+ for (let n = 1; n <= room.slots; n++) showQr(n, room.qr(n), room.joinUrl(n)); // <img src=qr> + the URL as text
29
+
30
+ function tick() {
31
+ for (let n = 1; n <= room.slots; n++) {
32
+ const { x, y, buttons, pressed } = room.input(n); // poll once per frame per slot
33
+ // move with x/y (and held buttons); trigger one-shot actions with pressed.a
34
+ }
35
+ requestAnimationFrame(tick);
36
+ }
37
+ tick();
38
+ ```
39
+
40
+ Slots are **1-based** everywhere. `room.input(0)` throws `RangeError`.
41
+
42
+ ## 3. Pause when a player is missing
43
+
44
+ The simplest correct recipe is to check every frame:
45
+
46
+ ```js
47
+ const everyone = () => [1, 2].every((n) => room.connected(n));
48
+ // in your frame loop:
49
+ const paused = !everyone();
50
+ ```
51
+
52
+ With events, also check once at start, because pads that were already
53
+ connected when `host()` resolved do not get a `join` event (see below):
54
+
55
+ ```js
56
+ room.on('leave', ({ slot, reason }) => pause(`Waiting for player ${slot}`));
57
+ room.on('join', () => { if (everyone()) resume(); });
58
+ if (!everyone()) pause('Waiting for players');
59
+ ```
60
+
61
+ `leave` fires immediately when a phone is locked, backgrounded, loses Wi-Fi,
62
+ holds "Leave", or is kicked. The same phone rejoins the same slot by itself
63
+ when it comes back; a new person can also scan that slot's QR.
64
+
65
+ Exact ordering in each reconnect case:
66
+
67
+ | Case | What the game sees |
68
+ |---|---|
69
+ | Game page reloaded (same tab) | `host()` resumes the room. Pads already connected show as `connected(n) === true` **when `host()` resolves**; no `join` event for them. Pads that arrive later get `join`. Held input is restored. |
70
+ | Host loses the server briefly (Wi-Fi blip) | `leave` (reason `'disconnected'`) for every connected slot, then `join` with `rejoin: true` for each once back. |
71
+ | Server restarted | `leave` (`'disconnected'`) for every slot; the host recreates the same code; each phone rejoins with a `join` whose `rejoin` is **false** (the server forgot the tokens). |
72
+ | Phone locked / Wi-Fi lost, then back | `leave` (`'disconnected'`), later `join` with `rejoin: true`. |
73
+
74
+ ## 4. Input contract
75
+
76
+ `room.input(n)` returns `{ x, y, buttons, pressed }` and is **never undefined**.
77
+
78
+ | Field | Meaning |
79
+ |---|---|
80
+ | `x` | -1 left .. 1 right. The d-pad gives -1, 0 or 1. |
81
+ | `y` | -1 **up** .. 1 down (screen convention, like the Gamepad API). |
82
+ | `buttons` | Held state of every layout button: `{ a: false, b: true }`. |
83
+ | `pressed` | Buttons that went down since your previous `input(n)` call: `{ b: true }`. Latched and **cleared on read**, so a tap shorter than one frame is never lost and never counted twice. |
84
+
85
+ - Use `pressed` for one-shot actions (attack, jump, confirm, pause). **Never
86
+ use held state (`buttons`) to trigger attacks**: you would fire every frame
87
+ or miss fast taps.
88
+ - Use `buttons` for things that last while held (block, run, charge).
89
+ - Neutral (`x = y = 0`, all buttons false, `pressed` empty) when the slot is
90
+ empty and right after `leave`. After a `join` or a game reload, a direction
91
+ or button the player is already holding shows in `x`/`y`/`buttons` but never
92
+ in `pressed`.
93
+ - Read `input(n)` exactly once per frame per slot; a second read in the same
94
+ frame has an empty `pressed`.
95
+ - The d-pad is 8-way. Up and the up-diagonals need the thumb clearly above
96
+ horizontal (30° or more), so a resting thumb does not jump by accident.
97
+ - There is no input event; polling is the only way to read input.
98
+ - **Keep calling `input(n)` every frame while paused** and ignore the result.
99
+ Otherwise presses made during the pause are still latched and fire the
100
+ moment you resume.
101
+
102
+ ## 5. Layout
103
+
104
+ Passed to `host({ layout })`. Validated by the server; an invalid layout
105
+ makes `host()` reject with `code: 'bad-message'` and a message saying why.
106
+
107
+ ```js
108
+ {
109
+ stick: 'dpad', // 'dpad' (default) or 'none'
110
+ orientation: 'landscape', // 'landscape' (default), 'portrait' or 'any'
111
+ arrangement: 'grid', // 'grid' (default), 'diamond' or 'arc'
112
+ haptics: true, // tap feedback on the phone (default true)
113
+ buttons: [ // 0-8 buttons; default: [{id:'a'},{id:'b'}]
114
+ { id: 'punch', label: 'P', color: '#e5484d' },
115
+ { id: 'kick', label: 'K', haptic: 20 },
116
+ { id: 'super', label: 'SUPER', size: 'large' },
117
+ { id: 'block', label: 'BLOCK', size: 'wide' },
118
+ { id: 'start', label: 'START', system: true },
119
+ ],
120
+ }
121
+ ```
122
+
123
+ - `id`: `/^[A-Za-z0-9_-]{1,16}$/`, unique, and not an `Object.prototype`
124
+ name (`constructor`, `toString`, `__proto__`...). It is the key in `buttons`/`pressed`.
125
+ - `label`: ≤ 8 chars (defaults to the id); the pad shrinks long labels to fit
126
+ the round button, so short labels read best. `color`: `/^#[0-9a-f]{3,8}$/i`.
127
+ - `size`: `'large'` (bigger round button), `'wide'` (a long bar under the
128
+ others, easy to find blind and rest a thumb on: good for a held action) or omitted.
129
+ - `system: true`: a small pill in the middle of the pad's top bar, away from
130
+ the thumbs (START, MENU, PAUSE). It reports in `buttons`/`pressed` like any
131
+ other button. The 8-button limit counts it.
132
+ - `orientation`: with `'landscape'` (the default) a phone held upright shows
133
+ the pad rotated 90°, so the player turns the phone sideways; it works even
134
+ with the rotation lock on, and the d-pad ends up under the left thumb. On
135
+ Android Chrome the first tap also goes fullscreen and locks landscape.
136
+ `'portrait'` is the mirror case; `'any'` follows the phone.
137
+ - `arrangement` (gameplay buttons only; order matters):
138
+ - `'grid'`: rows and columns, in order.
139
+ - `'diamond'`: buttons 1-4 as face buttons (bottom, left, right, top), the
140
+ rest in a column beside them.
141
+ - `'arc'`: button 1 is big and sits where the right thumb rests; the rest
142
+ fan out around it (left, then over the top). Put the most used action first.
143
+ - Taps between buttons go to the nearest one: there are no dead gaps.
144
+
145
+ **Haptics.** The pad gives a short tick on every button press and d-pad
146
+ direction change (`haptics: false` turns it off; `haptic: ms` per button,
147
+ 0 = none). What actually works:
148
+
149
+ | Phone | Tap feedback | `room.pad(n, { vibrate })` and highlight buzz |
150
+ |---|---|---|
151
+ | Android Chrome/Firefox | yes (`navigator.vibrate`) | yes |
152
+ | iOS 18+ Safari | best effort: one fixed system tick per tap, via a hidden switch control; length is ignored | no |
153
+ | iOS ≤ 17, desktop | no (fails silently) | no |
154
+
155
+ ## 6. Change a pad while playing
156
+
157
+ ```js
158
+ room.pad(1, { title: 'Ryu', color: '#e5484d' }); // header text (≤ 20 chars) and accent color
159
+ room.pad(1, { highlight: ['a'] }); // glow and make the phone vibrate briefly (Android only)
160
+ room.pad(1, { disabled: ['super'] }); // greyed out, cannot be pressed
161
+ room.pad(1, { vibrate: 200 }); // one-shot, ms (0-2000), Android only
162
+ room.pad(1, { highlight: [], disabled: [] }); // clear
163
+ ```
164
+
165
+ Patches merge; the server remembers the last state per slot and re-sends it
166
+ when a phone (re)joins. A button that is both highlighted and disabled is
167
+ shown disabled, without glow (disabled wins).
168
+
169
+ A **game page reload** (any new `host()` call that resumes the room) resets
170
+ every pad to the default state, because the game starts from scratch. In-session
171
+ reconnects (Wi-Fi blips, server restarts) keep the pad state. Pads still
172
+ connected after a reload get no `join` event, so set their state at start too:
173
+
174
+ ```js
175
+ const greet = (slot) => room.pad(slot, { title: `Player ${slot}` });
176
+ room.on('join', ({ slot }) => greet(slot));
177
+ for (let n = 1; n <= room.slots; n++) if (room.connected(n)) greet(n);
178
+ ```
179
+
180
+ ## 7. Full host API
181
+
182
+ ```ts
183
+ const room = await host({ slots?: 1-8 = 2, layout?, server?: string });
184
+ room.code // 'KQZX' (4 consonants)
185
+ room.slots; room.status // 'connecting' | 'connected' | 'reconnecting' | 'closed'
186
+ room.input(n) // see section 4
187
+ room.connected(n) // boolean
188
+ room.qr(n) // URL of an SVG QR code for <img src>
189
+ room.joinUrl(n) // the URL inside the QR: show it as text too
190
+ room.pad(n, patch) // section 6
191
+ room.kick(n) // disconnect that phone; it gets a "removed" screen
192
+ room.send(n, data) // any JSON to one phone (custom controllers)
193
+ room.broadcast(data) // any JSON to every phone
194
+ room.on(event, fn) // returns an unsubscribe function
195
+ room.close() // leave and forget the room (refresh keeps it otherwise)
196
+ ```
197
+
198
+ Events: `join {slot, name, rejoin}`, `leave {slot, reason}` where reason is
199
+ `'left' | 'disconnected' | 'timeout' | 'kicked'`, `message {slot, data}`,
200
+ `status` (string), `error {code, message}` (server errors after `host()`
201
+ resolved).
202
+
203
+ `host()` rejects with an `Error` whose `code` is `'bad-message'` (invalid
204
+ options), `'server-full'` or `'version'`, or with "Cannot reach the Joinstick
205
+ server" when nothing answers. It never hangs on an error.
206
+
207
+ A page refresh or a server restart is transparent: the room code and hostToken
208
+ are kept in `sessionStorage`, the host reconnects with backoff and recreates
209
+ the same code, and phones rejoin. QR codes stay valid. A stored room is only
210
+ resumed with the same server URL and the same `slots`; otherwise `host()`
211
+ creates a new room.
212
+
213
+ **Duplicated tabs:** Chrome's "Duplicate tab" copies `sessionStorage`, so the
214
+ new tab resumes the same room and the original tab gets an `error` event with
215
+ `code: 'replaced'`, then `status: 'closed'` (it stops reconnecting). Show a
216
+ "this game is open in another tab" message on that event.
217
+
218
+ ## 8. CLI and embedding
219
+
220
+ ```
221
+ joinstick [--port 3000] [--static ./dir] [--public-url URL]
222
+ ```
223
+
224
+ | Flag | Env | Purpose |
225
+ |---|---|---|
226
+ | `--port` | `PORT` | Port (default 3000). |
227
+ | `--static <dir>` | | Serve your game from the same server (recommended). |
228
+ | `--public-url <url>` | `PUBLIC_URL` | Base URL phones should open (tunnels, Docker, reverse proxies). Must be an origin like `https://abc.example.com`, without a path: Joinstick's routes live at the root. |
229
+
230
+ The join URL base is chosen as: `--public-url`, else the `Host` header the
231
+ game used if it is not loopback, else the detected LAN IPv4 (192.168.x
232
+ preferred; Docker/VPN/virtual interfaces skipped). All candidates are printed
233
+ at startup.
234
+
235
+ `--static` refuses dotfiles (`.env`, `.git/...`) and reads each file whole,
236
+ without HTTP Range support: fine for game assets, but Safari needs Range
237
+ requests to play `<video>`/`<audio>`, so serve media elsewhere if you need it.
238
+
239
+ Embed in an existing Node server instead of using the CLI:
240
+
241
+ ```js
242
+ import http from 'node:http';
243
+ import { attach } from 'joinstick';
244
+ const server = http.createServer(app); // your handler keeps every other path
245
+ attach(server, { static: './dist', publicUrl: process.env.PUBLIC_URL });
246
+ server.listen(3000);
247
+ ```
248
+
249
+ Call `attach()` after every other `'request'` listener is registered: it wraps
250
+ the listeners that exist at that moment, and later ones would also answer
251
+ Joinstick's routes.
252
+
253
+ Append `?name=Ana` to a join URL to give the player a name (shown to the host
254
+ in the `join` event).
255
+
256
+ Routes: WebSocket `/joinstick/ws`; SDKs `/joinstick/host.js`,
257
+ `/joinstick/client.js` (CORS `*`); pad page `/j` and `/j/ROOM/SLOT`; QR
258
+ `/joinstick/qr.svg?room=&slot=` (existing rooms only); `/llms.txt`.
259
+
260
+ ### Game served elsewhere (Vite, another port)
261
+
262
+ ```js
263
+ import { host } from 'joinstick/host'; // npm i joinstick (types included)
264
+ const room = await host({ slots: 2, server: `http://${location.hostname}:3000` });
265
+ ```
266
+
267
+ Pass `server` whenever host.js is bundled; by default it connects to the
268
+ origin it was loaded from. Or import it at runtime from
269
+ `http://localhost:3000/joinstick/host.js` (CORS is open).
270
+
271
+ ## 9. Custom controllers and remote play
272
+
273
+ `/joinstick/client.js` is what the default pad page is built on. Use it for a
274
+ custom phone UI or a remote client:
275
+
276
+ ```js
277
+ import { join } from '/joinstick/client.js';
278
+ const pad = await join({ room: 'KQZX', slot: 1, name: 'Ana' }); // slot omitted = first free
279
+ pad.setInput({ x: 0, y: -1, buttons: { a: true } }); // full state on every change
280
+ pad.send({ chat: 'gg' }); // host gets room.on('message')
281
+ pad.on('message', (data) => {}); // from room.send / broadcast
282
+ pad.on('pad', (state, patch) => {}); // title/color/highlight/disabled/vibrate
283
+ pad.on('error', (err) => {}); // kicked, room-closed, replaced...
284
+ pad.on('status', (s) => {});
285
+ pad.leave();
286
+ ```
287
+
288
+ `join()` rejects with `err.code` in `room-not-found`, `slot-taken` (with
289
+ `err.free`, the free slots), `bad-slot`, `version`, `unreachable`. Tokens are
290
+ kept in `localStorage` (`joinstick:ROOM:SLOT`) so the same browser rejoins the
291
+ same slot.
292
+
293
+ Remote players over the internet:
294
+
295
+ ```sh
296
+ cloudflared tunnel --url http://localhost:3000 # prints https://xyz.trycloudflare.com
297
+ node joinstick/src/cli.js --static ./my-game --public-url https://xyz.trycloudflare.com
298
+ ```
299
+
300
+ QR codes and `joinUrl()` then point at the tunnel; phones use `wss://` automatically.
301
+
302
+ Room codes are 4 letters (160,000 combinations), so on a public URL anyone
303
+ patient can guess one and take a free slot. That is fine for playing with
304
+ friends; there is no authentication beyond the code. The server caps rooms at
305
+ 1000 in total and 20 per client address (behind a tunnel all hosts share one
306
+ address).
307
+
308
+ ## 10. Error codes
309
+
310
+ | Code | Meaning |
311
+ |---|---|
312
+ | `room-not-found` | No room with that code (typo, or the host is gone). |
313
+ | `slot-taken` | Another phone holds that slot. Comes with `free: [..]`. |
314
+ | `bad-slot` | Slot is not an integer in 1..slots. |
315
+ | `bad-message` | Malformed or invalid message; `message` says why. Not fatal. |
316
+ | `replaced` | The same player (or host) connected again elsewhere; this connection is closed. |
317
+ | `kicked` | The host called `room.kick(n)`. |
318
+ | `room-closed` | The host was gone for 60 s; the room was deleted. |
319
+ | `version` | Protocol version mismatch. |
320
+ | `server-full` | 1000 rooms already exist, or this address already created 20. |
321
+ | `rate-limited` | 20 failed joins from this address in a minute (code guessing); wait a minute. |
322
+ | `unreachable` | Client SDK only: the server could not be reached. |
323
+
324
+ ## 11. Pitfalls
325
+
326
+ - **First run on macOS/Windows** shows a firewall prompt for Node. Allow it, or
327
+ phones cannot connect.
328
+ - **Guest / hotel / office Wi-Fi** often isolates clients (AP isolation):
329
+ phones cannot reach the laptop. Use a phone hotspot or `--public-url` with a tunnel.
330
+ - **Plain HTTP on a LAN** works, but phones get no wake lock and no fullscreen
331
+ on iOS. Host-driven `vibrate` only works on Android (see Haptics in section 5).
332
+ - **Mixed content:** a game page served over HTTPS cannot open `ws://` to a LAN
333
+ server. Serve the game with `--static`, or put both behind HTTPS (tunnel +
334
+ `--public-url`).
335
+ - **Docker** breaks LAN IP detection (you get the container IP). Pass
336
+ `--public-url http://<host-lan-ip>:3000`.
337
+ - Check the printed "Phones join at" URL: if it is not reachable from a phone,
338
+ nothing else will be.
339
+ - A pad in a **background tab** leaves its slot (that is the lock-screen
340
+ behavior). When testing with pads in the browser, keep them in the visible
341
+ game tab or a separate window, or use a Node script (next section).
342
+
343
+ ## 12. Testing without phones
344
+
345
+ Drive pads from code with the client SDK; it is how you verify an
346
+ integration end to end. In the game tab's DevTools console (the room code is
347
+ `room.code`; use the full `http://localhost:3000/joinstick/client.js` URL if
348
+ the game is served from another origin):
349
+
350
+ ```js
351
+ const { join } = await import('/joinstick/client.js');
352
+ const pad = await join({ room: 'KQZX', slot: 1 });
353
+ pad.setInput({ x: 1 }); // hold right
354
+ pad.setInput({ x: 1, buttons: { a: true } }); // press A...
355
+ pad.setInput({ x: 1, buttons: { a: false } }); // ...and release: pressed.a once
356
+ pad.leave(); // the game should pause
357
+ ```
358
+
359
+ Or from a Node 22+ script next to the checkout (`node pads.mjs KQZX`):
360
+
361
+ ```js
362
+ import { join } from './joinstick/public/client.js';
363
+ const pad = await join({ server: 'http://localhost:3000', room: process.argv[2], slot: 2 });
364
+ setInterval(() => pad.setInput({ x: Math.sign(Math.random() - 0.5) }), 500);
365
+ ```
366
+
367
+ ## 13. Don't
368
+
369
+ - Don't use `innerHTML` with player names, messages or anything from the
370
+ network. Use `textContent`.
371
+ - Don't trigger one-shot actions from held state; use `pressed`.
372
+ - Don't call `input(n)` more than once per frame per slot (it consumes `pressed`).
373
+ - Don't assume a slot is connected; check `room.connected(n)` or listen to `leave`.
package/CHANGELOG.md ADDED
@@ -0,0 +1,35 @@
1
+ # Changelog
2
+
3
+ All notable changes to this project are documented here. The format follows
4
+ [Keep a Changelog](https://keepachangelog.com/en/1.1.0/), and the project uses
5
+ [Semantic Versioning](https://semver.org/spec/v2.0.0.html). While the version
6
+ is 0.x, a minor release may change the API; the changelog says how.
7
+
8
+ ## [0.1.0] - 2026-10-04
9
+
10
+ First public release.
11
+
12
+ ### Added
13
+
14
+ - `joinstick` CLI: one Node server for rooms, the phone pad page at `/j`, the
15
+ browser SDKs at `/joinstick/*`, QR codes and `/llms.txt`; `--static` serves
16
+ your game on the same port, `--public-url` sets the address in QR codes.
17
+ - `attach(server, opts)` to embed Joinstick in an existing Node HTTP server.
18
+ - Game SDK (`host.js`): rooms with up to 8 slots, per-frame `input(n)` with
19
+ held `buttons` and one-shot `pressed`, join/leave events, QR and join
20
+ URLs, live pad changes (title, color, highlight, disabled, vibrate),
21
+ messages both ways.
22
+ - Phone SDK (`client.js`) for custom controllers and scripted test pads.
23
+ - Pad page: d-pad and up to 8 buttons; `grid`, `diamond` and `arc`
24
+ arrangements; `large` and `wide` buttons; `system` buttons (START/MENU) in
25
+ the top bar; landscape by default with CSS rotation when the phone is held
26
+ upright; tap haptics (Android, best effort on iOS 18+); multi-touch.
27
+ - Reconnection: phones that lock or drop Wi-Fi rejoin their slot; game page
28
+ reloads and server restarts keep the room code (AGENTS.md has the details).
29
+ - Room safety: 20 failed joins per minute per client address, then
30
+ `rate-limited`; per-address room cap; 4-letter room codes.
31
+ - `--version` flag.
32
+ - JSON/WebSocket protocol v1 (PROTOCOL.md), AGENTS.md for coding agents,
33
+ TypeScript declarations, Dockerfile.
34
+
35
+ [0.1.0]: https://github.com/raprav/joinstick/releases/tag/v0.1.0
package/LICENSE ADDED
@@ -0,0 +1,21 @@
1
+ MIT License
2
+
3
+ Copyright (c) 2026 Rafa Prats
4
+
5
+ Permission is hereby granted, free of charge, to any person obtaining a copy
6
+ of this software and associated documentation files (the "Software"), to deal
7
+ in the Software without restriction, including without limitation the rights
8
+ to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
9
+ copies of the Software, and to permit persons to whom the Software is
10
+ furnished to do so, subject to the following conditions:
11
+
12
+ The above copyright notice and this permission notice shall be included in all
13
+ copies or substantial portions of the Software.
14
+
15
+ THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
16
+ IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
17
+ FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
18
+ AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
19
+ LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
20
+ OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
21
+ SOFTWARE.
package/PROTOCOL.md ADDED
@@ -0,0 +1,236 @@
1
+ # Joinstick protocol, version 1
2
+
3
+ Plain JSON text frames over one WebSocket at `/joinstick/ws`. Every message is
4
+ an object with a string field `t`. You only need this to write a client in
5
+ another language; browser code should use `host.js` / `client.js`.
6
+
7
+ ```
8
+ host (game) server pad (phone)
9
+ | create -------------------> | |
10
+ | <------------------ created | |
11
+ | | <---------------------- join
12
+ | <--------------------- join | joined ------------------> |
13
+ | <-------------------- input | <--------------------- input
14
+ | pad / send / kick ---------> | pad / message / error ---> |
15
+ | <------------------- leave | (socket closed) |
16
+ ```
17
+
18
+ A connection is a host or a pad, decided by its first message (`create` or
19
+ `join`). Anything else first gets `bad-message`.
20
+
21
+ ## Common rules
22
+
23
+ - Max frame size 8 KB; bigger frames close the socket with code 1009.
24
+ - Unknown or malformed messages get `{t:'error', code:'bad-message', message}`
25
+ and are otherwise ignored.
26
+ - Slots are 1-based integers. Room codes are 4 letters from
27
+ `BCDFGHJKLMNPQRSTVWXZ`, case-insensitive (the server upper-cases them).
28
+ - Fatal errors are sent as `{t:'error', code}` and then the server closes the
29
+ socket with code 4000 and the error code as reason.
30
+
31
+ ## Liveness
32
+
33
+ The server sends `{t:'ping'}` every 2 s to every socket. Clients answer
34
+ `{t:'pong'}`. Any message counts as activity. The server drops a socket after
35
+ 5 s of silence; clients should drop and reconnect after 5 s without any
36
+ message from the server.
37
+
38
+ ## Host → server
39
+
40
+ ### `create`
41
+
42
+ ```json
43
+ {"t":"create","v":1,"slots":2,"layout":{"stick":"dpad","buttons":[{"id":"a","label":"A"}]},"room":"KQZX","hostToken":"…","resetPads":true}
44
+ ```
45
+
46
+ | Field | |
47
+ |---|---|
48
+ | `v` | Must be `1`, else fatal `version`. |
49
+ | `slots` | 1-8, default 2. |
50
+ | `layout` | Optional. `stick`: `'dpad'` (default) or `'none'`. `orientation`: `'landscape'`, `'portrait'` or `'any'`. `arrangement`: `'grid'`, `'diamond'` or `'arc'`. `haptics`: boolean. `buttons`: ≤ 8 of `{id, label?, color?, size?, haptic?, system?}`; `id` `/^[A-Za-z0-9_-]{1,16}$/`, unique, not an `Object.prototype` name; `label` ≤ 8 chars (default id), `color` `/^#[0-9a-f]{3,8}$/i`, `size` `'large'` or `'wide'`, `haptic` integer ms 0-100, `system` boolean. Optional fields are echoed only when given; the pad applies the defaults (landscape, grid, haptics on, 10 ms). Default: d-pad + buttons `a`, `b`. |
51
+ | `room`, `hostToken` | Optional, to resume. |
52
+ | `resetPads` | Optional. `true` on resume clears every slot's pad state (the game was reloaded). The host SDK sends it on the first `create` of each `host()` call. |
53
+
54
+ Resume-or-recreate:
55
+
56
+ - `room` exists, `hostToken` matches and `slots` is the same → **resume** the
57
+ same room and pads. A previous host connection gets fatal `replaced`. If the
58
+ layout changed or `resetPads` is true, connected pads receive a fresh
59
+ `joined`. Right after `created`, the server sends the last `input` of every
60
+ connected pad, so held state survives the reload.
61
+ - `room` does not exist, `room` is a valid code and `hostToken` is 32 hex
62
+ chars → **recreate** the room with that code and token (server restart case).
63
+ - Otherwise → a new room with a new code and token.
64
+
65
+ More than 1000 rooms, or 20 rooms created from the same client address →
66
+ fatal `server-full`.
67
+
68
+ ### `pad`
69
+
70
+ ```json
71
+ {"t":"pad","slot":1,"patch":{"title":"Ryu","color":"#e5484d","highlight":["a"],"disabled":["b"],"vibrate":200}}
72
+ ```
73
+
74
+ All patch fields optional: `title` (string, trimmed to 20 chars), `color` (hex),
75
+ `highlight` / `disabled` (arrays of ≤ 8 button ids), `vibrate` (integer
76
+ 0-2000 ms). The server merges the patch into that slot's pad state (everything
77
+ but `vibrate`), keeps it while the room lives, and forwards the patch to the
78
+ pad.
79
+
80
+ ### `send`
81
+
82
+ ```json
83
+ {"t":"send","slot":1,"data":{"any":"json"}}
84
+ {"t":"send","slot":null,"data":"to every pad"}
85
+ ```
86
+
87
+ ### `kick`
88
+
89
+ ```json
90
+ {"t":"kick","slot":1}
91
+ ```
92
+
93
+ The pad gets fatal `kicked`; the host gets `leave` with reason `kicked`.
94
+
95
+ ## Server → host
96
+
97
+ ### `created`
98
+
99
+ ```json
100
+ {"t":"created","room":"KQZX","hostToken":"9f…32 hex","joinBase":"http://192.168.1.20:3000","layout":{…validated…},"slots":[{"slot":1,"connected":false,"name":""}]}
101
+ ```
102
+
103
+ Pads join at `${joinBase}/j/${room}/${slot}`. The QR for a slot is
104
+ `/joinstick/qr.svg?room=KQZX&slot=1` on the Joinstick server.
105
+
106
+ ### `join`
107
+
108
+ ```json
109
+ {"t":"join","slot":1,"name":"Ana","rejoin":false}
110
+ ```
111
+
112
+ `rejoin` is true when the token is the same as the slot's previous occupant
113
+ (same phone coming back, or taking over from another tab).
114
+
115
+ ### `leave`
116
+
117
+ ```json
118
+ {"t":"leave","slot":1,"reason":"left"}
119
+ ```
120
+
121
+ `reason`: `left` (pad closed with code 1000), `disconnected` (any other
122
+ close, including a locked phone), `timeout` (no pong for 5 s), `kicked`.
123
+
124
+ ### `input`
125
+
126
+ ```json
127
+ {"t":"input","slot":1,"x":-1,"y":0,"b":{"a":true,"b":false}}
128
+ ```
129
+
130
+ Full state, forwarded as sent by the pad (x, y clamped to [-1, 1]). The first
131
+ `input` after a `join` (or after `created` on resume) is the slot's baseline:
132
+ buttons already down in it are held, not new presses. The client SDK always
133
+ sends one right after `joined`.
134
+
135
+ ### `message`
136
+
137
+ ```json
138
+ {"t":"message","slot":1,"data":{"any":"json"}}
139
+ ```
140
+
141
+ ## Pad → server
142
+
143
+ ### `join`
144
+
145
+ ```json
146
+ {"t":"join","v":1,"room":"KQZX","slot":1,"token":"32 hex chars","name":"Ana"}
147
+ ```
148
+
149
+ | Field | |
150
+ |---|---|
151
+ | `v` | Must be `1`, else fatal `version`. |
152
+ | `room` | Case-insensitive. Unknown → `room-not-found`. |
153
+ | `slot` | Optional. Omitted/null → the slot this token held before, else the first free one. Not 1..slots → `bad-slot`. |
154
+ | `token` | `/^[A-Za-z0-9_-]{16,64}$/`, generated and stored by the client. Identifies the phone. |
155
+ | `name` | Optional, control chars stripped, trimmed to 20 chars. |
156
+
157
+ The first join and a rejoin are the same message. Outcomes:
158
+
159
+ - Slot empty → taken.
160
+ - Slot occupied by the **same token** → the new connection wins; the old one
161
+ gets fatal `replaced`.
162
+ - Slot occupied by **another token** → `{t:'error', code:'slot-taken', free:[2,3]}`.
163
+
164
+ After 5 failed joins on one socket the server closes it. After 20 failed
165
+ joins from one client address within a minute, every further `join` from
166
+ that address gets fatal `rate-limited` until the minute is over. Failures in
167
+ the first minute after the server starts are not counted: phones are still
168
+ waiting for their game to recreate its room.
169
+
170
+ ### `input`
171
+
172
+ ```json
173
+ {"t":"input","x":0,"y":-1,"b":{"a":true}}
174
+ ```
175
+
176
+ Full state on every change. `x`, `y` are finite numbers (clamped to [-1, 1];
177
+ `y` = -1 is up). `b` maps ≤ 16 button ids to booleans (`Object.prototype`
178
+ names are refused). While the host is away it is not forwarded, but the
179
+ server keeps the latest one per slot for the host's resume. Send your current
180
+ state right after every `joined`.
181
+
182
+ ### `msg`
183
+
184
+ ```json
185
+ {"t":"msg","data":{"any":"json"}}
186
+ ```
187
+
188
+ Delivered to the host as `message` with the pad's slot.
189
+
190
+ ## Server → pad
191
+
192
+ ### `joined`
193
+
194
+ ```json
195
+ {"t":"joined","room":"KQZX","slot":1,"layout":{…},"pad":{"title":"Ryu","color":"#e5484d"}}
196
+ ```
197
+
198
+ `pad` is the slot's current pad state. Sent again (same shape) when the host
199
+ resumes with a different layout or with `resetPads`.
200
+
201
+ ### `pad`
202
+
203
+ ```json
204
+ {"t":"pad","patch":{"highlight":["a"],"vibrate":200}}
205
+ ```
206
+
207
+ ### `message`
208
+
209
+ ```json
210
+ {"t":"message","data":{"any":"json"}}
211
+ ```
212
+
213
+ ## Errors
214
+
215
+ | Code | Fatal | When |
216
+ |---|---|---|
217
+ | `room-not-found` | no | `join` with an unknown room. |
218
+ | `slot-taken` | no | Slot held by another token. Includes `free`. |
219
+ | `bad-slot` | no | Slot is not an integer in 1..slots. |
220
+ | `bad-message` | no | Invalid JSON, shape or values. Includes `message`. |
221
+ | `replaced` | yes | Same host token or pad token connected again. |
222
+ | `kicked` | yes | Host kicked this pad. |
223
+ | `room-closed` | yes | Host gone for 60 s; room deleted. |
224
+ | `version` | yes | `v` is not 1. |
225
+ | `server-full` | yes | Room limit reached (1000 total, 20 per client address). |
226
+ | `rate-limited` | yes | 20 failed joins from this client address in the last minute. |
227
+
228
+ "Fatal" means the server closes the socket right after the error. Failed
229
+ joins (the non-fatal join errors) count toward the 5-failure limit.
230
+
231
+ ## Host disconnects
232
+
233
+ When the host socket closes, the room stays for 60 s. Pads stay connected
234
+ (their input is dropped). A host that sends `create` with the same `room` and
235
+ `hostToken` within that time resumes it; otherwise every pad gets
236
+ `room-closed` and the room is deleted.