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/README.md
CHANGED
|
@@ -1,3 +1,270 @@
|
|
|
1
|
-
#
|
|
1
|
+
# Joinstick
|
|
2
2
|
|
|
3
|
-
|
|
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
|
+
[](https://www.npmjs.com/package/joinstick)
|
|
9
|
+
[](LICENSE)
|
|
10
|
+
[](package.json)
|
|
11
|
+
[](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.
|
|
4
|
-
"
|
|
5
|
-
"
|
|
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>;
|