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/AGENTS.md +374 -0
- package/CHANGELOG.md +64 -0
- package/LICENSE +21 -0
- package/PROTOCOL.md +238 -0
- package/README.md +282 -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 +200 -0
- package/public/link.js +73 -0
- package/public/pad-layout.js +152 -0
- package/public/pad.css +270 -0
- package/public/pad.html +57 -0
- package/public/pad.js +345 -0
- package/src/cli.js +75 -0
- package/src/lan.js +34 -0
- package/src/server.d.ts +23 -0
- package/src/server.js +491 -0
package/AGENTS.md
ADDED
|
@@ -0,0 +1,374 @@
|
|
|
1
|
+
# Joinstick for coding agents
|
|
2
|
+
|
|
3
|
+
Joinstick turns phones into gamepads for browser games. The game page (the
|
|
4
|
+
**host**) shows a QR code; each player scans it and their phone becomes a
|
|
5
|
+
controller in the first free slot (per-slot QR codes exist too). Read this
|
|
6
|
+
file before integrating; it is the whole 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
|
+
showQr(room.qr(), room.joinUrl()); // <img src=qr> + the URL as text; each phone gets the first free slot (room.qr(n): a fixed slot)
|
|
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>; no n: first free slot
|
|
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] [--client-ip-header NAME]
|
|
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
|
+
| `--client-ip-header <name>` | `CLIENT_IP_HEADER` | Behind a reverse proxy: the header it sets to the visitor's address (`fly-client-ip`, `x-real-ip`; first value used). The 20-rooms and failed-join limits per address then use it. Never set it without a proxy that overwrites the header: clients can fake it. `attach()` option: `clientIpHeader`. |
|
|
230
|
+
|
|
231
|
+
The join URL base is chosen as: `--public-url`, else the `Host` header the
|
|
232
|
+
game used if it is not loopback, else the detected LAN IPv4 (192.168.x
|
|
233
|
+
preferred; Docker/VPN/virtual interfaces skipped). All candidates are printed
|
|
234
|
+
at startup.
|
|
235
|
+
|
|
236
|
+
`--static` refuses dotfiles (`.env`, `.git/...`) and reads each file whole,
|
|
237
|
+
without HTTP Range support: fine for game assets, but Safari needs Range
|
|
238
|
+
requests to play `<video>`/`<audio>`, so serve media elsewhere if you need it.
|
|
239
|
+
|
|
240
|
+
Embed in an existing Node server instead of using the CLI:
|
|
241
|
+
|
|
242
|
+
```js
|
|
243
|
+
import http from 'node:http';
|
|
244
|
+
import { attach } from 'joinstick';
|
|
245
|
+
const server = http.createServer(app); // your handler keeps every other path
|
|
246
|
+
attach(server, { static: './dist', publicUrl: process.env.PUBLIC_URL });
|
|
247
|
+
server.listen(3000);
|
|
248
|
+
```
|
|
249
|
+
|
|
250
|
+
Call `attach()` after every other `'request'` listener is registered: it wraps
|
|
251
|
+
the listeners that exist at that moment, and later ones would also answer
|
|
252
|
+
Joinstick's routes.
|
|
253
|
+
|
|
254
|
+
Append `?name=Ana` to a join URL to give the player a name (shown to the host
|
|
255
|
+
in the `join` event).
|
|
256
|
+
|
|
257
|
+
Routes: WebSocket `/joinstick/ws`; SDKs `/joinstick/host.js`,
|
|
258
|
+
`/joinstick/client.js` (CORS `*`); pad page `/j`, `/j/ROOM` (first free slot) and `/j/ROOM/SLOT`; QR
|
|
259
|
+
`/joinstick/qr.svg?room=&slot=` (existing rooms only, slot optional); `/llms.txt`.
|
|
260
|
+
|
|
261
|
+
### Game served elsewhere (Vite, another port)
|
|
262
|
+
|
|
263
|
+
```js
|
|
264
|
+
import { host } from 'joinstick/host'; // npm i joinstick (types included)
|
|
265
|
+
const room = await host({ slots: 2, server: `http://${location.hostname}:3000` });
|
|
266
|
+
```
|
|
267
|
+
|
|
268
|
+
Pass `server` whenever host.js is bundled; by default it connects to the
|
|
269
|
+
origin it was loaded from. Or import it at runtime from
|
|
270
|
+
`http://localhost:3000/joinstick/host.js` (CORS is open).
|
|
271
|
+
|
|
272
|
+
## 9. Custom controllers and remote play
|
|
273
|
+
|
|
274
|
+
`/joinstick/client.js` is what the default pad page is built on. Use it for a
|
|
275
|
+
custom phone UI or a remote client:
|
|
276
|
+
|
|
277
|
+
```js
|
|
278
|
+
import { join } from '/joinstick/client.js';
|
|
279
|
+
const pad = await join({ room: 'KQZX', slot: 1, name: 'Ana' }); // slot omitted = first free
|
|
280
|
+
pad.setInput({ x: 0, y: -1, buttons: { a: true } }); // full state on every change
|
|
281
|
+
pad.send({ chat: 'gg' }); // host gets room.on('message')
|
|
282
|
+
pad.on('message', (data) => {}); // from room.send / broadcast
|
|
283
|
+
pad.on('pad', (state, patch) => {}); // title/color/highlight/disabled/vibrate
|
|
284
|
+
pad.on('error', (err) => {}); // kicked, room-closed, replaced...
|
|
285
|
+
pad.on('status', (s) => {});
|
|
286
|
+
pad.leave();
|
|
287
|
+
```
|
|
288
|
+
|
|
289
|
+
`join()` rejects with `err.code` in `room-not-found`, `slot-taken` (with
|
|
290
|
+
`err.free`, the free slots), `bad-slot`, `version`, `unreachable`. Tokens are
|
|
291
|
+
kept in `localStorage` (`joinstick:ROOM:SLOT`) so the same browser rejoins the
|
|
292
|
+
same slot.
|
|
293
|
+
|
|
294
|
+
Remote players over the internet:
|
|
295
|
+
|
|
296
|
+
```sh
|
|
297
|
+
cloudflared tunnel --url http://localhost:3000 # prints https://xyz.trycloudflare.com
|
|
298
|
+
node joinstick/src/cli.js --static ./my-game --public-url https://xyz.trycloudflare.com
|
|
299
|
+
```
|
|
300
|
+
|
|
301
|
+
QR codes and `joinUrl()` then point at the tunnel; phones use `wss://` automatically.
|
|
302
|
+
|
|
303
|
+
Room codes are 4 letters (160,000 combinations), so on a public URL anyone
|
|
304
|
+
patient can guess one and take a free slot. That is fine for playing with
|
|
305
|
+
friends; there is no authentication beyond the code. The server caps rooms at
|
|
306
|
+
1000 in total and 20 per client address (behind a tunnel all hosts share one
|
|
307
|
+
address).
|
|
308
|
+
|
|
309
|
+
## 10. Error codes
|
|
310
|
+
|
|
311
|
+
| Code | Meaning |
|
|
312
|
+
|---|---|
|
|
313
|
+
| `room-not-found` | No room with that code (typo, or the host is gone). |
|
|
314
|
+
| `slot-taken` | Another phone holds that slot. Comes with `free: [..]`. |
|
|
315
|
+
| `bad-slot` | Slot is not an integer in 1..slots. |
|
|
316
|
+
| `bad-message` | Malformed or invalid message; `message` says why. Not fatal. |
|
|
317
|
+
| `replaced` | The same player (or host) connected again elsewhere; this connection is closed. |
|
|
318
|
+
| `kicked` | The host called `room.kick(n)`. |
|
|
319
|
+
| `room-closed` | The host was gone for 60 s; the room was deleted. |
|
|
320
|
+
| `version` | Protocol version mismatch. |
|
|
321
|
+
| `server-full` | 1000 rooms already exist, or this address already created 20. |
|
|
322
|
+
| `rate-limited` | 20 failed joins from this address in a minute (code guessing); wait a minute. |
|
|
323
|
+
| `unreachable` | Client SDK only: the server could not be reached. |
|
|
324
|
+
|
|
325
|
+
## 11. Pitfalls
|
|
326
|
+
|
|
327
|
+
- **First run on macOS/Windows** shows a firewall prompt for Node. Allow it, or
|
|
328
|
+
phones cannot connect.
|
|
329
|
+
- **Guest / hotel / office Wi-Fi** often isolates clients (AP isolation):
|
|
330
|
+
phones cannot reach the laptop. Use a phone hotspot or `--public-url` with a tunnel.
|
|
331
|
+
- **Plain HTTP on a LAN** works, but phones get no wake lock and no fullscreen
|
|
332
|
+
on iOS. Host-driven `vibrate` only works on Android (see Haptics in section 5).
|
|
333
|
+
- **Mixed content:** a game page served over HTTPS cannot open `ws://` to a LAN
|
|
334
|
+
server. Serve the game with `--static`, or put both behind HTTPS (tunnel +
|
|
335
|
+
`--public-url`).
|
|
336
|
+
- **Docker** breaks LAN IP detection (you get the container IP). Pass
|
|
337
|
+
`--public-url http://<host-lan-ip>:3000`.
|
|
338
|
+
- Check the printed "Phones join at" URL: if it is not reachable from a phone,
|
|
339
|
+
nothing else will be.
|
|
340
|
+
- A pad in a **background tab** leaves its slot (that is the lock-screen
|
|
341
|
+
behavior). When testing with pads in the browser, keep them in the visible
|
|
342
|
+
game tab or a separate window, or use a Node script (next section).
|
|
343
|
+
|
|
344
|
+
## 12. Testing without phones
|
|
345
|
+
|
|
346
|
+
Drive pads from code with the client SDK; it is how you verify an
|
|
347
|
+
integration end to end. In the game tab's DevTools console (the room code is
|
|
348
|
+
`room.code`; use the full `http://localhost:3000/joinstick/client.js` URL if
|
|
349
|
+
the game is served from another origin):
|
|
350
|
+
|
|
351
|
+
```js
|
|
352
|
+
const { join } = await import('/joinstick/client.js');
|
|
353
|
+
const pad = await join({ room: 'KQZX', slot: 1 });
|
|
354
|
+
pad.setInput({ x: 1 }); // hold right
|
|
355
|
+
pad.setInput({ x: 1, buttons: { a: true } }); // press A...
|
|
356
|
+
pad.setInput({ x: 1, buttons: { a: false } }); // ...and release: pressed.a once
|
|
357
|
+
pad.leave(); // the game should pause
|
|
358
|
+
```
|
|
359
|
+
|
|
360
|
+
Or from a Node 22+ script next to the checkout (`node pads.mjs KQZX`):
|
|
361
|
+
|
|
362
|
+
```js
|
|
363
|
+
import { join } from './joinstick/public/client.js';
|
|
364
|
+
const pad = await join({ server: 'http://localhost:3000', room: process.argv[2], slot: 2 });
|
|
365
|
+
setInterval(() => pad.setInput({ x: Math.sign(Math.random() - 0.5) }), 500);
|
|
366
|
+
```
|
|
367
|
+
|
|
368
|
+
## 13. Don't
|
|
369
|
+
|
|
370
|
+
- Don't use `innerHTML` with player names, messages or anything from the
|
|
371
|
+
network. Use `textContent`.
|
|
372
|
+
- Don't trigger one-shot actions from held state; use `pressed`.
|
|
373
|
+
- Don't call `input(n)` more than once per frame per slot (it consumes `pressed`).
|
|
374
|
+
- Don't assume a slot is connected; check `room.connected(n)` or listen to `leave`.
|
package/CHANGELOG.md
ADDED
|
@@ -0,0 +1,64 @@
|
|
|
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
|
+
## [Unreleased]
|
|
9
|
+
|
|
10
|
+
## [0.2.0] - 2026-10-16
|
|
11
|
+
|
|
12
|
+
### Added
|
|
13
|
+
|
|
14
|
+
- One QR for everyone: `room.qr()` and `room.joinUrl()` without a slot give a
|
|
15
|
+
link that puts each phone in the first free slot (`/j/ROOM`);
|
|
16
|
+
`/joinstick/qr.svg` accepts a missing `slot`.
|
|
17
|
+
- `examples/paint-party`: a party game for 1-8 phones.
|
|
18
|
+
- `--client-ip-header <name>` (`CLIENT_IP_HEADER`, `attach()` option
|
|
19
|
+
`clientIpHeader`): behind a reverse proxy, the per-address room cap and
|
|
20
|
+
failed-join limit use the header the proxy sets instead of the proxy's
|
|
21
|
+
address. Off by default, since clients can send the header themselves.
|
|
22
|
+
|
|
23
|
+
### Changed
|
|
24
|
+
|
|
25
|
+
- The player number on the pad's join form is optional; blank takes the first
|
|
26
|
+
free slot.
|
|
27
|
+
- The pad's top bar is tinted and underlined in the pad color, so players can
|
|
28
|
+
match their phone to the screen at a glance.
|
|
29
|
+
|
|
30
|
+
### Fixed
|
|
31
|
+
|
|
32
|
+
- `--static` redirects a directory requested without its trailing slash
|
|
33
|
+
(`/demo/game` -> `/demo/game/`, query kept) instead of answering 404.
|
|
34
|
+
|
|
35
|
+
## [0.1.0] - 2026-10-04
|
|
36
|
+
|
|
37
|
+
First public release.
|
|
38
|
+
|
|
39
|
+
### Added
|
|
40
|
+
|
|
41
|
+
- `joinstick` CLI: one Node server for rooms, the phone pad page at `/j`, the
|
|
42
|
+
browser SDKs at `/joinstick/*`, QR codes and `/llms.txt`; `--static` serves
|
|
43
|
+
your game on the same port, `--public-url` sets the address in QR codes.
|
|
44
|
+
- `attach(server, opts)` to embed Joinstick in an existing Node HTTP server.
|
|
45
|
+
- Game SDK (`host.js`): rooms with up to 8 slots, per-frame `input(n)` with
|
|
46
|
+
held `buttons` and one-shot `pressed`, join/leave events, QR and join
|
|
47
|
+
URLs, live pad changes (title, color, highlight, disabled, vibrate),
|
|
48
|
+
messages both ways.
|
|
49
|
+
- Phone SDK (`client.js`) for custom controllers and scripted test pads.
|
|
50
|
+
- Pad page: d-pad and up to 8 buttons; `grid`, `diamond` and `arc`
|
|
51
|
+
arrangements; `large` and `wide` buttons; `system` buttons (START/MENU) in
|
|
52
|
+
the top bar; landscape by default with CSS rotation when the phone is held
|
|
53
|
+
upright; tap haptics (Android, best effort on iOS 18+); multi-touch.
|
|
54
|
+
- Reconnection: phones that lock or drop Wi-Fi rejoin their slot; game page
|
|
55
|
+
reloads and server restarts keep the room code (AGENTS.md has the details).
|
|
56
|
+
- Room safety: 20 failed joins per minute per client address, then
|
|
57
|
+
`rate-limited`; per-address room cap; 4-letter room codes.
|
|
58
|
+
- `--version` flag.
|
|
59
|
+
- JSON/WebSocket protocol v1 (PROTOCOL.md), AGENTS.md for coding agents,
|
|
60
|
+
TypeScript declarations, Dockerfile.
|
|
61
|
+
|
|
62
|
+
[Unreleased]: https://github.com/raprav/joinstick/compare/v0.2.0...HEAD
|
|
63
|
+
[0.2.0]: https://github.com/raprav/joinstick/compare/v0.1.0...v0.2.0
|
|
64
|
+
[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.
|