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/README.md CHANGED
@@ -1,3 +1,270 @@
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"><img src="https://raw.githubusercontent.com/raprav/joinstick/main/docs/hero.png" alt="Left: the example game on a laptop, showing a room code, two moving squares and a QR code per player. Right: a phone held sideways showing the Joinstick pad with a d-pad and two buttons." width="800"></p>
14
+
15
+ ## Quick start
16
+
17
+ Save this as `my-game/index.html`:
18
+
19
+ ```html
20
+ <!doctype html>
21
+ <meta charset="utf-8">
22
+ <img id="qr" width="240">
23
+ <script type="module">
24
+ import { host } from '/joinstick/host.js';
25
+ const room = await host({ slots: 2 }); // default pad: d-pad + A, B
26
+ document.getElementById('qr').src = room.qr(1); // player 1 scans this
27
+ (function frame() {
28
+ const p1 = room.input(1); // { x, y, buttons, pressed }, never undefined
29
+ if (p1.pressed.a) console.log('jump!'); // x, y in [-1, 1]; pressed = went down since last read
30
+ requestAnimationFrame(frame);
31
+ })();
32
+ </script>
33
+ ```
34
+
35
+ Then run (Node 22 or newer):
36
+
37
+ ```sh
38
+ npx joinstick --static ./my-game
39
+ ```
40
+
41
+ Open `http://localhost:3000` on the computer, scan the QR with a phone on the
42
+ same Wi-Fi, and press A. Embedding in your own Node server or using
43
+ TypeScript: `npm i joinstick`. A complete example game is in
44
+ [`examples/basic`](examples/basic).
45
+
46
+ ### Using a bundler or your own dev server
47
+
48
+ Run Joinstick on its own port next to your dev server (Vite, webpack...) and
49
+ tell the SDK where it is:
50
+
51
+ ```sh
52
+ npm i joinstick
53
+ npx joinstick # Joinstick alone on :3000
54
+ ```
55
+
56
+ ```js
57
+ import { host } from 'joinstick/host'; // typed
58
+
59
+ const room = await host({ slots: 2, server: `http://${location.hostname}:3000` });
60
+ ```
61
+
62
+ QR codes then point at that server. If your dev server uses HTTPS, Joinstick
63
+ needs HTTPS too (see Self-hosting).
64
+
65
+ ## How it works
66
+
67
+ ```
68
+ laptop / TV Joinstick server phones
69
+ ┌─────────────┐ WebSocket ┌──────────────────┐ WebSocket ┌──────────┐
70
+ │ your game │ ◄────────────► │ rooms, relay, │ ◄──────────► │ pad page │
71
+ │ + host.js │ input, joins │ QR codes, pad │ pad state │ /j/KQZX/1│
72
+ └─────────────┘ │ page, SDKs │ └──────────┘
73
+ └──────────────────┘
74
+ ```
75
+
76
+ The game creates a room (`KQZX`) with up to 8 player slots. Each slot has a QR
77
+ code that opens `/j/KQZX/<slot>` on a phone. The phone sends its controller
78
+ state on every change; your game reads `room.input(n)` once per frame. All
79
+ traffic goes through your server; nothing leaves your network unless you put
80
+ it on the internet.
81
+
82
+ ## Features
83
+
84
+ - **Zero install for players**: a QR code (or `http://<laptop-ip>:3000/j` and
85
+ a 4-letter code) opens the controller in the phone's browser.
86
+ - **Small and self-hosted**: one Node process, two runtime dependencies
87
+ (`ws`, `uqr`), plain ES modules in the browser, no build step.
88
+ - **Reconnects automatically**: phones that lock or drop Wi-Fi leave and rejoin
89
+ their own slot; game page reloads and server restarts keep the room code.
90
+ Your game gets `join`/`leave` events to pause and resume.
91
+ - **Configurable pad**: d-pad plus up to 8 buttons with labels, colors and
92
+ sizes; the game can retitle, recolor, highlight or disable buttons live.
93
+ - **Landscape by default**: held upright, the pad shows rotated, so it works
94
+ with the phone's rotation lock on (on Android Chrome the first tap also
95
+ tries fullscreen and an orientation lock).
96
+ - **Haptics**: a short tick on every press (Android; best effort on iOS 18+,
97
+ see the FAQ), plus game-driven vibration on Android.
98
+ - **Bring your own controller**: the pad is just one client of a documented
99
+ JSON/WebSocket protocol; `client.js` lets you build your own or drive pads
100
+ from tests.
101
+
102
+ ## Pad layouts
103
+
104
+ ```js
105
+ await host({
106
+ slots: 2,
107
+ layout: {
108
+ arrangement: 'arc', // 'grid' (default) | 'diamond' | 'arc'
109
+ buttons: [
110
+ { id: 'punch', label: 'PUNCH' }, // in 'arc' the first button is the big one
111
+ { id: 'kick', label: 'KICK' },
112
+ { id: 'ult', label: 'ULT', size: 'large' },
113
+ { id: 'block', label: 'BLOCK', size: 'wide' }, // a bar to rest a thumb on
114
+ { id: 'start', label: 'START', system: true }, // a pill in the top bar
115
+ ],
116
+ },
117
+ });
118
+ ```
119
+
120
+ <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>
121
+
122
+ Also: `stick: 'none'` (buttons only), `orientation: 'landscape' | 'portrait' |
123
+ 'any'`, `haptics: false`, per-button `color` and `haptic` (ms). Taps between
124
+ buttons go to the nearest one, and several fingers work at once (move, hold
125
+ a button, tap another). Full schema in [AGENTS.md](AGENTS.md#5-layout).
126
+
127
+ ## API in one screen
128
+
129
+ | Game side (`/joinstick/host.js`) | |
130
+ |---|---|
131
+ | `host({ slots, layout, server })` | Create (or after a reload, resume) a room. `server` is the Joinstick URL when the game is served elsewhere. |
132
+ | `room.input(n)` | `{ x, y, buttons, pressed }` for player `n`. Read it every frame. |
133
+ | `room.qr(n)`, `room.joinUrl(n)` | QR image URL and plain join link for player `n`. |
134
+ | `room.connected(n)`, `room.on('join' \| 'leave', fn)` | Who is here; pause when someone drops. |
135
+ | `room.pad(n, { title, color, highlight, disabled, vibrate })` | Change what a phone shows. |
136
+ | `room.send(n, data)`, `room.broadcast(data)`, `room.on('message', fn)` | Custom messages both ways. |
137
+ | `room.kick(n)`, `room.close()` | Remove a player; end the room. |
138
+ | `room.on('status' \| 'error', fn)` | Connection to the server (`connected`, `reconnecting`...) and fatal errors. |
139
+
140
+ | Server side (`import { attach } from 'joinstick'`) | |
141
+ |---|---|
142
+ | `attach(httpServer, { static, publicUrl })` | Add Joinstick to your own Node HTTP server. |
143
+
144
+ Types: [`host.d.ts`](public/host.d.ts), [`client.d.ts`](public/client.d.ts),
145
+ [`server.d.ts`](src/server.d.ts). Wire protocol: [PROTOCOL.md](PROTOCOL.md).
146
+ Everything else (input contract, pausing, error codes, testing without
147
+ phones): [AGENTS.md](AGENTS.md).
148
+
149
+ ## CLI
150
+
151
+ ```
152
+ joinstick [--port 3000] [--static ./dir] [--public-url URL] [--version]
153
+ ```
154
+
155
+ | Flag | Env | |
156
+ |---|---|---|
157
+ | `--port` | `PORT` | Port to listen on. Default 3000. |
158
+ | `--static <dir>` | | Also serve your game from this directory. |
159
+ | `--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. |
160
+ | `-v`, `--version` | | Print the version. |
161
+
162
+ ## Self-hosting
163
+
164
+ - **LAN (the usual case)**: run it on the laptop that shows the game. Phones
165
+ on the same Wi-Fi open the printed "Phones join at" URL. Plain HTTP is fine.
166
+ - **Behind a reverse proxy with TLS**: proxy everything (or at least `/j`,
167
+ `/joinstick/`) to Joinstick, let the proxy pass WebSocket upgrades on
168
+ `/joinstick/ws`, and start Joinstick with `--public-url https://your.domain`
169
+ so QR codes point at the public address. Behind a proxy every game shares
170
+ the proxy's IP address, so the 20-rooms-per-IP cap applies to all of them
171
+ together. With Caddy (which passes WebSockets through by default):
172
+
173
+ ```
174
+ your.domain {
175
+ reverse_proxy localhost:3000
176
+ }
177
+ ```
178
+
179
+ - **Docker**: the repo has a small [Dockerfile](Dockerfile) (non-root,
180
+ Node 22 Alpine; it is not in the npm package). Inside a container the LAN
181
+ address cannot be detected, so pass `--public-url`. From a clone of the repo:
182
+
183
+ ```sh
184
+ docker build -t joinstick .
185
+ docker run --rm -p 3000:3000 -v "$PWD/my-game:/site:ro" joinstick \
186
+ --static /site --public-url http://<your-lan-ip>:3000
187
+ ```
188
+
189
+ ## Remote play
190
+
191
+ What works today: put the server on a public URL (a tunnel such as
192
+ `cloudflared tunnel --url http://localhost:3000`, or a reverse proxy) and pass
193
+ that URL as `--public-url`. Remote phones then join like local ones.
194
+
195
+ What it does not do: Joinstick only carries controller input. Remote players
196
+ still need to see the game (screen sharing, a video call). Input is relayed
197
+ through your server, not peer to peer, so each remote player adds their
198
+ network delay to the server.
199
+
200
+ Security on a public URL, plainly:
201
+
202
+ - The room code is the only credential. Codes are 4 letters from 20
203
+ consonants, so 160,000 possible codes. Anyone who guesses a live code can
204
+ take a **free** slot (a taken slot also needs that phone's secret token).
205
+ - Guessing is slowed down: after 20 failed joins in a minute, an address is
206
+ refused for the rest of that minute (not counted in the first minute after
207
+ the server starts, while phones rejoin). Behind a tunnel or proxy every phone
208
+ shares the proxy's address, so a guesser also delays real players by up to
209
+ a minute.
210
+ - The server does not check the `Origin` header: any web page that knows the
211
+ server's address can open a connection to it, like any other client.
212
+
213
+ Fine for playing with friends; not for anything secret.
214
+
215
+ ## For AI coding agents
216
+
217
+ Point your agent at [AGENTS.md](AGENTS.md): integration checklist, full API,
218
+ input contract, pitfalls, and how to test with scripted pads instead of
219
+ phones. A running server also serves a condensed version at `/llms.txt`.
220
+
221
+ ## FAQ
222
+
223
+ **The phone can't connect.** It must reach the laptop: same Wi-Fi, and allow
224
+ Node through the firewall prompt on first run (macOS/Windows). Guest, hotel
225
+ and office networks often isolate devices; use a phone hotspot or a tunnel.
226
+ If the printed "Phones join at" URL doesn't open on the phone, nothing else will.
227
+
228
+ **Do players need the same Wi-Fi?** For local play, yes (or any network where
229
+ the phone can reach the server). For remote play, see above.
230
+
231
+ **Does vibration work on iPhone?** Partly. Android gets tap feedback and
232
+ game-driven vibration. iOS Safari has no vibration API; on iOS 18+ the pad
233
+ plays the system's single haptic tick on presses as a best effort (length is
234
+ ignored, game-driven vibration does nothing). Older iOS and desktops: none.
235
+
236
+ **How much latency?** Input is sent the moment it changes, with no polling or
237
+ batching. On a LAN the delay is about one Wi-Fi hop from the phone to the
238
+ laptop. The server's own relay is small: measured on loopback (pad socket to
239
+ server to game socket, same machine, 500 inputs) the median was 0.06 ms. We
240
+ have not published phone-to-screen numbers.
241
+
242
+ **Which browsers?**
243
+
244
+ | | Chrome / Android | Safari / iOS | Firefox | Samsung Internet |
245
+ |---|---|---|---|---|
246
+ | Phone pad | 87 | 14.5 | 79 | 14 |
247
+ | Game page (`host.js`) | 93 | 15.4 | 92 | 17 |
248
+
249
+ Derived, not tested on each version: these are the first versions that
250
+ support the newest feature each side needs, looked up in MDN's
251
+ [browser-compat-data](https://github.com/mdn/browser-compat-data) 8.1.4. Pad:
252
+ CSS `inset` (Chrome 87, iOS 14.5), `??=` (Firefox 79), plus Pointer Events,
253
+ ES modules and WebSocket. Game page: `Object.hasOwn` in `host.js`; the
254
+ Quick start snippet also uses top-level `await` (Chrome 89, Safari 15,
255
+ Firefox 89). Newer browsers additionally get `color-mix()` colors (Chrome 111,
256
+ Safari 16.2, Firefox 113); older ones show plainer colors. The iOS haptic tick
257
+ needs iOS 18.
258
+
259
+ **Limits?** 8 players per room, 8 buttons per pad, 1000 rooms per server
260
+ (20 per IP address), 8 KB per message. A room closes 60 s after the game page
261
+ goes away.
262
+
263
+ **HTTPS game page?** A page served over HTTPS cannot open a plain `ws://`
264
+ connection. Serve the game with `--static`, or put both behind HTTPS.
265
+
266
+ ## License
267
+
268
+ [MIT](LICENSE) © 2026 Rafa Prats. Third-party notices:
269
+ [THIRD_PARTY_NOTICES.md](THIRD_PARTY_NOTICES.md).
270
+ 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
+ ```
package/llms.txt ADDED
@@ -0,0 +1,102 @@
1
+ # Joinstick
2
+
3
+ > Turn phones into gamepads for browser games. The game page (host) shows one QR code per player slot; scanning it opens a controller page on the phone. Self-hosted Node server (`npx joinstick`), plain ESM browser SDKs, JSON over WebSocket.
4
+
5
+ Full guide: AGENTS.md. Wire format: PROTOCOL.md. Types: /joinstick/host.d.ts, /joinstick/client.d.ts (all in the npm package and at https://github.com/raprav/joinstick).
6
+
7
+ ## Run
8
+
9
+ `npx joinstick --static ./my-game` serves the game and Joinstick on http://localhost:3000 (or `npm i joinstick`, then `npx joinstick`). Node 22+. Phones must be on the same Wi-Fi. Flags: `--port` (PORT, default 3000), `--static <dir>`, `--public-url <url>` (PUBLIC_URL; base URL phones open, an origin without a path, for tunnels/Docker/proxies).
10
+
11
+ ## Integrate
12
+
13
+ ```js
14
+ import { host } from '/joinstick/host.js';
15
+ const room = await host({ slots: 2, layout: { stick: 'dpad', buttons: [{ id: 'a', label: 'A' }, { id: 'b', label: 'B' }] } });
16
+ // show <img src={room.qr(n)}> and the text room.joinUrl(n) for n = 1..room.slots
17
+ function tick() {
18
+ for (let n = 1; n <= room.slots; n++) {
19
+ const { x, y, buttons, pressed } = room.input(n); // once per frame per slot
20
+ }
21
+ requestAnimationFrame(tick);
22
+ }
23
+ tick();
24
+ ```
25
+
26
+ If host.js is bundled or the game is served from another origin: `import { host } from 'joinstick/host'` (package installed from git or a checkout) and pass `server: 'http://<host>:3000'`.
27
+
28
+ ## Input contract
29
+
30
+ - Slots are 1-based. `room.input(0)` throws RangeError.
31
+ - `room.input(n)` is never undefined: `{ x, y, buttons, pressed }`. Neutral when the slot is empty and after leave. After a join or game reload, input already held shows in x/y/buttons but never in pressed.
32
+ - `x`: -1 left .. 1 right. `y`: -1 up .. 1 down. The d-pad gives -1/0/1 (8-way; up needs the thumb clearly above horizontal).
33
+ - `buttons`: held state of every layout button. Use for movement, block, charge.
34
+ - `pressed`: buttons that went down since the previous `input(n)` call, latched and cleared on read. Use for attacks, jumps, confirm. Never use held state to trigger one-shot actions.
35
+ - No input events: polling is the only way to read input.
36
+ - Keep calling input(n) every frame while paused (ignore the result), or presses made during the pause fire on resume.
37
+
38
+ ## Pause recipe
39
+
40
+ ```js
41
+ const paused = ![1, 2].every((n) => room.connected(n)); // every frame: simplest and always right
42
+ room.on('leave', ({ slot, reason }) => pause(slot)); // reason: left | disconnected | timeout | kicked
43
+ room.on('join', ({ slot, name, rejoin }) => maybeResume());
44
+ ```
45
+
46
+ A locked/backgrounded phone leaves immediately and rejoins its slot when back (join with rejoin: true). Game page reload: host() resumes the same room; pads already connected are visible via connected(n) when host() resolves and get NO join event, so check connected(n) at start; held input is restored; every pad's state (title/color/highlight/disabled) is reset. Host Wi-Fi blip: leave (disconnected) for all, then join with rejoin: true. Server restart: same room code; phones rejoin with rejoin: false. A stored room is only resumed with the same slots count. A duplicated browser tab takes over the room: the original gets room.on('error') with code 'replaced' and status 'closed'.
47
+
48
+ ## Layout
49
+
50
+ `{ stick: 'dpad' | 'none', orientation?: 'landscape' (default) | 'portrait' | 'any', arrangement?: 'grid' (default) | 'diamond' | 'arc', haptics?: true, buttons: [{ id, label?, color?, size?: 'large' | 'wide', haptic?: ms 0-100, system?: true }] }`, max 8 buttons (system ones included). id `/^[A-Za-z0-9_-]{1,16}$/` unique, not an Object.prototype name (constructor, toString...); label ≤ 8 chars; color `/^#[0-9a-f]{3,8}$/i`. Default: d-pad + a, b. Invalid layout → host() rejects with code 'bad-message'.
51
+ - orientation 'landscape': a phone held upright shows the pad rotated 90° (works with the rotation lock on); Android Chrome also goes fullscreen and locks the orientation on the first tap.
52
+ - arrangement: 'diamond' = first 4 buttons as face buttons (bottom, left, right, top), the rest beside them; 'arc' = first button big under the right thumb, the rest fanned around it.
53
+ - size 'wide': a long bar under the other buttons (good for a held action like block).
54
+ - system: true renders a small pill in the pad's top bar (START/MENU); it reports in input(n).buttons/pressed like any other button.
55
+ - haptics: local tap feedback on press and d-pad direction change (Android: vibrate; iOS 18+: best-effort system tick; desktop: none). layout.haptics false turns it off; per-button haptic sets ms (0 = off).
56
+
57
+ ## Host API
58
+
59
+ room.code, room.slots, room.status ('connecting'|'connected'|'reconnecting'|'closed'), room.input(n), room.connected(n), room.qr(n), room.joinUrl(n), room.pad(n, { title?, color?, highlight?: ids (glow and make the phone vibrate briefly (Android only); disabled wins over highlight), disabled?: ids, vibrate?: ms }), room.kick(n), room.send(n, data), room.broadcast(data), room.on('join'|'leave'|'message'|'status'|'error', fn) → unsubscribe, room.close().
60
+
61
+ ## Client API (custom controllers, remote players)
62
+
63
+ ```js
64
+ import { join } from '/joinstick/client.js';
65
+ const pad = await join({ room: 'KQZX', slot: 1, name: 'Ana' }); // slot omitted = first free
66
+ pad.setInput({ x, y, buttons: { a: true } }); pad.send(data); pad.on('message'|'pad'|'error'|'status', fn); pad.leave();
67
+ ```
68
+
69
+ ## Error codes
70
+
71
+ room-not-found, slot-taken (with free: [slots]), bad-slot, bad-message (with message), replaced, kicked, room-closed (host gone 60 s), version, server-full (1000 rooms, or 20 from one address), rate-limited (20 failed joins from one address in a minute); client-only: unreachable.
72
+
73
+ ## Pitfalls
74
+
75
+ - First run: allow Node through the OS firewall prompt.
76
+ - Guest/hotel Wi-Fi with client isolation blocks phones: use a hotspot or a tunnel.
77
+ - Plain HTTP: no wake lock, no iOS fullscreen. Host-driven vibrate (room.pad vibrate/highlight) works only on Android.
78
+ - An HTTPS game page cannot reach a ws:// LAN server (mixed content): serve the game with --static or put both behind HTTPS.
79
+ - Docker breaks LAN IP detection: use --public-url.
80
+ - Remote play: `cloudflared tunnel --url http://localhost:3000`, then start Joinstick with `--public-url https://<name>.trycloudflare.com`. 4-letter room codes are guessable on a public URL; there is no other authentication.
81
+ - --static refuses dotfiles and has no HTTP Range support (Safari video/audio needs Range: serve media elsewhere).
82
+ - Embedding with attach(server): call it after registering your own 'request' listeners.
83
+ - A pad in a background browser tab leaves its slot (lock-screen behavior).
84
+
85
+ ## Testing without phones
86
+
87
+ In the game tab's console (or a Node 22+ script importing joinstick/public/client.js with `server: 'http://localhost:3000'`):
88
+
89
+ ```js
90
+ const { join } = await import('/joinstick/client.js');
91
+ const pad = await join({ room: room.code, slot: 1 });
92
+ pad.setInput({ x: 1 }); // hold right
93
+ pad.setInput({ x: 1, buttons: { a: true } }); // press A
94
+ pad.setInput({ x: 1, buttons: { a: false } }); // release: input(1).pressed.a seen once
95
+ pad.leave(); // game should pause
96
+ ```
97
+
98
+ ## Don't
99
+
100
+ - Don't use innerHTML with player names or network data; use textContent.
101
+ - Don't read held state for one-shot actions; use pressed.
102
+ - Don't call input(n) twice per frame for the same slot.
package/package.json CHANGED
@@ -1,6 +1,81 @@
1
1
  {
2
2
  "name": "joinstick",
3
- "version": "0.0.0-stage",
4
- "stub": true,
5
- "description": "Temporary package placeholder for staged publishing"
6
- }
3
+ "version": "0.1.0",
4
+ "description": "Use phones as gamepads for HTML5 browser games: scan a QR, play. Self-hosted and open source, no accounts, no app install.",
5
+ "license": "MIT",
6
+ "author": {
7
+ "name": "Rafa Prats",
8
+ "url": "https://github.com/raprav"
9
+ },
10
+ "homepage": "https://github.com/raprav/joinstick#readme",
11
+ "repository": {
12
+ "type": "git",
13
+ "url": "git+https://github.com/raprav/joinstick.git"
14
+ },
15
+ "bugs": {
16
+ "url": "https://github.com/raprav/joinstick/issues"
17
+ },
18
+ "keywords": [
19
+ "gamepad",
20
+ "controller",
21
+ "phone-controller",
22
+ "mobile-controller",
23
+ "phone-gamepad",
24
+ "virtual-gamepad",
25
+ "touch-controls",
26
+ "local-multiplayer",
27
+ "multiplayer",
28
+ "party-game",
29
+ "couch-coop",
30
+ "smartphone-controller",
31
+ "game-controller",
32
+ "qr",
33
+ "qr-code",
34
+ "websocket",
35
+ "html5",
36
+ "html5-game",
37
+ "browser-game",
38
+ "self-hosted"
39
+ ],
40
+ "type": "module",
41
+ "main": "src/server.js",
42
+ "types": "src/server.d.ts",
43
+ "exports": {
44
+ ".": {
45
+ "types": "./src/server.d.ts",
46
+ "default": "./src/server.js"
47
+ },
48
+ "./host": {
49
+ "types": "./public/host.d.ts",
50
+ "default": "./public/host.js"
51
+ },
52
+ "./client": {
53
+ "types": "./public/client.d.ts",
54
+ "default": "./public/client.js"
55
+ },
56
+ "./package.json": "./package.json"
57
+ },
58
+ "bin": {
59
+ "joinstick": "src/cli.js"
60
+ },
61
+ "files": [
62
+ "src",
63
+ "public",
64
+ "llms.txt",
65
+ "AGENTS.md",
66
+ "PROTOCOL.md",
67
+ "CHANGELOG.md",
68
+ "THIRD_PARTY_NOTICES.md"
69
+ ],
70
+ "engines": {
71
+ "node": ">=22"
72
+ },
73
+ "scripts": {
74
+ "start": "node src/cli.js",
75
+ "test": "node --test"
76
+ },
77
+ "dependencies": {
78
+ "uqr": "^0.1.3",
79
+ "ws": "^8.22.0"
80
+ }
81
+ }
@@ -0,0 +1,88 @@
1
+ import type { Layout } from './host.js';
2
+
3
+ export type { Layout } from './host.js';
4
+
5
+ export interface PadState {
6
+ title?: string;
7
+ color?: string;
8
+ highlight?: string[];
9
+ disabled?: string[];
10
+ }
11
+
12
+ export interface PadPatch extends PadState {
13
+ vibrate?: number;
14
+ }
15
+
16
+ export type ErrorCode =
17
+ | 'room-not-found'
18
+ | 'slot-taken'
19
+ | 'bad-slot'
20
+ | 'bad-message'
21
+ | 'replaced'
22
+ | 'kicked'
23
+ | 'room-closed'
24
+ | 'version'
25
+ | 'rate-limited'
26
+ | 'unreachable';
27
+
28
+ export interface JoinError {
29
+ code: ErrorCode;
30
+ message?: string;
31
+ /** With 'slot-taken': the slots that are free right now. */
32
+ free?: number[];
33
+ }
34
+
35
+ export type Status = 'connecting' | 'connected' | 'reconnecting' | 'closed';
36
+
37
+ export interface PadInput {
38
+ /** -1 (left) .. 1 (right). */
39
+ x?: number;
40
+ /** -1 (up) .. 1 (down). */
41
+ y?: number;
42
+ /** Full held state, e.g. { a: true, b: false }. */
43
+ buttons?: Record<string, boolean>;
44
+ }
45
+
46
+ export interface PadEvents {
47
+ /** Data sent by the host with room.send() or room.broadcast(). */
48
+ message: [data: unknown];
49
+ /** The host changed this pad. `state` is the merged state, `patch` what changed (may include vibrate). */
50
+ pad: [state: PadState, patch: PadPatch];
51
+ /** Fatal error after joining (kicked, room-closed, replaced, slot-taken on rejoin...). The pad is closed. */
52
+ error: [error: JoinError];
53
+ status: [status: Status];
54
+ }
55
+
56
+ export interface JoinOptions {
57
+ /** Room code, case-insensitive. */
58
+ room: string;
59
+ /** 1-based slot. Omit to take the first free one. */
60
+ slot?: number;
61
+ /** Player name shown to the host, at most 20 chars. */
62
+ name?: string;
63
+ /** Joinstick server URL. Default: where client.js was loaded from. */
64
+ server?: string;
65
+ }
66
+
67
+ export interface Pad {
68
+ readonly room: string;
69
+ readonly slot: number;
70
+ /** Layout chosen by the host. Render labels with textContent only. */
71
+ readonly layout: Layout & Required<Pick<Layout, 'stick' | 'buttons'>>;
72
+ readonly state: PadState;
73
+ readonly status: Status;
74
+ /** Send the full current state. Cheap to call often: unchanged state is not resent. */
75
+ setInput(input: PadInput): void;
76
+ /** Send any JSON value to the host (arrives as room.on('message')). */
77
+ send(data: unknown): void;
78
+ on<E extends keyof PadEvents>(event: E, fn: (...args: PadEvents[E]) => void): () => void;
79
+ /** Leave the slot for good (no auto-rejoin). The host sees reason 'left'. */
80
+ leave(): void;
81
+ }
82
+
83
+ /**
84
+ * Join a room. Reconnects and rejoins the same slot by itself (network blips,
85
+ * phone locked, server restart). Rejects with an Error carrying a JoinError's
86
+ * fields (`code`, `free`) if the first join fails.
87
+ */
88
+ export function join(options: JoinOptions): Promise<Pad>;