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 +373 -0
- package/CHANGELOG.md +35 -0
- package/LICENSE +21 -0
- package/PROTOCOL.md +236 -0
- package/README.md +269 -2
- package/THIRD_PARTY_NOTICES.md +66 -0
- package/llms.txt +102 -0
- package/package.json +79 -4
- package/public/client.d.ts +88 -0
- package/public/client.js +182 -0
- package/public/host.d.ts +116 -0
- package/public/host.js +195 -0
- package/public/link.js +73 -0
- package/public/pad-layout.js +152 -0
- package/public/pad.css +268 -0
- package/public/pad.html +57 -0
- package/public/pad.js +344 -0
- package/src/cli.js +69 -0
- package/src/lan.js +34 -0
- package/src/server.d.ts +17 -0
- package/src/server.js +479 -0
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.
|