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/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
|
-
#
|
|
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"><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
|
+
```
|