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/PROTOCOL.md ADDED
@@ -0,0 +1,238 @@
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}`, or at `${joinBase}/j/${room}` to
104
+ get the first free slot. The QR for a slot is
105
+ `/joinstick/qr.svg?room=KQZX&slot=1` on the Joinstick server; without `slot`
106
+ it encodes the slot-less link.
107
+
108
+ ### `join`
109
+
110
+ ```json
111
+ {"t":"join","slot":1,"name":"Ana","rejoin":false}
112
+ ```
113
+
114
+ `rejoin` is true when the token is the same as the slot's previous occupant
115
+ (same phone coming back, or taking over from another tab).
116
+
117
+ ### `leave`
118
+
119
+ ```json
120
+ {"t":"leave","slot":1,"reason":"left"}
121
+ ```
122
+
123
+ `reason`: `left` (pad closed with code 1000), `disconnected` (any other
124
+ close, including a locked phone), `timeout` (no pong for 5 s), `kicked`.
125
+
126
+ ### `input`
127
+
128
+ ```json
129
+ {"t":"input","slot":1,"x":-1,"y":0,"b":{"a":true,"b":false}}
130
+ ```
131
+
132
+ Full state, forwarded as sent by the pad (x, y clamped to [-1, 1]). The first
133
+ `input` after a `join` (or after `created` on resume) is the slot's baseline:
134
+ buttons already down in it are held, not new presses. The client SDK always
135
+ sends one right after `joined`.
136
+
137
+ ### `message`
138
+
139
+ ```json
140
+ {"t":"message","slot":1,"data":{"any":"json"}}
141
+ ```
142
+
143
+ ## Pad → server
144
+
145
+ ### `join`
146
+
147
+ ```json
148
+ {"t":"join","v":1,"room":"KQZX","slot":1,"token":"32 hex chars","name":"Ana"}
149
+ ```
150
+
151
+ | Field | |
152
+ |---|---|
153
+ | `v` | Must be `1`, else fatal `version`. |
154
+ | `room` | Case-insensitive. Unknown → `room-not-found`. |
155
+ | `slot` | Optional. Omitted/null → the slot this token held before, else the first free one. Not 1..slots → `bad-slot`. |
156
+ | `token` | `/^[A-Za-z0-9_-]{16,64}$/`, generated and stored by the client. Identifies the phone. |
157
+ | `name` | Optional, control chars stripped, trimmed to 20 chars. |
158
+
159
+ The first join and a rejoin are the same message. Outcomes:
160
+
161
+ - Slot empty → taken.
162
+ - Slot occupied by the **same token** → the new connection wins; the old one
163
+ gets fatal `replaced`.
164
+ - Slot occupied by **another token** → `{t:'error', code:'slot-taken', free:[2,3]}`.
165
+
166
+ After 5 failed joins on one socket the server closes it. After 20 failed
167
+ joins from one client address within a minute, every further `join` from
168
+ that address gets fatal `rate-limited` until the minute is over. Failures in
169
+ the first minute after the server starts are not counted: phones are still
170
+ waiting for their game to recreate its room.
171
+
172
+ ### `input`
173
+
174
+ ```json
175
+ {"t":"input","x":0,"y":-1,"b":{"a":true}}
176
+ ```
177
+
178
+ Full state on every change. `x`, `y` are finite numbers (clamped to [-1, 1];
179
+ `y` = -1 is up). `b` maps ≤ 16 button ids to booleans (`Object.prototype`
180
+ names are refused). While the host is away it is not forwarded, but the
181
+ server keeps the latest one per slot for the host's resume. Send your current
182
+ state right after every `joined`.
183
+
184
+ ### `msg`
185
+
186
+ ```json
187
+ {"t":"msg","data":{"any":"json"}}
188
+ ```
189
+
190
+ Delivered to the host as `message` with the pad's slot.
191
+
192
+ ## Server → pad
193
+
194
+ ### `joined`
195
+
196
+ ```json
197
+ {"t":"joined","room":"KQZX","slot":1,"layout":{…},"pad":{"title":"Ryu","color":"#e5484d"}}
198
+ ```
199
+
200
+ `pad` is the slot's current pad state. Sent again (same shape) when the host
201
+ resumes with a different layout or with `resetPads`.
202
+
203
+ ### `pad`
204
+
205
+ ```json
206
+ {"t":"pad","patch":{"highlight":["a"],"vibrate":200}}
207
+ ```
208
+
209
+ ### `message`
210
+
211
+ ```json
212
+ {"t":"message","data":{"any":"json"}}
213
+ ```
214
+
215
+ ## Errors
216
+
217
+ | Code | Fatal | When |
218
+ |---|---|---|
219
+ | `room-not-found` | no | `join` with an unknown room. |
220
+ | `slot-taken` | no | Slot held by another token. Includes `free`. |
221
+ | `bad-slot` | no | Slot is not an integer in 1..slots. |
222
+ | `bad-message` | no | Invalid JSON, shape or values. Includes `message`. |
223
+ | `replaced` | yes | Same host token or pad token connected again. |
224
+ | `kicked` | yes | Host kicked this pad. |
225
+ | `room-closed` | yes | Host gone for 60 s; room deleted. |
226
+ | `version` | yes | `v` is not 1. |
227
+ | `server-full` | yes | Room limit reached (1000 total, 20 per client address). |
228
+ | `rate-limited` | yes | 20 failed joins from this client address in the last minute. |
229
+
230
+ "Fatal" means the server closes the socket right after the error. Failed
231
+ joins (the non-fatal join errors) count toward the 5-failure limit.
232
+
233
+ ## Host disconnects
234
+
235
+ When the host socket closes, the room stays for 60 s. Pads stay connected
236
+ (their input is dropped). A host that sends `create` with the same `room` and
237
+ `hostToken` within that time resumes it; otherwise every pad gets
238
+ `room-closed` and the room is deleted.
package/README.md CHANGED
@@ -1,3 +1,283 @@
1
- # Temporary Holding Version
1
+ # Joinstick
2
2
 
3
- This version is a temporary placeholder for this package. An operational version to replace this has been submitted for review and is awaiting a staged release.
3
+ **Use phones as gamepads for browser games.** The game shows a QR code, each
4
+ player scans it, and their phone becomes a controller. No accounts, no app
5
+ install: one small self-hosted Node server and a few lines in your game. Made
6
+ for local multiplayer and party games on a laptop or TV.
7
+
8
+ [![npm](https://img.shields.io/npm/v/joinstick)](https://www.npmjs.com/package/joinstick)
9
+ [![license: MIT](https://img.shields.io/badge/license-MIT-blue)](LICENSE)
10
+ [![node >= 22](https://img.shields.io/badge/node-%3E%3D22-339933)](package.json)
11
+ [![CI](https://github.com/raprav/joinstick/actions/workflows/ci.yml/badge.svg)](https://github.com/raprav/joinstick/actions/workflows/ci.yml)
12
+
13
+ <p align="center"><a href="https://joinstick.fly.dev/demo/paint-party"><img src="https://raw.githubusercontent.com/raprav/joinstick/main/docs/hero.png" alt="Left: Paint Party on a TV mid-round, four paint blobs covering the floor and one QR code in the corner that every player scans. Right: a phone held sideways showing player 1's orange pad with a d-pad and BOMB and DASH buttons." width="800"></a></p>
14
+
15
+ **[Live demo: Paint Party](https://joinstick.fly.dev/demo/paint-party)**. Open it on a big screen and scan the QR
16
+ with phones: one QR for everyone, drop in or out anytime, 1-8 players, haptics
17
+ on Android. Its source is a single file in
18
+ [`examples/paint-party`](examples/paint-party) that uses only the public SDK.
19
+
20
+ ## Quick start
21
+
22
+ Save this as `my-game/index.html`:
23
+
24
+ ```html
25
+ <!doctype html>
26
+ <meta charset="utf-8">
27
+ <img id="qr" width="240">
28
+ <script type="module">
29
+ import { host } from '/joinstick/host.js';
30
+ const room = await host({ slots: 2 }); // default pad: d-pad + A, B
31
+ document.getElementById('qr').src = room.qr(); // every player scans this
32
+ (function frame() {
33
+ const p1 = room.input(1); // { x, y, buttons, pressed }, never undefined
34
+ if (p1.pressed.a) console.log('jump!'); // x, y in [-1, 1]; pressed = went down since last read
35
+ requestAnimationFrame(frame);
36
+ })();
37
+ </script>
38
+ ```
39
+
40
+ Then run (Node 22 or newer):
41
+
42
+ ```sh
43
+ npx joinstick --static ./my-game
44
+ ```
45
+
46
+ Open `http://localhost:3000` on the computer, scan the QR with a phone on the
47
+ same Wi-Fi, and press A. Embedding in your own Node server or using
48
+ TypeScript: `npm i joinstick`. A complete example game is in
49
+ [`examples/basic`](examples/basic).
50
+ [`examples/paint-party`](examples/paint-party) is a party game for 1-8 phones
51
+ that join and leave at any time through one QR code
52
+ ([play it live](https://joinstick.fly.dev/demo/paint-party): open it on a big
53
+ screen, then scan the QR with phones).
54
+
55
+ ### Using a bundler or your own dev server
56
+
57
+ Run Joinstick on its own port next to your dev server (Vite, webpack...) and
58
+ tell the SDK where it is:
59
+
60
+ ```sh
61
+ npm i joinstick
62
+ npx joinstick # Joinstick alone on :3000
63
+ ```
64
+
65
+ ```js
66
+ import { host } from 'joinstick/host'; // typed
67
+
68
+ const room = await host({ slots: 2, server: `http://${location.hostname}:3000` });
69
+ ```
70
+
71
+ QR codes then point at that server. If your dev server uses HTTPS, Joinstick
72
+ needs HTTPS too (see Self-hosting).
73
+
74
+ ## How it works
75
+
76
+ ```
77
+ laptop / TV Joinstick server phones
78
+ ┌─────────────┐ WebSocket ┌──────────────────┐ WebSocket ┌──────────┐
79
+ │ your game │ ◄────────────► │ rooms, relay, │ ◄──────────► │ pad page │
80
+ │ + host.js │ input, joins │ QR codes, pad │ pad state │ /j/KQZX/1│
81
+ └─────────────┘ │ page, SDKs │ └──────────┘
82
+ └──────────────────┘
83
+ ```
84
+
85
+ The game creates a room (`KQZX`) with up to 8 player slots. One QR code opens
86
+ `/j/KQZX` on any phone, which takes the first free slot (`/j/KQZX/<slot>` asks
87
+ for a specific one). The phone sends its controller
88
+ state on every change; your game reads `room.input(n)` once per frame. All
89
+ traffic goes through your server; nothing leaves your network unless you put
90
+ it on the internet.
91
+
92
+ ## Features
93
+
94
+ - **Zero install for players**: a QR code (or `http://<laptop-ip>:3000/j` and
95
+ a 4-letter code) opens the controller in the phone's browser.
96
+ - **Small and self-hosted**: one Node process, two runtime dependencies
97
+ (`ws`, `uqr`), plain ES modules in the browser, no build step.
98
+ - **Reconnects automatically**: phones that lock or drop Wi-Fi leave and rejoin
99
+ their own slot; game page reloads and server restarts keep the room code.
100
+ Your game gets `join`/`leave` events to pause and resume.
101
+ - **Configurable pad**: d-pad plus up to 8 buttons with labels, colors and
102
+ sizes; the game can retitle, recolor, highlight or disable buttons live.
103
+ - **Landscape by default**: held upright, the pad shows rotated, so it works
104
+ with the phone's rotation lock on (on Android Chrome the first tap also
105
+ tries fullscreen and an orientation lock).
106
+ - **Haptics**: a short tick on every press (Android; best effort on iOS 18+,
107
+ see the FAQ), plus game-driven vibration on Android.
108
+ - **Bring your own controller**: the pad is just one client of a documented
109
+ JSON/WebSocket protocol; `client.js` lets you build your own or drive pads
110
+ from tests.
111
+
112
+ ## Pad layouts
113
+
114
+ ```js
115
+ await host({
116
+ slots: 2,
117
+ layout: {
118
+ arrangement: 'arc', // 'grid' (default) | 'diamond' | 'arc'
119
+ buttons: [
120
+ { id: 'punch', label: 'PUNCH' }, // in 'arc' the first button is the big one
121
+ { id: 'kick', label: 'KICK' },
122
+ { id: 'ult', label: 'ULT', size: 'large' },
123
+ { id: 'block', label: 'BLOCK', size: 'wide' }, // a bar to rest a thumb on
124
+ { id: 'start', label: 'START', system: true }, // a pill in the top bar
125
+ ],
126
+ },
127
+ });
128
+ ```
129
+
130
+ <p align="center"><img src="https://raw.githubusercontent.com/raprav/joinstick/main/docs/layouts.png" alt="Four pad screenshots: grid, diamond, arc, and arc with a wide BLOCK bar. Each has a d-pad on the left and a START pill in the top bar." width="800"></p>
131
+
132
+ Also: `stick: 'none'` (buttons only), `orientation: 'landscape' | 'portrait' |
133
+ 'any'`, `haptics: false`, per-button `color` and `haptic` (ms). Taps between
134
+ buttons go to the nearest one, and several fingers work at once (move, hold
135
+ a button, tap another). Full schema in [AGENTS.md](AGENTS.md#5-layout).
136
+
137
+ ## API in one screen
138
+
139
+ | Game side (`/joinstick/host.js`) | |
140
+ |---|---|
141
+ | `host({ slots, layout, server })` | Create (or after a reload, resume) a room. `server` is the Joinstick URL when the game is served elsewhere. |
142
+ | `room.input(n)` | `{ x, y, buttons, pressed }` for player `n`. Read it every frame. |
143
+ | `room.qr(n?)`, `room.joinUrl(n?)` | QR image URL and plain join link for player `n`. Without `n`: one link for everyone, each phone gets the first free slot. |
144
+ | `room.connected(n)`, `room.on('join' \| 'leave', fn)` | Who is here; pause when someone drops. |
145
+ | `room.pad(n, { title, color, highlight, disabled, vibrate })` | Change what a phone shows. |
146
+ | `room.send(n, data)`, `room.broadcast(data)`, `room.on('message', fn)` | Custom messages both ways. |
147
+ | `room.kick(n)`, `room.close()` | Remove a player; end the room. |
148
+ | `room.on('status' \| 'error', fn)` | Connection to the server (`connected`, `reconnecting`...) and fatal errors. |
149
+
150
+ | Server side (`import { attach } from 'joinstick'`) | |
151
+ |---|---|
152
+ | `attach(httpServer, { static, publicUrl })` | Add Joinstick to your own Node HTTP server. |
153
+
154
+ Types: [`host.d.ts`](public/host.d.ts), [`client.d.ts`](public/client.d.ts),
155
+ [`server.d.ts`](src/server.d.ts). Wire protocol: [PROTOCOL.md](PROTOCOL.md).
156
+ Everything else (input contract, pausing, error codes, testing without
157
+ phones): [AGENTS.md](AGENTS.md).
158
+
159
+ ## CLI
160
+
161
+ ```
162
+ joinstick [--port 3000] [--static ./dir] [--public-url URL] [--client-ip-header NAME] [--version]
163
+ ```
164
+
165
+ | Flag | Env | |
166
+ |---|---|---|
167
+ | `--port` | `PORT` | Port to listen on. Default 3000. |
168
+ | `--static <dir>` | | Also serve your game from this directory. |
169
+ | `--public-url <url>` | `PUBLIC_URL` | Origin that QR codes and join links use (no path). Default: the host the game page connected with, unless it is `localhost`/loopback; then the detected LAN address. |
170
+ | `--client-ip-header <name>` | `CLIENT_IP_HEADER` | Header a reverse proxy sets to the visitor's address (`fly-client-ip`, `x-real-ip`). The per-address limits use it instead of the proxy's address. Only set it behind a proxy that overwrites the header. |
171
+ | `-v`, `--version` | | Print the version. |
172
+
173
+ ## Self-hosting
174
+
175
+ - **LAN (the usual case)**: run it on the laptop that shows the game. Phones
176
+ on the same Wi-Fi open the printed "Phones join at" URL. Plain HTTP is fine.
177
+ - **Behind a reverse proxy with TLS**: proxy everything (or at least `/j`,
178
+ `/joinstick/`) to Joinstick, let the proxy pass WebSocket upgrades on
179
+ `/joinstick/ws`, and start Joinstick with `--public-url https://your.domain`
180
+ so QR codes point at the public address. Behind a proxy every game shares
181
+ the proxy's IP address, so the 20-rooms-per-IP cap applies to all of them
182
+ together unless you pass `--client-ip-header` with a header your proxy
183
+ overwrites with the visitor's address (Fly.io: `fly-client-ip`).
184
+ With Caddy (which passes WebSockets through by default):
185
+
186
+ ```
187
+ your.domain {
188
+ reverse_proxy localhost:3000
189
+ }
190
+ ```
191
+
192
+ - **Docker**: the repo has a small [Dockerfile](Dockerfile) (non-root,
193
+ Node 22 Alpine; it is not in the npm package). Inside a container the LAN
194
+ address cannot be detected, so pass `--public-url`. From a clone of the repo:
195
+
196
+ ```sh
197
+ docker build -t joinstick .
198
+ docker run --rm -p 3000:3000 -v "$PWD/my-game:/site:ro" joinstick \
199
+ --static /site --public-url http://<your-lan-ip>:3000
200
+ ```
201
+
202
+ ## Remote play
203
+
204
+ What works today: put the server on a public URL (a tunnel such as
205
+ `cloudflared tunnel --url http://localhost:3000`, or a reverse proxy) and pass
206
+ that URL as `--public-url`. Remote phones then join like local ones.
207
+
208
+ What it does not do: Joinstick only carries controller input. Remote players
209
+ still need to see the game (screen sharing, a video call). Input is relayed
210
+ through your server, not peer to peer, so each remote player adds their
211
+ network delay to the server.
212
+
213
+ Security on a public URL, plainly:
214
+
215
+ - The room code is the only credential. Codes are 4 letters from 20
216
+ consonants, so 160,000 possible codes. Anyone who guesses a live code can
217
+ take a **free** slot (a taken slot also needs that phone's secret token).
218
+ - Guessing is slowed down: after 20 failed joins in a minute, an address is
219
+ refused for the rest of that minute (not counted in the first minute after
220
+ the server starts, while phones rejoin). Behind a tunnel or proxy every phone
221
+ shares the proxy's address, so a guesser also delays real players by up to
222
+ a minute.
223
+ - The server does not check the `Origin` header: any web page that knows the
224
+ server's address can open a connection to it, like any other client.
225
+
226
+ Fine for playing with friends; not for anything secret.
227
+
228
+ ## For AI coding agents
229
+
230
+ Point your agent at [AGENTS.md](AGENTS.md): integration checklist, full API,
231
+ input contract, pitfalls, and how to test with scripted pads instead of
232
+ phones. A running server also serves a condensed version at `/llms.txt`.
233
+
234
+ ## FAQ
235
+
236
+ **The phone can't connect.** It must reach the laptop: same Wi-Fi, and allow
237
+ Node through the firewall prompt on first run (macOS/Windows). Guest, hotel
238
+ and office networks often isolate devices; use a phone hotspot or a tunnel.
239
+ If the printed "Phones join at" URL doesn't open on the phone, nothing else will.
240
+
241
+ **Do players need the same Wi-Fi?** For local play, yes (or any network where
242
+ the phone can reach the server). For remote play, see above.
243
+
244
+ **Does vibration work on iPhone?** Partly. Android gets tap feedback and
245
+ game-driven vibration. iOS Safari has no vibration API; on iOS 18+ the pad
246
+ plays the system's single haptic tick on presses as a best effort (length is
247
+ ignored, game-driven vibration does nothing). Older iOS and desktops: none.
248
+
249
+ **How much latency?** Input is sent the moment it changes, with no polling or
250
+ batching. On a LAN the delay is about one Wi-Fi hop from the phone to the
251
+ laptop. The server's own relay is small: measured on loopback (pad socket to
252
+ server to game socket, same machine, 500 inputs) the median was 0.06 ms. We
253
+ have not published phone-to-screen numbers.
254
+
255
+ **Which browsers?**
256
+
257
+ | | Chrome / Android | Safari / iOS | Firefox | Samsung Internet |
258
+ |---|---|---|---|---|
259
+ | Phone pad | 87 | 14.5 | 79 | 14 |
260
+ | Game page (`host.js`) | 93 | 15.4 | 92 | 17 |
261
+
262
+ Derived, not tested on each version: these are the first versions that
263
+ support the newest feature each side needs, looked up in MDN's
264
+ [browser-compat-data](https://github.com/mdn/browser-compat-data) 8.1.4. Pad:
265
+ CSS `inset` (Chrome 87, iOS 14.5), `??=` (Firefox 79), plus Pointer Events,
266
+ ES modules and WebSocket. Game page: `Object.hasOwn` in `host.js`; the
267
+ Quick start snippet also uses top-level `await` (Chrome 89, Safari 15,
268
+ Firefox 89). Newer browsers additionally get `color-mix()` colors (Chrome 111,
269
+ Safari 16.2, Firefox 113); older ones show plainer colors. The iOS haptic tick
270
+ needs iOS 18.
271
+
272
+ **Limits?** 8 players per room, 8 buttons per pad, 1000 rooms per server
273
+ (20 per IP address), 8 KB per message. A room closes 60 s after the game page
274
+ goes away.
275
+
276
+ **HTTPS game page?** A page served over HTTPS cannot open a plain `ws://`
277
+ connection. Serve the game with `--static`, or put both behind HTTPS.
278
+
279
+ ## License
280
+
281
+ [MIT](LICENSE) © 2026 Rafa Prats. Third-party notices:
282
+ [THIRD_PARTY_NOTICES.md](THIRD_PARTY_NOTICES.md).
283
+ QR Code is a registered trademark of DENSO WAVE INCORPORATED.
@@ -0,0 +1,66 @@
1
+ # Third-party notices
2
+
3
+ Joinstick's own code is MIT licensed, © 2026 Rafa Prats (see [LICENSE](LICENSE)).
4
+ Every file in `src/` and `public/` is original to this project.
5
+
6
+ Joinstick does not bundle third-party code. It depends on the npm packages
7
+ below, which npm installs next to it (the Docker image includes them). Both
8
+ are MIT licensed; their notices follow.
9
+
10
+ QR Code is a registered trademark of DENSO WAVE INCORPORATED.
11
+
12
+ ## ws
13
+
14
+ https://github.com/websockets/ws (MIT)
15
+
16
+ ```
17
+ Copyright (c) 2011 Einar Otto Stangvik <einaros@gmail.com>
18
+ Copyright (c) 2013 Arnout Kazemier and contributors
19
+ Copyright (c) 2016 Luigi Pinca and contributors
20
+
21
+ Permission is hereby granted, free of charge, to any person obtaining a copy of
22
+ this software and associated documentation files (the "Software"), to deal in
23
+ the Software without restriction, including without limitation the rights to
24
+ use, copy, modify, merge, publish, distribute, sublicense, and/or sell copies of
25
+ the Software, and to permit persons to whom the Software is furnished to do so,
26
+ subject to the following conditions:
27
+
28
+ The above copyright notice and this permission notice shall be included in all
29
+ copies or substantial portions of the Software.
30
+
31
+ THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
32
+ IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, FITNESS
33
+ FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE AUTHORS OR
34
+ COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY, WHETHER
35
+ IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, OUT OF OR IN
36
+ CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE SOFTWARE.
37
+ ```
38
+
39
+ ## uqr
40
+
41
+ https://github.com/unjs/uqr (MIT; based on the QR Code generator library by Project Nayuki)
42
+
43
+ ```
44
+ MIT License
45
+
46
+ Copyright (c) Project Nayuki
47
+ Copyright (c) 2023 Anthony Fu <https://github.com/antfu>
48
+
49
+ Permission is hereby granted, free of charge, to any person obtaining a copy
50
+ of this software and associated documentation files (the "Software"), to deal
51
+ in the Software without restriction, including without limitation the rights
52
+ to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
53
+ copies of the Software, and to permit persons to whom the Software is
54
+ furnished to do so, subject to the following conditions:
55
+
56
+ The above copyright notice and this permission notice shall be included in all
57
+ copies or substantial portions of the Software.
58
+
59
+ THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
60
+ IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
61
+ FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
62
+ AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
63
+ LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
64
+ OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
65
+ SOFTWARE.
66
+ ```