tickcli-js 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/CHANGELOG.md +15 -0
- package/LICENSE +21 -0
- package/README.md +360 -2
- package/client/game.js +982 -0
- package/client/game3d.js +109 -0
- package/client/relay.js +196 -0
- package/client/room.js +632 -0
- package/dist/cli.d.ts +11 -0
- package/dist/cli.js +82 -0
- package/dist/cli.js.map +1 -0
- package/dist/hub.d.ts +619 -0
- package/dist/hub.js +1777 -0
- package/dist/hub.js.map +1 -0
- package/dist/node.d.ts +106 -0
- package/dist/node.js +301 -0
- package/dist/node.js.map +1 -0
- package/dist/remote.d.ts +75 -0
- package/dist/remote.js +214 -0
- package/dist/remote.js.map +1 -0
- package/dist/serve.d.ts +42 -0
- package/dist/serve.js +231 -0
- package/dist/serve.js.map +1 -0
- package/examples/games/arena.html +108 -0
- package/examples/games/arena3d.html +110 -0
- package/examples/games/chess.html +132 -0
- package/examples/games/cup.html +52 -0
- package/examples/games/delve.html +137 -0
- package/examples/games/dungeon.html +110 -0
- package/examples/games/neon-paint.html +79 -0
- package/examples/games/own-engine.html +41 -0
- package/examples/games/race.html +83 -0
- package/examples/games/shooter.html +95 -0
- package/examples/games/sky-duel.html +136 -0
- package/examples/games/space-squad.html +3627 -0
- package/examples/games/tag.html +51 -0
- package/examples/games/tictactoe.html +53 -0
- package/package.json +101 -4
- package/src/cli.ts +59 -0
- package/src/hub.ts +1521 -0
- package/src/node.ts +245 -0
- package/src/remote.ts +236 -0
- package/src/serve.ts +224 -0
package/CHANGELOG.md
ADDED
|
@@ -0,0 +1,15 @@
|
|
|
1
|
+
# Changelog
|
|
2
|
+
|
|
3
|
+
## 0.1.0 (unreleased)
|
|
4
|
+
|
|
5
|
+
Games hosted anywhere: `tickcli-js/remote` (`createRemote`, `createRemoteServer`, `originAllowed`). A deployment is a public id with the sites allowed to use it; the page pastes one tag (`/c/<id>.js`: relay, room.js and game.js set up for it), the server checks the page's Origin at the socket upgrade: https sites, http only for localhost when listed (`http://localhost:*`), one kind of deployment. Guests keep a random 128-bit key in the page's storage, sent as the socket's first message (no cookie). Each deployment is its own namespace with optional caps (rooms, players, KB a second) and `revoke(id)`. Hub: `roomsPerScope` option and `scopeStats(scope)`. A refused page shows the server's reason (game.js). e2e `remote.ts` (two browsers on another site).
|
|
6
|
+
|
|
7
|
+
Behind a proxy: `npx tickcli-js --behind-proxy`, `createGameServer({ trustProxy })`, `clientAddress` in `tickcli-js/node` (players counted by their own address, not the proxy's).
|
|
8
|
+
|
|
9
|
+
Fixes: `createGameServer` reads and hashes each game once per version of its file (mtime and size), not on every page load and socket; game pages may open sockets to their own server only (CSP `connect-src`); the byte meters and the room data budget count UTF-8 bytes as sent; the guest name is kept under `tickcli.name` (a name saved under the old key is still read).
|
|
10
|
+
|
|
11
|
+
Other engines: `Game({context: "webgl"})` for three.js (the shell leaves the canvas to WebGL; `client/game3d.js`, global `TickcliThree`: renderer, resize, place, pose, pool), `Game({headless: true})` for engines that run their own loop (Phaser, PixiJS, Babylon.js, Kaplay: only the lobby, notices and countdown over the page) and `g.sync(make, place, remove)` for one engine object per other player. Quaternions `qx, qy, qz, qw` ease as one turn (slerp), `tickcli.precision(n)` and `multiplayer.decimals` for host positions (2 by default in webgl mode), `g.pointer.nx, ny`. The server serves `lib:three` as the global `THREE`, made from the installed `three` package (optional peer). Examples `arena3d.html` and `own-engine.html`, e2e `three.ts`.
|
|
12
|
+
|
|
13
|
+
Added since the first cut: a list of other open rooms in the lobby (`tickcli.rooms()`, message `rooms`, hub option `listRooms`, game option `multiplayer.roomList`), per-reader update rates for slow links and slow devices (message `rate`, limits `rateBytes`, `rateUpMs`), drawing steps for slow devices and `g.quality` in game.js. A tidier lobby (one main button, the rest as small links, the whole play area, 44 px buttons) and idle rooms closed after 15 minutes with a warning a minute before (close code 4010, message `stay`, limits `idleMs`, `idleWarnMs`, `listIdleMs`).
|
|
14
|
+
|
|
15
|
+
First public release. `npx tickcli-js <folder>` serves a folder of .html games with live rooms; `createGameServer()` (`tickcli-js/serve`) does the same inside your own Node app. Hub (`tickcli-js`), Node adapter (`tickcli-js/node`: sockets, tickets, guest passes, file saves), browser libraries (`client/room.js`, `relay.js`, `game.js`; `Game()` called before `<body>` exists waits for it), a demo server and the example games (shipped in the package under `examples/games/`). The browser libraries are marked as having side effects, so bundlers keep `import "tickcli-js/client/relay.js"`. Protocol version 1. The browser globals are `window.tickcli` and `tickcliRelay`, and the page messages carry a `tickcli` field.
|
package/LICENSE
ADDED
|
@@ -0,0 +1,21 @@
|
|
|
1
|
+
MIT License
|
|
2
|
+
|
|
3
|
+
Copyright (c) 2026 The tickcli authors
|
|
4
|
+
|
|
5
|
+
Permission is hereby granted, free of charge, to any person obtaining a copy
|
|
6
|
+
of this software and associated documentation files (the "Software"), to deal
|
|
7
|
+
in the Software without restriction, including without limitation the rights
|
|
8
|
+
to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
|
|
9
|
+
copies of the Software, and to permit persons to whom the Software is
|
|
10
|
+
furnished to do so, subject to the following conditions:
|
|
11
|
+
|
|
12
|
+
The above copyright notice and this permission notice shall be included in all
|
|
13
|
+
copies or substantial portions of the Software.
|
|
14
|
+
|
|
15
|
+
THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
|
|
16
|
+
IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
|
|
17
|
+
FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
|
|
18
|
+
AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
|
|
19
|
+
LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
|
|
20
|
+
OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
|
|
21
|
+
SOFTWARE.
|
package/README.md
CHANGED
|
@@ -1,3 +1,361 @@
|
|
|
1
|
-
#
|
|
1
|
+
# tickcli
|
|
2
2
|
|
|
3
|
-
|
|
3
|
+
Live rooms for browser games, made for games written in one HTML file (by people or by an AI). One player's browser runs the rules (the host); a small Node hub keeps the shared state, who is in the room, the order of changes and the limits. No game code ever runs on the server, so a new game needs no server change.
|
|
4
|
+
|
|
5
|
+
Good for: party and co-op games, tag and races, turn and board games with people watching, team battles with minions and towers, co-op dungeon crawlers, casual shooters, knockout tournaments. Players can be on a computer, a laptop or a phone in the same room. Not for: precise shooters or anything with prizes (the host's browser decides hits and scores, so a player who edits their browser can cheat).
|
|
6
|
+
|
|
7
|
+
MIT. No runtime dependencies; the Node adapter uses [ws](https://github.com/websockets/ws) (peer dependency).
|
|
8
|
+
|
|
9
|
+
## Quick start (2 minutes)
|
|
10
|
+
|
|
11
|
+
1. Save this as `dots.html` in an empty folder:
|
|
12
|
+
|
|
13
|
+
```html
|
|
14
|
+
<!doctype html><meta charset="utf-8"><title>Dots</title>
|
|
15
|
+
<script src="room.js"></script>
|
|
16
|
+
<script src="game.js"></script>
|
|
17
|
+
<script>
|
|
18
|
+
let x = 400, y = 300; // where my dot is
|
|
19
|
+
Game({
|
|
20
|
+
title: "Dots", size: [800, 600], // draw in 800 x 600 on any screen
|
|
21
|
+
multiplayer: { max: 8, player: { x: [0, 800], y: [0, 600] } }, // what each player shares
|
|
22
|
+
update(g, dt) {
|
|
23
|
+
const a = g.axis(); // arrow keys, WASD or the touch pad
|
|
24
|
+
x = Math.min(800, Math.max(0, x + a.x * 250 * dt));
|
|
25
|
+
y = Math.min(600, Math.max(0, y + a.y * 250 * dt));
|
|
26
|
+
g.me.set({ x, y }); // 1. tell everyone where I am
|
|
27
|
+
},
|
|
28
|
+
draw(g, ctx, w, h) {
|
|
29
|
+
ctx.fillStyle = "#0f172a"; ctx.fillRect(0, 0, w, h);
|
|
30
|
+
for (const p of g.players) { // 2. draw everyone, me included
|
|
31
|
+
ctx.fillStyle = p.me ? "#60a5fa" : "#e2e8f0";
|
|
32
|
+
ctx.beginPath(); ctx.arc(p.state.x, p.state.y, 12, 0, 7); ctx.fill();
|
|
33
|
+
}
|
|
34
|
+
},
|
|
35
|
+
});
|
|
36
|
+
</script>
|
|
37
|
+
```
|
|
38
|
+
|
|
39
|
+
2. Run `npx tickcli-js` in that folder (Node 22 or later).
|
|
40
|
+
3. Open http://localhost:8080/, pick the game, and open it again in a second window or send the link to a friend: you get a lobby, a room code, an invite link and both dots moving live.
|
|
41
|
+
|
|
42
|
+
More: `npx tickcli-js ./games --port 3000 --saves ./saves` (saved profiles and worlds), `--title "Game night"`, `--help`. Set `TICKCLI_SECRET` to any long random text so players keep their seats across restarts. The bigger example games (tag, chess, a race, a shooter, a dungeon, a tournament...): `npx tickcli-js node_modules/tickcli-js/examples/games` after `npm install tickcli-js`, or `npm run demo` in a clone of this repo.
|
|
43
|
+
|
|
44
|
+
## In your own Node app
|
|
45
|
+
|
|
46
|
+
```sh
|
|
47
|
+
npm install tickcli-js ws
|
|
48
|
+
```
|
|
49
|
+
|
|
50
|
+
```js
|
|
51
|
+
import { createGameServer } from "tickcli-js/serve";
|
|
52
|
+
|
|
53
|
+
const app = createGameServer({ games: "./games", saves: "./saves", secret: process.env.TICKCLI_SECRET });
|
|
54
|
+
console.log("open", await app.listen(8080));
|
|
55
|
+
process.on("SIGTERM", () => app.close().then(() => process.exit(0))); // writes pending saves first
|
|
56
|
+
```
|
|
57
|
+
|
|
58
|
+
| Option | Default | What it does |
|
|
59
|
+
|---|---|---|
|
|
60
|
+
| `games` | the current folder | folder of `.html` games (names: letters, digits, `-`, `_`) |
|
|
61
|
+
| `saves` | none | a folder for saved profiles and worlds, or your own `SavePort` |
|
|
62
|
+
| `secret` | random per process | signs the players' pass cookies; keep it stable to keep seats and saves |
|
|
63
|
+
| `limits` | `DEFAULT_LIMITS` | players per room, rooms, message sizes and rates |
|
|
64
|
+
| `secure` | `false` | mark the pass cookie Secure (set it behind https) |
|
|
65
|
+
| `title` | "tickcli games" | heading of the game list page |
|
|
66
|
+
|
|
67
|
+
`app.server` is a plain `node:http` server and `app.hub` the `RoomHub`, so you can add routes or read `hub.stats()`. A game loads the libraries as `room.js`, `lib:room` or `tickcli/room.js` (same for `game.js`); the server serves its own copies.
|
|
68
|
+
|
|
69
|
+
Need signed-in players, your own routes, or games in a sandboxed frame? Build on the parts instead: `tickcli-js` (the hub, no dependencies), `tickcli-js/node` (sockets, tickets, guest passes, saves; uses `ws`) and `tickcli-js/client/*.js` (the browser libraries). See [Hosting it](#hosting-it).
|
|
70
|
+
|
|
71
|
+
## How it works
|
|
72
|
+
|
|
73
|
+
```
|
|
74
|
+
game page (room.js + game.js) --postMessage, private port--> relay.js (trusted page) --1 WebSocket--> hub (Node)
|
|
75
|
+
```
|
|
76
|
+
|
|
77
|
+
| Part | File | Job |
|
|
78
|
+
|---|---|---|
|
|
79
|
+
| Hub | `src/hub.ts` | Rooms in memory, membership, host election and terms, change order, rates and sizes, data budgets, fan-out 20 times a second on a 10 ms sub-tick, restart hand-over. Pure logic: no sockets, injected clock. |
|
|
80
|
+
| Node adapter | `src/node.ts` | ws sockets to the hub, heartbeat, same-site check, one-time tickets, anonymous passes. |
|
|
81
|
+
| Relay | `client/relay.js` | Runs in the trusted page around the game (or in the game's own window). Owns the socket: reconnects with jitter, tells the server when the tab is hidden, pings. |
|
|
82
|
+
| Room client | `client/room.js` | In the game page: state mirrors, sends own state at most 12 times a second (10 in rooms over 8 players), eases others' movement, clock, actions. |
|
|
83
|
+
| Game shell | `client/game.js` | `Game({...})`: board fitting, input (keys, swipe, touch pad, held directions), no phone zoom on fast taps, tap-then-hold, pinch or long press, screens; with `multiplayer` the lobby, teams, invite link, notices and the host loop. |
|
|
84
|
+
|
|
85
|
+
The game page never needs network access: it can run in `<iframe sandbox="allow-scripts">` and still play, because the relay outside it holds the socket. The page never sees a ticket, a pass or an address.
|
|
86
|
+
|
|
87
|
+
## Writing a game
|
|
88
|
+
|
|
89
|
+
```html
|
|
90
|
+
<script src="room.js"></script>
|
|
91
|
+
<script src="game.js"></script>
|
|
92
|
+
<script>
|
|
93
|
+
Game({
|
|
94
|
+
title: "Tag", aspect: 4 / 3,
|
|
95
|
+
multiplayer: { max: 8, teams: 0, player: { x: [0, 800], y: [0, 600] } }, // every field a player sets, with its range
|
|
96
|
+
setup(g) { /* each match, everyone */ },
|
|
97
|
+
update(g, dt) { const a = g.axis(); /* move yourself */ g.me.set({ x, y }); },
|
|
98
|
+
host(g, dt) { /* ONE player: rules, timers, monsters */ g.shared.set({ round: 2, hp: { [id]: 80 } }); /* g.gameOver() ends the match (call it every frame if you like: only the first call in a match counts) */ },
|
|
99
|
+
onAction(g, from, name, data) { /* host: a player asked; return false to refuse */ },
|
|
100
|
+
onFx(g, from, name, data) { /* everyone: sparks and sounds */ },
|
|
101
|
+
draw(g, ctx, w, h) { /* g.players, g.shared (read-only; g.shared is frozen) */ },
|
|
102
|
+
});
|
|
103
|
+
</script>
|
|
104
|
+
```
|
|
105
|
+
|
|
106
|
+
| Who | Writes | How |
|
|
107
|
+
|---|---|---|
|
|
108
|
+
| Each player | own input and position | `g.me.set({...})`, any time; clamped to the declared ranges |
|
|
109
|
+
| Host (one browser) | rules, scores, health, everything that is not a player | `g.shared.set({...})` in `host()`, `onAction()` or `setup()` (starting values; only the host's count) |
|
|
110
|
+
| Anyone | a request to the host | `g.act(name, data)` resolves `{ok}`; never merged or dropped |
|
|
111
|
+
| Anyone | a cosmetic effect (arrives with the next frame, up to 50 ms later) | `g.fx(name, data)` |
|
|
112
|
+
| Server | membership, host, order, match number, clock | - |
|
|
113
|
+
|
|
114
|
+
- Writes merge deeply: `{hp: {a: 80}}` changes one entry, `null` removes a key, lists are replaced whole. Values that did not change are not sent (room.js and the hub both drop them), so writing a whole object every step is fine; a write that changes nothing sends nothing.
|
|
115
|
+
- Things the host moves (monsters, minions, shots) ease smoothly on every other screen when their position fields are named `x`, `y` (also `z`, `angle`, `rot`, and a quaternion `qx, qy, qz, qw`). Players ease the same way. Others are drawn in the past by a delay measured per room (mean delay of the last 40 updates + 2 send intervals + its spread, 100 to 500 ms; usually 200 to 400), at their senders' sample times; the drawing clock follows it by running 2% fast or 4% slow; a late update: the last speed for 100 ms, then hold. Measured through the real code: 0% jump frames, speed within 1%, for 4 and 16 players with 30 ms jitter.
|
|
116
|
+
- Timers are end times on the shared clock: `g.shared.set({endsAt: g.now + 60})`. The clock: 4 pings 250 ms apart after connecting, then one every 5 s; the offset of the fastest of the last 8 (none older than 60 s) is used, reached over a second, never in one step.
|
|
117
|
+
- `g.shared` is read-only all the way down (frozen): a write into it throws in strict code and does nothing otherwise, and `list.sort()` on a list in it throws, so copy first (`g.shared.list.slice().sort()`). The page never copies the whole state: each write makes new objects only along the paths it changes, so a frame costs the same at 3 KB or 60 KB (0.07 ms at 60 KB with 32 moving things, was 1.4 ms). `structuredClone(g.shared)` works (plain frozen objects).
|
|
118
|
+
- A late joiner gets `g.shared` and `g.players` in full, so they must tell the whole story.
|
|
119
|
+
- Host duty moves at once when the host leaves, hides its tab, or its game loop stops for 5 s; the new host continues from the server's copy of `g.shared`. Keep nothing important only in host variables.
|
|
120
|
+
- Teams: `multiplayer.teams: 2` splits the lobby evenly (anyone can switch before the start); `g.me.team`, `player.team`, `g.teams`, `g.teamColor(i)`.
|
|
121
|
+
- No host app around the page (a downloaded copy): the game runs as a one-player game.
|
|
122
|
+
|
|
123
|
+
More of the shell, by kind of game:
|
|
124
|
+
|
|
125
|
+
| Option or call | For | What it does |
|
|
126
|
+
|---|---|---|
|
|
127
|
+
| `size: [800, 600]` | every game with fixed coordinates | `draw` works in these units on any screen |
|
|
128
|
+
| `world: [2400, 1600]`, `g.camera.follow(x, y)`, `overlay(g, ctx, w, h)` | races, dungeons, team battles | a world bigger than the board, a camera, a layer on top in board units |
|
|
129
|
+
| `onPointer(g, x, y)`, `g.pointer` | board games, aiming | a click or tap in board (world) units |
|
|
130
|
+
| `buttons: [{id, label, key}]` | abilities, boost, fire | up to 4 extra buttons: a key on a computer, a touch button on a phone |
|
|
131
|
+
| `multiplayer.seats`, `seatNames`, `min` | chess and other games for a fixed few | that many play (`g.seats`, `g.me.seat`), the rest of the room watches; watchers get no update, input or actions |
|
|
132
|
+
| `multiplayer.cup: true` | tournaments | a knockout bracket over the room: the shell seats each match and moves winners on; the game ends a match with `g.gameOver(winnerId)` |
|
|
133
|
+
| `multiplayer.countdown: 3` | races, shooters | 3-2-1 on the shared clock before each match |
|
|
134
|
+
| `g.shoot({x, y, vx, vy, life, r})`, `g.shots`, `g.hit(shot)` | shooters | a shot is sent once and every screen moves it; the host decides what it hits |
|
|
135
|
+
| `g.late` | races, anything with a start line | true in `setup()` on a page that came into a running match (a late joiner, a reload): start next to the others, not on the start line |
|
|
136
|
+
| `g.spawnNear({x: [0, W], y: [0, H], gap})` | any game with a camera or a big world | where to start or respawn so the others are on screen: a ring round the middle at the start of a match, later `gap` away from someone still playing (`keys: ["x", "z"]` in 3D, `skip(player)` to leave someone out) |
|
|
137
|
+
|
|
138
|
+
The lobby also takes a friend's room code typed in by hand (codes never use I, O, 0 or 1), the touch pad holds one direction per finger, and angles named `angle` or `rot` (radians) turn the short way round on other screens.
|
|
139
|
+
|
|
140
|
+
### three.js and other engines
|
|
141
|
+
|
|
142
|
+
**three.js** (the shell keeps its board, bar and touch pad; three.js draws on the shell's canvas). Load three.js as the global `THREE` (`<script src="lib:three"></script>` on the tickcli server, which makes it from the installed `three` package, 0.171 or newer; or your own bundle), then `room.js`, `game.js`, `game3d.js`:
|
|
143
|
+
|
|
144
|
+
```js
|
|
145
|
+
const R3 = TickcliThree; // client/game3d.js: renderer(g), resize(renderer, camera, g), place(obj, state), pose(obj), pool(parent, make)
|
|
146
|
+
let renderer, scene, camera, me, others;
|
|
147
|
+
Game({
|
|
148
|
+
context: "webgl", // no 2D context: g.canvas is yours; draw(g) runs every frame
|
|
149
|
+
multiplayer: { max: 8, player: { x: [-20, 20], y: [0, 5], z: [-20, 20], qx: [-1, 1], qy: [-1, 1], qz: [-1, 1], qw: [-1, 1] } },
|
|
150
|
+
setup(g) { renderer = renderer || R3.renderer(g); /* scene, camera, me = new THREE.Mesh(...) */ others = others || R3.pool(scene, (p) => makeAvatar(p)); },
|
|
151
|
+
update(g, dt) { /* move me */ g.me.set(R3.pose(me)); }, // position + quaternion, rounded
|
|
152
|
+
host(g, dt) { g.shared.set({ gem: R3.pose(gem) }); }, // host-moved things ease too
|
|
153
|
+
draw(g) {
|
|
154
|
+
R3.resize(renderer, camera, g); // size, pixel ratio (the quality ladder), camera aspect
|
|
155
|
+
others.sync(g.players.filter((p) => !p.me)); // one object per other player, eased, removed when they leave
|
|
156
|
+
renderer.render(scene, camera);
|
|
157
|
+
},
|
|
158
|
+
});
|
|
159
|
+
```
|
|
160
|
+
|
|
161
|
+
A rotation is a quaternion in four fields `qx, qy, qz, qw` (three.js and Babylon order). Other screens ease it as one turn (slerp, the short way round, always of length 1); players declare each as `[-1, 1]` and send 3 decimals. In webgl mode the positions the host shares keep 2 decimals (`multiplayer.decimals` changes it; 2D games keep whole numbers). `g.pointer.nx, ny` (-1..1) go straight into a `Raycaster`. Example: `examples/games/arena3d.html`.
|
|
162
|
+
|
|
163
|
+
**Any engine that runs its own loop** (Phaser, PixiJS, Babylon.js, Kaplay, PlayCanvas, Excalibur, your own `requestAnimationFrame`): `headless: true`. The shell adds only the lobby, notices and countdown over your page (clicks pass through everywhere else) and leaves your page, canvas, loop and touches alone. The room is the same: `setup`, `host`, `onAction`, `onFx`, `g.me.set`, `g.shared.set`, `g.players` (eased), `g.gameOver()`.
|
|
164
|
+
|
|
165
|
+
```js
|
|
166
|
+
const g = Game({ headless: true, title: "My game", multiplayer: { max: 8, player: { x: [0, 800], y: [0, 600] } },
|
|
167
|
+
host(g, dt) { /* rules on one player: g.shared.set({...}) */ } });
|
|
168
|
+
|
|
169
|
+
// in your engine's update, once a frame: the whole room is these two lines
|
|
170
|
+
if (g.state === "playing") g.me.set({ x: hero.x, y: hero.y });
|
|
171
|
+
g.sync(make, place, remove); // one object per other player: make(p) when they join, place(sprite, state) every frame (eased), remove(sprite) when they leave
|
|
172
|
+
```
|
|
173
|
+
|
|
174
|
+
| Engine | Call from | make / place / remove |
|
|
175
|
+
|---|---|---|
|
|
176
|
+
| Phaser 4 | the scene's `update()` | `this.add.circle(0, 0, 12, 0xff0000)` / `s.setPosition(st.x, st.y)` / `s.destroy()` |
|
|
177
|
+
| PixiJS 8 | `app.ticker.add(...)` | `app.stage.addChild(new PIXI.Graphics().circle(0, 0, 12).fill(0xff0000))` / `s.position.set(st.x, st.y)` / `s.destroy()` |
|
|
178
|
+
| Babylon.js | `scene.onBeforeRenderObservable.add(...)` | `BABYLON.MeshBuilder.CreateBox(id, {}, scene)` / `s.position.set(st.x, st.y, st.z)`; `s.rotationQuaternion = new BABYLON.Quaternion(st.qx, st.qy, st.qz, st.qw)` / `s.dispose()` |
|
|
179
|
+
| Kaplay | `onUpdate(...)` | `add([circle(12), pos(0, 0), color(255, 0, 0)])` / `s.pos = vec2(st.x, st.y)` / `destroy(s)` |
|
|
180
|
+
|
|
181
|
+
On the tickcli server, `<script src="lib:phaser"></script>` and `<script src="lib:pixi"></script>` load the installed `phaser` and `pixi.js` packages' own global builds (PixiJS with its no-eval add-on, since game pages run without eval), like `lib:three`.
|
|
182
|
+
|
|
183
|
+
Examples: `own-engine.html` (a plain canvas engine, two marked lines of room code), `sky-duel.html` (Phaser air shooter), `neon-paint.html` (PixiJS, draw together), `arena3d.html` (three.js), `chess.html` (the shell alone). All in `examples/games/`, played by two browsers in `e2e/demos.ts`.
|
|
184
|
+
|
|
185
|
+
### Other open rooms
|
|
186
|
+
|
|
187
|
+
The lobby lists the other open rooms of the same game (asked when the lobby opens, then every 5 s while it shows and the page is visible): the host's name, players / max, waiting or playing, how fast the host's connection is (fast under 80 ms, ok under 200, slow above; the host's browser runs the rules, so its link is what a newcomer feels) and busy when that room is sending less often than normal; **Join** moves you there. Full rooms are left out (the heading counts them). Listed: rooms in the same namespace, game version and save slot that are not private, not someone's game alone and have someone in them; a page asking with its own options (room.js does) sees only rooms it could join. Rooms made by typing a new code are private. `multiplayer.roomList: false` hides the list; `new RoomHub({ listRooms: false })` turns it off on the server (the answer is `{off: true}`, room.js gives null). A game can draw its own list: `tickcli.rooms()` resolves `[{code, host, players, away, max, started, match, ping, slowed, full, you?}]` (players: connected; away: dropped with the seat held; ping: the host's round trip to the server in ms, 0 = not known yet), and joining one is `tickcli.join({spec, code})`. Lists are built at most every 250 ms per game and asked at most once a second per socket (burst 2); up to 50 rooms, the joinable ones first, then waiting, then the fullest, then the newest.
|
|
188
|
+
|
|
189
|
+
### The lobby
|
|
190
|
+
|
|
191
|
+
One card over the whole play area (so an upright phone has room), top to bottom: the game's name and one line saying what happens next ("Everyone here? Press Start.", "Waiting for Ana to start.", "Waiting for 1 more player."), the room code with **Copy invite link**, who is here (**Change my name** for guests), ONE main button for the host (**Start game**; shown greyed out while players are missing, so it is clear what is missing; **Continue** first when there is a saved game), then the less common choices as small links (**Join with a code** opens the code field, **Make it private**, **Play alone**, **Invite anyone**) and other open rooms. Buttons are 44 px high or more (WCAG 2.2 asks 24), focus is outlined, the status line is announced to screen readers, and the touch pad stays hidden behind the lobby until the start.
|
|
192
|
+
|
|
193
|
+
### Idle rooms
|
|
194
|
+
|
|
195
|
+
A room where no person did anything for 15 minutes (`idleMs`) is closed: moving, actions, effects, joining, starting, coming back to the page and **Keep it** count; the host's automatic updates and clock pings do not, so a game left running with nobody at it still ends. A minute before (`idleWarnMs`) every page hears `idle {closesAt}`: the lobby shows "Nobody has played for a while, so this room closes in about a minute" with **Keep it** (a notice during a match), and anything a person does takes it back (`idle {closesAt: 0}`). Saves are written first, then every page gets close code 4010 and the lobby offers **Start a new room**. A room idle over 5 minutes (`listIdleMs`) is left out of room lists before that. Rooms nobody is connected to were already gone after their seats' hold (30 s, 5 min for a page put away). `idleMs: 0` turns it off. (PlayFab lobbies work alike: any member activity resets the lobby's time to live, and lobbies that look abandoned leave search first.)
|
|
196
|
+
|
|
197
|
+
### Slow links and slow devices
|
|
198
|
+
|
|
199
|
+
Every page gets the same updates unless it cannot keep up. Then it gets fewer, never broken ones:
|
|
200
|
+
|
|
201
|
+
- **The hub** watches each socket's send queue: over 16 KB (`rateBytes`) that reader goes to every second frame, a second later still behind to every fourth (at 50 ms frames: 10 then 5 a second). Changes and effects in between are merged into the next frame it gets, so nothing is lost, only the in-between steps. It goes back one step after its queue stayed near empty for 10 s (`rateUpMs`): down fast, up slowly, so a phone on a weak signal does not swing. A reader changes rate only at a frame that every rate gets, and joins, leaves, host changes and starts send every rate's frame first, so events never overtake a change. Past 64 KB the old rule still holds (skip, one full copy later; 256 KB: dropped).
|
|
202
|
+
- **The page** (room.js) watches what the hub cannot: how late frames arrive compared with the link's usual delay (a far-away player has a steady delay, a link that cannot keep up a growing one: over 250 ms more for 2 s = ask for half, then a quarter; under 60 ms for 15 s = one step back), and how fast the game draws (under 20 frames a second for 3 s = ask for half). It asks with `rate {div}`; the hub uses the slower of its own reading and the ask and tells the page (`rate {div, every}`), which then keeps others drawn far enough in the past to stay smooth (2 of the slower gaps, up to 800 ms). On Chrome, Edge and Android, `navigator.connection` sets the starting point (data saver or 3G: half, 2G: a quarter).
|
|
203
|
+
- **Drawing** (game.js): a device that cannot keep up draws at a lower resolution first (the screen's pixel ratio capped at 2, 1.5, 1, then 0.75), then at 30 frames a second; the game itself keeps stepping at its normal rate. Under 45 frames a second or over 12 ms of drawing for 3 s = one step down; 55 frames and under 5 ms for 15 s = one step up. `g.quality = {tier: "high" | "medium" | "low", fps, scale, halfRate, rate, rtt}` lets a game drop its own extras (particles, shadows, trails) on "low"; `quality: false` in `Game({...})` turns the drawing steps off.
|
|
204
|
+
|
|
205
|
+
Not done on purpose: moving the socket or the room logic into a Web Worker (measured: the page's network work is about 0.07 ms a frame, less than one worker message would cost).
|
|
206
|
+
|
|
207
|
+
## Hosting it
|
|
208
|
+
|
|
209
|
+
```ts
|
|
210
|
+
import { RoomHub } from "tickcli-js";
|
|
211
|
+
import { WS_OPTIONS, attach, sameSite, Tickets, GuestPasses, gameHash } from "tickcli-js/node";
|
|
212
|
+
|
|
213
|
+
const hub = new RoomHub({ secret, saves, onSnapshot: (snap) => writeFile("rooms.json", JSON.stringify(snap)) });
|
|
214
|
+
try { hub.importRooms(JSON.parse(readFileSync("rooms.json", "utf8"))); } catch {} // the rooms of the last process, if under 2 minutes old
|
|
215
|
+
hub.start();
|
|
216
|
+
process.on("SIGTERM", async () => { await hub.shutdown(); process.exit(0); });
|
|
217
|
+
const wss = new WebSocketServer({ ...WS_OPTIONS });
|
|
218
|
+
server.on("upgrade", (req, socket, head) => {
|
|
219
|
+
if (!sameSite((n) => req.headers[n])) return socket.destroy();
|
|
220
|
+
wss.handleUpgrade(req, socket, head, (ws) => attach(ws, { hub, address, identity /* or authenticate: (ticket) => ... */ }));
|
|
221
|
+
});
|
|
222
|
+
```
|
|
223
|
+
|
|
224
|
+
Identity always comes from your app, never from the page:
|
|
225
|
+
|
|
226
|
+
- Signed-in people: issue a one-time ticket (`new Tickets(secret).issue({...})`, 30 s) from an authenticated route; the relay's `connect()` returns `{url, first: {v: 1, t: "auth", ticket}}` and the ticket travels as the socket's first message (proxies log addresses, so not in the URL). `authenticate(ticket)` turns it into `{scope, game, who}`.
|
|
227
|
+
- Anonymous visitors: set a pass cookie (`GuestPasses`, HttpOnly, SameSite=Strict, limited to one path) and check it at the upgrade (`identity`).
|
|
228
|
+
- `scope` is your namespace (a team, a public link); a room code only picks a room inside it. `game` is the page's hash: players on another version are told to reload.
|
|
229
|
+
|
|
230
|
+
Restarts: `hub.shutdown()` writes pending saves, hands the live rooms to `onSnapshot` (plain JSON: rooms, matches, shared state, seats; no tickets or passes) and closes every socket with 1012. The next process calls `importRooms(snapshot, {maxAgeMs})` (default 2 minutes; an older or other-format snapshot is ignored) before `start()`: each room comes back in the same match with a new host term, its seats held for 60 s and no host until a member is back; save leases are taken again. Players reconnect (spread over 0.3 to 3 s) and their welcome has `kept: true`. Keep the file private (it holds seat keys and save slots).
|
|
231
|
+
|
|
232
|
+
`src/serve.ts` (what `createGameServer` and `npx tickcli-js` run) is a complete host in about 120 lines.
|
|
233
|
+
|
|
234
|
+
### Games hosted on another site (tickcli-js/remote)
|
|
235
|
+
|
|
236
|
+
The game page lives anywhere (your own site, GitHub Pages, a game portal) and plays in rooms on a tickcli server. The page needs one tag:
|
|
237
|
+
|
|
238
|
+
```html
|
|
239
|
+
<script src="https://rooms.example/c/dep_ab12.js" data-game="dots" data-version="1"></script> <!-- relay + room.js + game.js, set up -->
|
|
240
|
+
<script> Game({ /* as above */ }) </script>
|
|
241
|
+
```
|
|
242
|
+
|
|
243
|
+
`data-game` names the game (its saves; default: the page's file name), `data-version` keeps players of different versions apart (default 1). The server side:
|
|
244
|
+
|
|
245
|
+
```ts
|
|
246
|
+
import { createRemoteServer } from "tickcli-js/remote";
|
|
247
|
+
|
|
248
|
+
const deployments = {
|
|
249
|
+
// "http://localhost:*" lets the author test the same page from a laptop; drop it whenever you like
|
|
250
|
+
dep_ab12: { id: "dep_ab12", origins: ["https://mygame.example", "https://*.portal.example", "http://localhost:*"], caps: { rooms: 20, players: 200, kbPerSec: 2048 } },
|
|
251
|
+
};
|
|
252
|
+
const app = createRemoteServer({ deployment: (id) => deployments[id], secret: process.env.TICKCLI_SECRET, saves: "./saves" });
|
|
253
|
+
await app.listen(8080); // behind https: the pages use wss://
|
|
254
|
+
```
|
|
255
|
+
|
|
256
|
+
Or in your own server: `createRemote({ hub, deployment })` gives `upgrade(req, socket, head)` and `request(req, res)` (pass `roomsPerScope: (s) => remote.roomsPerScope(s)` to the hub for room caps).
|
|
257
|
+
|
|
258
|
+
- A deployment is a public id plus the sites allowed to use it: `https://site`, one wildcard level `https://*.example`, any port `:*`. The server checks the page's `Origin` when the socket opens: browsers send it and page scripts cannot change it, so another site cannot use your deployment. There is one kind of deployment: https sites, and http only for this machine (`localhost`, `127.0.0.1`, `[::1]`) when listed, e.g. `http://localhost:*` to test from a laptop. Pages with no site (`null`, `file://`) are refused.
|
|
259
|
+
- Guests: the page keeps a random 128-bit key in its own `localStorage` and sends it as the socket's first message (no cookie, so it works from any site). The same key is the same seat; other players see a short id made from it, never the key.
|
|
260
|
+
- Each deployment is its own namespace (rooms and saves never mix). `caps`: open rooms, connected sockets, KB a second going out (over it, new sockets are told the game is busy and retry; open rooms carry on). `revoked: true` refuses new sockets; `remote.revoke(id)` also closes its open rooms.
|
|
261
|
+
- A refused page stops and shows why ("This site is not allowed for deployment dep_ab12.").
|
|
262
|
+
- Not a guard: a script outside a browser can send any `Origin`. The caps, the per-address limits and revoking the id are what stop a misused deployment.
|
|
263
|
+
|
|
264
|
+
## Protocol (version 1)
|
|
265
|
+
|
|
266
|
+
Every message is JSON with `v: 1`.
|
|
267
|
+
|
|
268
|
+
| To server | | To client | |
|
|
269
|
+
|---|---|---|---|
|
|
270
|
+
| `auth {ticket}` | first message, ticket mode | `welcome / sync {you, boot, ver, code, private, max, match, rev, term, host, started, players, shared, now, tick, saved, resumed, kept?, saveBusy?, saveError?}` (`ver`: the room's game version) | full state |
|
|
271
|
+
| `join {spec, code?, private?, name?, hidden?, f?}` | | `delta {rev, at, players?, pt?, shared?, st?, sc?, fx?}` | changes and effects, batched per tick; `pt` {player id: sample time} and `sc` (shared) are the senders' own sample times on the shared clock (`c` in `me`/`shared`, trusted within 500 ms back and 50 ms ahead, else the hub's time): pages draw others at these times, so updates sent 80 ms apart are drawn 80 ms apart whatever the fan-out did; the page applies players, then shared, then `fx` [{from, name, data}]. `f: ["fxb"]` = the page reads `fx` in the frame; a page without it gets single `fx` messages. The host's frame leaves out its own shared writes; a hidden page gets no frames (one `sync` when it is back) |
|
|
272
|
+
| `me {s, c?}` / `shared {term, p, c?}` | own state / host's shared state (`c`: when the values were set, shared clock ms) | `joined / online {player}`, `left {player}` | |
|
|
273
|
+
| `act {id, name, data}` / `result {id, ok}` | ask the host / host answers | `host {term, player}` | new host term |
|
|
274
|
+
| | | `newer` | the game was edited and someone opened the new version with this room's code: it is theirs now (this room carries on under a hidden code); game.js says press restart |
|
|
275
|
+
| `fx {name, data}`, `alive {term}`, `vis {hidden}` | | `action {id, from, name, data}` | host only |
|
|
276
|
+
| `start {resume?}`, `end`, `kick {player}`, `name {name}`, `leave`, `time {c}` | | `result {id, ok, error?}`, `fx` (old pages only), `started {resumed?}`, `ended`, `error {code, ref?}`, `time {c, s}` | `ref` of `bad_value`: the path that broke the shape (`mobs.12.name`) |
|
|
277
|
+
| `sync` | after a refused write: send me your copy | `sync {...}` (as welcome) | room.js replaces its copy with it |
|
|
278
|
+
| `rooms {spec?}` | the other open rooms of this game (joined sockets) | `rooms {rooms: [{code, host, players, away, max, started, match, ping, slowed, full, you?}], at}` or `rooms {off: true}` | see Other open rooms |
|
|
279
|
+
| `stay` | the person pressed Keep it after an idle warning | `idle {closesAt}` | the room closes then for lack of activity (0 = not any more) |
|
|
280
|
+
| `rate {div}` | send me every div-th frame (1, 2, 4; anything else = 1) | `rate {div, every}` | the rate in use and ms between frames; changes only at a frame every rate gets |
|
|
281
|
+
| `time {c, r?}` | `r`: the page's best recent round trip (ms), shown in room lists | `time {c, s}` | |
|
|
282
|
+
| `profile`, `save {p}` | read / merge into your saved profile | `profile {p, v}` or `{off:true}`, `saved {at, v}` (`v`: the save format number) | |
|
|
283
|
+
| `world {clear:true}` | host: forget the saved game | welcome also has `saved {at, v}` and `resumed` | |
|
|
284
|
+
|
|
285
|
+
Close codes: 1012 server restart (reconnect, the first try spread over 0.3 to 3 s so a whole server does not come back at once; with `onSnapshot`/`importRooms` the room carries on: welcome has `kept: true`, the page says "Back after a short pause"), 1013 busy (reconnect; over 200 new sockets a second or 200 unverified), 4009 a visible page silent for 15 s (reconnect; its seat is held), 1011 the room failed and was closed (reconnect: a new room; the rest of the server is untouched), 4001 not allowed, 4002 full, 4003 replaced by another tab, 4004 too fast, 4005 bad message, 4006 removed by the host, 4007 game updated (reload; also sent by room.js itself when a reconnect lands in another version than the page joined first), 4008 too slow, 4010 closed because nobody played for `idleMs` (final: the lobby offers a new room).
|
|
286
|
+
|
|
287
|
+
A write that breaks the shape or size limits is refused whole (`error bad_value`, `too_big` or `out_of_range`), never in part, and the connection stays. room.js checks the shape before it applies or sends a write (the game gets `error` with the path, nothing goes out) and, when the server refuses one anyway, asks `sync` and takes the server's copy, so the host never stays out of step. Sizes are counted in bytes, as the server counts them (a Vietnamese letter is 3, an emoji 4); room.js splits a big update at any depth and reports `too_big` for one value it cannot split. A message over 16,000 bytes never leaves relay.js: the game gets `error too_big`.
|
|
288
|
+
|
|
289
|
+
Edited games: a join always carries the page's current version (the ticket's hash). Joining a room code whose room runs an older version gives that code to a new room on the current version, so everyone who presses restart lands together; the old room keeps playing for whoever is in it, under a new hidden code, and hears `newer`. A page left open on the old version that reconnects into the new one stops with 4007 (press restart) instead of playing old code in it.
|
|
290
|
+
|
|
291
|
+
A page that reloads (or a second tab) takes over its seat and counts its actions from 1 again; its `setup()` runs, but its `g.shared` writes there count only on a page that saw the match start (or when the shared state holds only shell keys), so a host that reloads mid-match never resets the live game; actions the old page sent and the host had not answered are dropped. A page that comes back after its seat was given up sends its own state again. The relay's `connect()` may reject with `{final: true, reason}` (signed out, the game deleted, rooms off): it then stops and the game shows the reason; 10 minutes of failed tries in a row end the same way.
|
|
292
|
+
|
|
293
|
+
## Single player, together, and saved games
|
|
294
|
+
|
|
295
|
+
One game file plays alone or together. The lobby has **Play alone** (a private room of one: it starts by itself, and keeps the saved profile and saved game) when the game allows one player (`min` 1, no cup; `multiplayer.solo: false` hides it), and **Play with others** in a room of one (before it starts or after it ends). While playing alone the bar has **End** (tap twice) back to that lobby. A room of one is joined with `alone: true` (room.js `join({alone})`, relay `j.alone`): the hub keeps its world save under the player (`SavePort` `who` = `"alone:<seat key>"`), so a game alone and the shared game played together never read or overwrite each other, and nobody else can join it by code. The profile is the same in both. **Invite anyone** shows in a room for anyone when the host app offers it (relay option `inviteAnyone`, info `anyone: true`, room.js `inviteAnyone()`, port message `invite-anyone`): the app shows its own way in for people without an account (for example a share dialog with a public link). The public wrapper and the view page do not offer it. With no host app around the page it runs as the one-player game. Write for `g.players` (1 to max) and it works both ways.
|
|
296
|
+
|
|
297
|
+
Saving needs a host app that gives the hub a storage port (`new RoomHub({ saves })`, `SavePort` in `hub.ts`: `load(scope, slot, who)` gives `{at, data, v?}` or null, `store(scope, slot, who, json | null, {session?, v?})`) and a stable `slot` per game in the socket's identity (not the page hash, so editing a game keeps its saves). Without them everything below is off and `g.saves` is false.
|
|
298
|
+
|
|
299
|
+
- **Player profile** (`g.profile`, `g.profile.set({...})`): one small document per person and game, for stats, level, items, weapons, outfits, hp, mp, lives left. Loaded before `setup` (the game waits up to 3 s); merges like shared writes; written within seconds and when the person leaves. A profile that arrives after that wait is taken only if the game has not written its own yet; otherwise this page does not save and says "Progress is not being saved in this game". A public link's visitor keeps theirs in their own browser (`relay.js` option `profile`).
|
|
300
|
+
- **World save** (`multiplayer.save: true`): the host's `g.shared` (map level, monsters and their stats, progress) is written while it changes and at the end of a match. A new room finds it: the host sees **Continue** and **New game** (New game asks first: it replaces the save); Continue starts from the saved state (`g.resumed`, writes to `g.shared` inside `setup` are ignored), `g.clearSave()` ends the saved game. Shell keys (teams, seats, cup) are saved with it. One room at a time plays a given save: a second room of the same game (another group, a second tab alone) plays unsaved (`saveBusy` in welcome; the lobby says so and offers no Continue), and the next room after the first one closes continues it. A save that could not be read is never written over: until it is read the room writes and clears nothing (a Start waits up to 3 s for it, so Continue is still offered); a failed read is tried again after 2, 5, 15 s, then every 30 s, and meanwhile the lobby says "The saved game could not be opened, so this game is not being saved" (`saveError` in welcome, `savestate {error}` when it changes). A match that started without the save stays unsaved; the next start offers Continue.
|
|
301
|
+
- **Save versions** (`multiplayer.saveVersion`, a whole number, default 1): kept with every world save and profile (a save without one counts as 1). When a change to the game makes old saves unreadable, raise it and give `migrateSave(old, fromVersion)` (and `migrateProfile(old, fromVersion)`) returning the new shape: the host runs it once before `setup()` and its result replaces the game's own keys (shell keys stay). Without `migrateSave`, or for a save from a newer version, the lobby says the save was made by another version and offers no Continue; an old profile without `migrateProfile` is used as it is. Pages with different `saveVersion` never share a room (the newer one is told to reload).
|
|
302
|
+
- **Stores**: `memorySaves()` and `fileSaves(dir)` in `node.ts` (the demo server uses files: `TICKCLI_SAVES`, default `~/.tickcli-saves`). A host app can bring its own `SavePort`; keeping earlier copies of each world save (the save before a session's first write, one a day) helps with support. `fileSaves` writes async (temp file, fsync, rename), keeps no earlier copies, does not queue writes per file, and reads a damaged file as no save (after moving it to `.bad-<time>`). The store hears one session id per room and the save format (`store(..., {session, v})`).
|
|
303
|
+
- Example: `examples/games/delve.html` (levels, xp, gold, potions, weapons and outfits by level, hp and mp, team lives, depth).
|
|
304
|
+
|
|
305
|
+
## Limits (defaults, all in `Limits`)
|
|
306
|
+
|
|
307
|
+
| Limit | Value |
|
|
308
|
+
|---|---|
|
|
309
|
+
| Players per room | 16 |
|
|
310
|
+
| Rooms on the server / per namespace | 500 / 50 |
|
|
311
|
+
| Sockets on the server / unverified / per address | 2000 / 200 / 20 |
|
|
312
|
+
| New sockets | 200 a second (over it: 1013 busy) |
|
|
313
|
+
| Joins per address | 30 a minute |
|
|
314
|
+
| Message / player state / shared state | 16 KB / 2 KB / 64 KB (the client splits bigger host updates) |
|
|
315
|
+
| Shape | plain JSON, depth 4, 64 keys per object, 64 items per list, text 200 characters, keys 40 characters (`SHAPE`); a write that breaks one is refused whole |
|
|
316
|
+
| Messages per socket | 50 a second, burst 100 (a state write over it is dropped and the sender gets `error too_fast`, at most once a second; room.js then resyncs from the server's copy; room.js itself sends at most 20 effects a second, the rest wait for the next send slot); actions 5 a second, burst 10; clock pings 2 a second, burst 4 (2 before sign-in) |
|
|
317
|
+
| Data out | a room over 250 KB/s (moving average, `roomOutBytes`) sends half as often (down to an eighth), and goes back to the faster rate once that rate would be under 80% of the budget; its host gets `error too_much_data` once (game.js prints a hint). The whole server over 80% of `maxOutMbit` (100), or an event loop over 30 ms p99 for 10 s (`hub.loopRecent`), sends rooms over 4 players at half rate and answers new rooms (not joins to open ones) 1013 busy, until under 60%. Sizing: GB an hour = Mbit/s x 0.45 |
|
|
318
|
+
| Fan-out | each room on its own phase of a 10 ms sub-tick (rooms do not all write their sockets at once); changes every 50 ms; every 100 ms in rooms over 8 players (effects every 50 ms); effects queued past 128 or 16 KB go out at once |
|
|
319
|
+
| Seat held after a drop | 30 s; 5 min when the page was put away first (a phone switched apps, e.g. to send the invite). Host duty moves at once. A full room gives the seat of someone gone at least 10 s (`evictAfterMs`; a page put away first, then the longest gone) to a newcomer. |
|
|
320
|
+
| Silent host | 5 s, then host duty moves |
|
|
321
|
+
| Silent page | a visible page that sends nothing for 15 s (`silentMs`; the relay pings every 5 s) is dropped with 4009, its seat held as for any drop; hidden pages only need the socket ping |
|
|
322
|
+
| Saved profile / written | 16 KB per person and game / at most every 3 s, and when the person leaves or a match ends |
|
|
323
|
+
| Saved world / written / kept | one per namespace and game, written by one room at a time, 64 KB / every 5 s while it changes, and when a match ends or the room empties / 90 days |
|
|
324
|
+
| Slow reader | 16 KB queued (`rateBytes`): half the frames, then a quarter, merged so nothing is lost; back one step after 10 s near empty (`rateUpMs`). 64 KB: changes skipped, one full copy later; 256 KB: dropped |
|
|
325
|
+
| Idle room | closed after 15 min with no person doing anything, warned 1 min before; left out of lists after 5 min |
|
|
326
|
+
| Room list | 1 request a second per socket, burst 2 (`listRate`); built at most every 250 ms per game; 50 rooms |
|
|
327
|
+
|
|
328
|
+
## Not in version 1
|
|
329
|
+
|
|
330
|
+
Server-side rules (speed limits, "first to claim wins" decided by the server), interest management for very large worlds, binary encoding. Rooms live in memory: a restart without `onSnapshot` ends them with 1012 and players start a new match; what survives is what a game saves (below).
|
|
331
|
+
|
|
332
|
+
## Todo
|
|
333
|
+
|
|
334
|
+
Next:
|
|
335
|
+
|
|
336
|
+
- [ ] Computer players (bots) so games that need 2 or more people can also be played alone.
|
|
337
|
+
- [ ] Profile writes made while the line is down are lost (the end-of-match write is the one that matters).
|
|
338
|
+
|
|
339
|
+
Smaller open items:
|
|
340
|
+
|
|
341
|
+
- [ ] Tournament brackets for teams, and a best-of-three option.
|
|
342
|
+
- [ ] Chess: under-promotion (always a queen today), draw by repetition and the 50-move rule, a resign button.
|
|
343
|
+
- [ ] Name labels of players standing on the same spot overlap (dungeon and race examples).
|
|
344
|
+
- [ ] Paddle Cup played with no host app ends at once (a tournament needs 2 players).
|
|
345
|
+
|
|
346
|
+
## Meters
|
|
347
|
+
|
|
348
|
+
`hub.stats().live`: frames, bytes and messages per second, tick time p50/p99/max (the last 1,200 sub-ticks of 10 ms, about 12 s), slow readers, matches in progress, saves ok/failed, failed loads, world writes a minute, the 5 busiest rooms (players, bytes out a second, shared size; no names or ids) `loop` (event-loop delay, set by the host process: `hub.loop = {p50, p99, max}`), `pressure` (the server is over its data cap or its loop runs late) and `slowedRooms` (rooms over their own budget). `hub.stats()` also has `refused`: turned-away and dropped counts by reason (full, joins, address, accept_rate, busy_rooms, silent, room_error, room_budget, pressure, too_fast, ...). A host app can show the whole `stats()` on an admin page and a few totals in its health check.
|
|
349
|
+
|
|
350
|
+
## Tests
|
|
351
|
+
|
|
352
|
+
```sh
|
|
353
|
+
npm run typecheck
|
|
354
|
+
npm run build # dist/ (what npm ships), from src/
|
|
355
|
+
npm test # everything: test/ and test/engine/
|
|
356
|
+
npm run test:critical # test/ only: saves, security rules, the Node adapter (a fast gate for an app that embeds the hub)
|
|
357
|
+
npm run e2e:quick # the critical browser suites in parallel: quick start, the core games together, saved games, the room list
|
|
358
|
+
npm run e2e # every browser suite in parallel (adds three.js, the engine demos, the tutorials); e2e:serial = one at a time
|
|
359
|
+
```
|
|
360
|
+
|
|
361
|
+
`test/*.test.ts` is the critical set: saved games (`saves.test.ts`), the hub rules that protect the server and other players (`security.test.ts`), games on other sites (`remote.test.ts`), tickets, passes and the demo server over real sockets (`node.test.ts`), the one-call server and the command line (`serve.test.ts`). `test/engine/` holds the engine's behaviour (joining, host election, state, actions, matches, fan-out, the page libraries). A new test of a rule that protects data or the server goes in `test/`, anything else in `test/engine/`.
|