@net-mesh/browser 0.37.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 +517 -0
- package/LICENSE-APACHE +190 -0
- package/LICENSE-MIT +21 -0
- package/README.md +666 -0
- package/dist/async-queue.d.ts +26 -0
- package/dist/async-queue.js +62 -0
- package/dist/credential.d.ts +70 -0
- package/dist/credential.js +95 -0
- package/dist/errors.d.ts +400 -0
- package/dist/errors.js +726 -0
- package/dist/events.d.ts +232 -0
- package/dist/events.js +298 -0
- package/dist/identity.d.ts +40 -0
- package/dist/identity.js +78 -0
- package/dist/index.bundle.js +2 -0
- package/dist/index.d.ts +38 -0
- package/dist/index.js +46 -0
- package/dist/leader/events.d.ts +111 -0
- package/dist/leader/events.js +116 -0
- package/dist/leader/index.d.ts +14 -0
- package/dist/leader/index.js +11 -0
- package/dist/leader/session.d.ts +539 -0
- package/dist/leader/session.js +770 -0
- package/dist/leader/wasm.d.ts +123 -0
- package/dist/leader/wasm.js +39 -0
- package/dist/lobby.d.ts +183 -0
- package/dist/lobby.js +504 -0
- package/dist/local.d.ts +80 -0
- package/dist/local.js +160 -0
- package/dist/net_leaf.d.ts +1494 -0
- package/dist/net_leaf.js +3753 -0
- package/dist/net_leaf_bg.wasm +0 -0
- package/dist/net_leaf_bg.wasm.d.ts +137 -0
- package/dist/netcode/client.d.ts +102 -0
- package/dist/netcode/client.js +296 -0
- package/dist/netcode/clock.d.ts +45 -0
- package/dist/netcode/clock.js +54 -0
- package/dist/netcode/host.d.ts +118 -0
- package/dist/netcode/host.js +276 -0
- package/dist/netcode/index.d.ts +19 -0
- package/dist/netcode/index.js +14 -0
- package/dist/netcode/interpolate.d.ts +50 -0
- package/dist/netcode/interpolate.js +100 -0
- package/dist/netcode/wire.d.ts +125 -0
- package/dist/netcode/wire.js +117 -0
- package/dist/node.d.ts +908 -0
- package/dist/node.js +1085 -0
- package/dist/org.d.ts +412 -0
- package/dist/org.js +567 -0
- package/dist/peer-driver.d.ts +117 -0
- package/dist/peer-driver.js +224 -0
- package/dist/store/assembly.d.ts +176 -0
- package/dist/store/assembly.js +325 -0
- package/dist/store/chunker.d.ts +83 -0
- package/dist/store/chunker.js +190 -0
- package/dist/store/core.d.ts +136 -0
- package/dist/store/core.js +477 -0
- package/dist/store/definition.d.ts +23 -0
- package/dist/store/definition.js +83 -0
- package/dist/store/errors.d.ts +77 -0
- package/dist/store/errors.js +94 -0
- package/dist/store/host.d.ts +247 -0
- package/dist/store/host.js +675 -0
- package/dist/store/index.d.ts +50 -0
- package/dist/store/index.js +40 -0
- package/dist/store/interest.d.ts +40 -0
- package/dist/store/interest.js +77 -0
- package/dist/store/inventory.d.ts +81 -0
- package/dist/store/inventory.js +140 -0
- package/dist/store/join.d.ts +118 -0
- package/dist/store/join.js +724 -0
- package/dist/store/json.d.ts +50 -0
- package/dist/store/json.js +283 -0
- package/dist/store/ledger.d.ts +187 -0
- package/dist/store/ledger.js +292 -0
- package/dist/store/owner.d.ts +616 -0
- package/dist/store/owner.js +1711 -0
- package/dist/store/patch.d.ts +70 -0
- package/dist/store/patch.js +166 -0
- package/dist/store/player.d.ts +65 -0
- package/dist/store/player.js +261 -0
- package/dist/store/replica.d.ts +187 -0
- package/dist/store/replica.js +740 -0
- package/dist/store/state.d.ts +42 -0
- package/dist/store/state.js +165 -0
- package/dist/store/types.d.ts +200 -0
- package/dist/store/types.js +25 -0
- package/dist/store/visibility.d.ts +118 -0
- package/dist/store/visibility.js +282 -0
- package/dist/store/wire.d.ts +309 -0
- package/dist/store/wire.js +834 -0
- package/dist/stream.d.ts +174 -0
- package/dist/stream.js +238 -0
- package/dist/three/index.d.ts +109 -0
- package/dist/three/index.js +130 -0
- package/dist/udp-probe.d.ts +159 -0
- package/dist/udp-probe.js +245 -0
- package/dist/wasm.d.ts +642 -0
- package/dist/wasm.js +98 -0
- package/dist/world/border.d.ts +84 -0
- package/dist/world/border.js +99 -0
- package/dist/world/handoff.d.ts +187 -0
- package/dist/world/handoff.js +222 -0
- package/dist/world/index.d.ts +13 -0
- package/dist/world/index.js +9 -0
- package/dist/world/link.d.ts +179 -0
- package/dist/world/link.js +476 -0
- package/dist/world/regions.d.ts +154 -0
- package/dist/world/regions.js +314 -0
- package/package.json +72 -0
package/README.md
ADDED
|
@@ -0,0 +1,666 @@
|
|
|
1
|
+
# @net-mesh/browser
|
|
2
|
+
|
|
3
|
+
[](https://www.npmjs.com/package/@net-mesh/browser)
|
|
4
|
+
|
|
5
|
+
**Multiplayer state for browser games.** One player's page hosts the world;
|
|
6
|
+
everyone else joins it. Each player gets a live copy of the parts of the world
|
|
7
|
+
they're allowed to see, and your Three.js scene stays in sync with it
|
|
8
|
+
automatically.
|
|
9
|
+
|
|
10
|
+
- **One source of truth.** The host runs your game rules. Players ask for things
|
|
11
|
+
to happen; the host decides. Nobody can cheat by editing their own copy.
|
|
12
|
+
- **Two kinds of messages, matched to how games work.** *Inputs* for things you
|
|
13
|
+
send every frame (steering, aim) — the newest one wins. *Actions* for things
|
|
14
|
+
that need an answer (fire, pick up, buy) — you get the result back, or a clear
|
|
15
|
+
reason it was refused.
|
|
16
|
+
- **Hidden information stays hidden.** The host decides what each player can
|
|
17
|
+
see. Fog of war, other players' hands, a secret objective: it never reaches a
|
|
18
|
+
player who shouldn't have it, so it can't be read out of the browser.
|
|
19
|
+
- **Three.js in a few lines.** Say what an entity looks like; meshes are
|
|
20
|
+
created, updated and cleaned up for you — and only the ones that changed are
|
|
21
|
+
touched.
|
|
22
|
+
- **Peer to peer.** Players connect to each other directly over WebRTC.
|
|
23
|
+
|
|
24
|
+
```sh
|
|
25
|
+
npm install @net-mesh/browser three
|
|
26
|
+
```
|
|
27
|
+
|
|
28
|
+
`three` is only needed for the Three.js helpers. The package itself has no
|
|
29
|
+
dependency on it, so you use whichever Three.js version you like.
|
|
30
|
+
|
|
31
|
+
---
|
|
32
|
+
|
|
33
|
+
## Building with Claude Code (or another AI agent)
|
|
34
|
+
|
|
35
|
+
If an AI agent is writing your game code, give it the Net skills first.
|
|
36
|
+
Without them, an agent will write multiplayer code that looks right and runs,
|
|
37
|
+
but gets the details wrong — like re-running game rules on each player instead
|
|
38
|
+
of on the host, or retrying an action that may already have happened.
|
|
39
|
+
|
|
40
|
+
```sh
|
|
41
|
+
npx skills add ai-2070/net-claude-skill -g # drop -g to install for this project only
|
|
42
|
+
```
|
|
43
|
+
|
|
44
|
+
The `net-browser` skill in that set covers this package end to end: connecting,
|
|
45
|
+
hosting and joining a world, inputs vs actions, hidden information, netcode,
|
|
46
|
+
worlds across region hosts, the Three.js binding, and every error code. Ask for what you want in plain words — *"make
|
|
47
|
+
this a two-player game where one player hosts"* — and the agent will use it.
|
|
48
|
+
|
|
49
|
+
To let the agent read Net's actual source instead of guessing, add
|
|
50
|
+
[`opensrc`](https://github.com/vercel-labs/opensrc):
|
|
51
|
+
|
|
52
|
+
```sh
|
|
53
|
+
npx -y opensrc@latest path ai-2070/net
|
|
54
|
+
```
|
|
55
|
+
|
|
56
|
+
More install options: [Claude Skills](https://ai2070.net/docs/start/claude-skills).
|
|
57
|
+
|
|
58
|
+
---
|
|
59
|
+
|
|
60
|
+
## Before players can connect
|
|
61
|
+
|
|
62
|
+
A browser can't find other browsers on its own, so every multiplayer game on
|
|
63
|
+
Net needs two things that don't come from the page:
|
|
64
|
+
|
|
65
|
+
1. **An anchor** — a small server that introduces players to each other and
|
|
66
|
+
helps them open a direct connection. Your game traffic doesn't pass through
|
|
67
|
+
it.
|
|
68
|
+
2. **A credential per player** — a string the anchor issues, which the page
|
|
69
|
+
passes to `connect()`.
|
|
70
|
+
|
|
71
|
+
**Run an anchor for your game** with the `net-mesh` CLI. It needs a TLS
|
|
72
|
+
certificate browsers trust, the page's origin, a pre-shared key file, and an
|
|
73
|
+
issuer key file (`net-mesh identity generate --out issuer.toml`):
|
|
74
|
+
|
|
75
|
+
```sh
|
|
76
|
+
net-mesh anchor serve --psk-file psk.hex --url https://anchor.example.com --tls-cert cert.pem --tls-key key.pem --allow-origin https://game.example.com --issuer-identity issuer.toml --game my-game
|
|
77
|
+
```
|
|
78
|
+
|
|
79
|
+
**Each player gets their own credential from it**, anonymously — no sign-up.
|
|
80
|
+
`rememberedIdentity()` keeps the player the same across visits:
|
|
81
|
+
|
|
82
|
+
```js
|
|
83
|
+
import { connect, rememberedIdentity, requestCredential } from '@net-mesh/browser';
|
|
84
|
+
|
|
85
|
+
const { credentialB64, bootstrapUrl } = await requestCredential({
|
|
86
|
+
anchorUrl: 'https://anchor.example.com',
|
|
87
|
+
game: 'my-game',
|
|
88
|
+
});
|
|
89
|
+
const node = await connect({ credentialB64, bootstrapUrl, ...rememberedIdentity() });
|
|
90
|
+
```
|
|
91
|
+
|
|
92
|
+
- A player is a **browser profile**: clearing site data makes a new player,
|
|
93
|
+
and one person on two devices is two players.
|
|
94
|
+
- A credential is one player's: the anchor refuses it from anyone else.
|
|
95
|
+
- One anchor can serve several games (repeat `--game`), kept apart: a
|
|
96
|
+
player of one game never discovers, lists or reaches another game's
|
|
97
|
+
lobbies or players.
|
|
98
|
+
|
|
99
|
+
> **Building the game before you have an anchor?** `@net-mesh/browser/local`
|
|
100
|
+
> is a mesh inside one page: no anchor, no network, and the real store on top.
|
|
101
|
+
>
|
|
102
|
+
> ```js
|
|
103
|
+
> import { createLocalMesh } from '@net-mesh/browser/local';
|
|
104
|
+
>
|
|
105
|
+
> const mesh = createLocalMesh();
|
|
106
|
+
> const node = mesh.node(); // use it wherever this guide uses `node`
|
|
107
|
+
> const friend = mesh.node(); // a second player, in the same page
|
|
108
|
+
> ```
|
|
109
|
+
>
|
|
110
|
+
> Local nodes can `announce` and `query` too, so step 3's host-finding code
|
|
111
|
+
> works unchanged. Build your game logic this way, then swap in `connect()` for real
|
|
112
|
+
> multiplayer. It proves your rules work, not that two browsers can reach each
|
|
113
|
+
> other. The [`demo/`](https://github.com/ai-2070/net/tree/master/net/crates/net/browser-ts/demo)
|
|
114
|
+
> runs a host and two players like this.
|
|
115
|
+
|
|
116
|
+
---
|
|
117
|
+
|
|
118
|
+
## Your first multiplayer scene
|
|
119
|
+
|
|
120
|
+
Five steps: describe the game, connect, host (or join), render, and play.
|
|
121
|
+
|
|
122
|
+
### 1. Describe the game
|
|
123
|
+
|
|
124
|
+
A *store definition* says what the world looks like and which messages players
|
|
125
|
+
can send. It contains no game rules, so it's safe to ship to every player.
|
|
126
|
+
|
|
127
|
+
```js
|
|
128
|
+
import { defineStore } from '@net-mesh/browser';
|
|
129
|
+
|
|
130
|
+
// Your validators: turn untrusted data into a clean value, or throw.
|
|
131
|
+
// (Zod or Valibot `schema.parse` works here too.)
|
|
132
|
+
const num = v => (Number.isFinite(Number(v)) ? Number(v) : 0);
|
|
133
|
+
|
|
134
|
+
export const arena = defineStore({
|
|
135
|
+
id: 'my-game.arena',
|
|
136
|
+
version: 1,
|
|
137
|
+
state: v => ({ ships: v?.ships ?? {} }), // the whole world
|
|
138
|
+
empty: () => ({ ships: {} }), // what a player sees before joining
|
|
139
|
+
visibility: 'open', // every player sees every ship — see "Hidden information"
|
|
140
|
+
actions: {
|
|
141
|
+
// Something that needs an answer. `enlist` gives the sender a ship…
|
|
142
|
+
enlist: {
|
|
143
|
+
input: () => ({}),
|
|
144
|
+
output: v => ({ id: String(v.id) }),
|
|
145
|
+
},
|
|
146
|
+
// …and `fire` returns the target's new hull.
|
|
147
|
+
fire: {
|
|
148
|
+
input: v => ({ at: String(v.at) }),
|
|
149
|
+
output: v => ({ hull: num(v.hull) }),
|
|
150
|
+
},
|
|
151
|
+
},
|
|
152
|
+
inputs: {
|
|
153
|
+
// Something sent every frame: only the newest matters.
|
|
154
|
+
steer: v => ({ dx: num(v.dx), dz: num(v.dz), dt: num(v.dt) }),
|
|
155
|
+
},
|
|
156
|
+
});
|
|
157
|
+
```
|
|
158
|
+
|
|
159
|
+
### 2. Connect
|
|
160
|
+
|
|
161
|
+
```js
|
|
162
|
+
import { connect, rememberedIdentity, requestCredential } from '@net-mesh/browser';
|
|
163
|
+
|
|
164
|
+
const { credentialB64, bootstrapUrl } = await requestCredential({
|
|
165
|
+
anchorUrl: 'https://anchor.example.com',
|
|
166
|
+
game: 'my-game',
|
|
167
|
+
});
|
|
168
|
+
const node = await connect({
|
|
169
|
+
credentialB64, // this player's credential, from your anchor
|
|
170
|
+
bootstrapUrl, // the anchor's address
|
|
171
|
+
...rememberedIdentity(), // the same player on every visit
|
|
172
|
+
});
|
|
173
|
+
|
|
174
|
+
const myId = node.nodeIdHex(); // this player's id, shared with whoever joins
|
|
175
|
+
```
|
|
176
|
+
|
|
177
|
+
### 3a. Host the world (on one player's page)
|
|
178
|
+
|
|
179
|
+
The host holds the real world and runs the rules. `context.peer` is the id of
|
|
180
|
+
the player who sent the message, and the connection proves it, so a player
|
|
181
|
+
can't pretend to be someone else.
|
|
182
|
+
|
|
183
|
+
```js
|
|
184
|
+
import { hostStore } from '@net-mesh/browser';
|
|
185
|
+
|
|
186
|
+
const host = hostStore({
|
|
187
|
+
definition: arena,
|
|
188
|
+
transport: node,
|
|
189
|
+
initialState: { ships: {} },
|
|
190
|
+
maxEventBytes: 8104, // the largest message the connection carries
|
|
191
|
+
|
|
192
|
+
// Who may do what. Here: no shooting yourself.
|
|
193
|
+
authorize: request =>
|
|
194
|
+
request.type !== 'action' || request.input.at !== request.peer,
|
|
195
|
+
|
|
196
|
+
actions: {
|
|
197
|
+
enlist: (input, context) => {
|
|
198
|
+
const ships = { ...context.getState().ships };
|
|
199
|
+
ships[context.peer] ??= { x: 0, z: 0, hull: 100 }; // one ship per player
|
|
200
|
+
context.setState({ ships });
|
|
201
|
+
return { id: context.peer };
|
|
202
|
+
},
|
|
203
|
+
fire: (input, context) => {
|
|
204
|
+
const ships = { ...context.getState().ships };
|
|
205
|
+
const target = ships[input.at];
|
|
206
|
+
if (!target) throw new Error('no such ship'); // refused: the player gets
|
|
207
|
+
// code 'action-rejected' — your
|
|
208
|
+
// message stays on the host
|
|
209
|
+
ships[input.at] = { ...target, hull: Math.max(0, target.hull - 25) };
|
|
210
|
+
context.setState({ ships });
|
|
211
|
+
return { hull: ships[input.at].hull };
|
|
212
|
+
},
|
|
213
|
+
},
|
|
214
|
+
inputs: {
|
|
215
|
+
steer: (input, context) => {
|
|
216
|
+
const ships = { ...context.getState().ships };
|
|
217
|
+
const ship = ships[context.peer];
|
|
218
|
+
if (!ship) return;
|
|
219
|
+
ships[context.peer] = { ...ship, x: ship.x + input.dx * 6 * input.dt,
|
|
220
|
+
z: ship.z + input.dz * 6 * input.dt };
|
|
221
|
+
context.setState({ ships });
|
|
222
|
+
},
|
|
223
|
+
},
|
|
224
|
+
});
|
|
225
|
+
|
|
226
|
+
// Let other players find this host. Announcements expire after a few seconds,
|
|
227
|
+
// so keep refreshing them.
|
|
228
|
+
const TAG = 'my-game.host';
|
|
229
|
+
await node.announce([TAG]);
|
|
230
|
+
setInterval(() => node.announce([TAG]).catch(() => {}), 2_000);
|
|
231
|
+
```
|
|
232
|
+
|
|
233
|
+
The host is usually a player too. A page can't join its own world, so it plays
|
|
234
|
+
through `hostPlayer` — the same kind of handle joined players get, held to the
|
|
235
|
+
same `authorize` rules and seeing the same projected view:
|
|
236
|
+
|
|
237
|
+
```js
|
|
238
|
+
import { hostPlayer } from '@net-mesh/browser';
|
|
239
|
+
|
|
240
|
+
const world = hostPlayer(host, { audience: ['crew'] });
|
|
241
|
+
await world.ready();
|
|
242
|
+
await world.act('enlist', {}); // same rules as everyone else
|
|
243
|
+
```
|
|
244
|
+
|
|
245
|
+
From here on, the host's page and every other page use `world` the same way.
|
|
246
|
+
|
|
247
|
+
### 3b. Join the world (on everyone else's page)
|
|
248
|
+
|
|
249
|
+
```js
|
|
250
|
+
import { joinStore, peerIdHex } from '@net-mesh/browser';
|
|
251
|
+
|
|
252
|
+
// Wait until the host's announcement is visible, then join.
|
|
253
|
+
async function findHost(hostId) {
|
|
254
|
+
for (let i = 0; i < 80; i++) {
|
|
255
|
+
const hosts = await node.query('my-game.host');
|
|
256
|
+
if (hosts.some(h => peerIdHex(h.nodeId) === hostId)) return;
|
|
257
|
+
await new Promise(resolve => setTimeout(resolve, 250));
|
|
258
|
+
}
|
|
259
|
+
throw new Error('the host never showed up');
|
|
260
|
+
}
|
|
261
|
+
|
|
262
|
+
await findHost(hostId); // `hostId` = the host's `node.nodeIdHex()`
|
|
263
|
+
const world = joinStore({
|
|
264
|
+
definition: arena,
|
|
265
|
+
transport: node,
|
|
266
|
+
host: hostId,
|
|
267
|
+
audience: ['crew'], // which view of the world to ask for
|
|
268
|
+
key: 'player', // an opaque join token (required)
|
|
269
|
+
maxEventBytes: 8104,
|
|
270
|
+
});
|
|
271
|
+
await world.ready(); // resolves once the first copy has arrived
|
|
272
|
+
await world.act('enlist', {}); // ask the host for a ship
|
|
273
|
+
```
|
|
274
|
+
|
|
275
|
+
### 4. Render it with Three.js
|
|
276
|
+
|
|
277
|
+
```js
|
|
278
|
+
import * as THREE from 'three';
|
|
279
|
+
import { bindEntities } from '@net-mesh/browser/three';
|
|
280
|
+
|
|
281
|
+
const geometry = new THREE.ConeGeometry(0.4, 1, 8); // shared by every ship
|
|
282
|
+
const material = new THREE.MeshStandardMaterial({ color: 0x44aaff });
|
|
283
|
+
|
|
284
|
+
const ships = bindEntities({
|
|
285
|
+
store: world, // `hostPlayer(host)` or `joinStore(…)` — same code
|
|
286
|
+
scene,
|
|
287
|
+
select: state => state.ships, // the entities to draw, by id
|
|
288
|
+
binding: {
|
|
289
|
+
create: (ship, id) => { // a ship appeared: build it where it is
|
|
290
|
+
const mesh = new THREE.Mesh(geometry, material);
|
|
291
|
+
mesh.position.set(ship.x, 0, ship.z);
|
|
292
|
+
return mesh;
|
|
293
|
+
},
|
|
294
|
+
update: (mesh, ship) => mesh.position.set(ship.x, 0, ship.z),
|
|
295
|
+
remove: mesh => {}, // free anything you created per ship here
|
|
296
|
+
},
|
|
297
|
+
});
|
|
298
|
+
```
|
|
299
|
+
|
|
300
|
+
`create` runs once when a ship appears, `update` only for ships that actually
|
|
301
|
+
changed afterwards, and a ship that leaves the world is removed from the scene
|
|
302
|
+
for you. Shared geometry and materials (like the
|
|
303
|
+
two above) are yours to dispose once, when the match ends.
|
|
304
|
+
|
|
305
|
+
### 5. Play
|
|
306
|
+
|
|
307
|
+
```js
|
|
308
|
+
function frame(now) {
|
|
309
|
+
// Every frame: send the held direction. The newest input wins.
|
|
310
|
+
world.input('steer', { dx: keys.dx, dz: keys.dz, dt: 1 / 60 });
|
|
311
|
+
renderer.render(scene, camera);
|
|
312
|
+
requestAnimationFrame(frame);
|
|
313
|
+
}
|
|
314
|
+
requestAnimationFrame(frame);
|
|
315
|
+
|
|
316
|
+
// On a key press: an action, answered by the host.
|
|
317
|
+
try {
|
|
318
|
+
const { hull } = await world.act('fire', { at: targetId });
|
|
319
|
+
showHit(targetId, hull);
|
|
320
|
+
} catch (error) {
|
|
321
|
+
showMessage(`Refused: ${error.code}`); // e.g. 'forbidden' for firing at yourself
|
|
322
|
+
}
|
|
323
|
+
```
|
|
324
|
+
|
|
325
|
+
---
|
|
326
|
+
|
|
327
|
+
## Big worlds: send each player only what's near them
|
|
328
|
+
|
|
329
|
+
In a large world, a player doesn't need every ship — only the ones nearby. Tell
|
|
330
|
+
the definition how to place an entity on a grid, and each player asks for the
|
|
331
|
+
cells around them:
|
|
332
|
+
|
|
333
|
+
```js
|
|
334
|
+
import { cellKey, cellsAround, stickyCells, sameCells } from '@net-mesh/browser';
|
|
335
|
+
|
|
336
|
+
const arena = defineStore({
|
|
337
|
+
// …
|
|
338
|
+
interest: { ships: ship => cellKey(ship.x, ship.z, 32) }, // 32-unit grid cells
|
|
339
|
+
});
|
|
340
|
+
|
|
341
|
+
const world = joinStore({ /* … */ interest: cellsAround(me.x, me.z, { size: 32 }) });
|
|
342
|
+
|
|
343
|
+
// As the player moves:
|
|
344
|
+
let cells = cellsAround(me.x, me.z, { size: 32 });
|
|
345
|
+
function onMove() {
|
|
346
|
+
const next = stickyCells(cells, me.x, me.z, { size: 32 });
|
|
347
|
+
if (!sameCells(next, cells)) { cells = next; world.setInterest(cells); }
|
|
348
|
+
}
|
|
349
|
+
```
|
|
350
|
+
|
|
351
|
+
- Ships entering the player's cells appear and ships leaving disappear in one
|
|
352
|
+
update — the world never blanks while the player crosses a border — and
|
|
353
|
+
`bindEntities` adds and removes them from the scene as usual.
|
|
354
|
+
- Changes far away send that player nothing at all.
|
|
355
|
+
- `stickyCells` keeps a cell the player just left until they're a cell past it,
|
|
356
|
+
so walking along a border doesn't flicker.
|
|
357
|
+
- An interest key is any string — a room name works as well as a grid cell. An
|
|
358
|
+
entity whose key function returns `null` goes to everyone.
|
|
359
|
+
- Interest decides what's *near*, not what's *allowed*: secrets still belong in
|
|
360
|
+
`visibility`.
|
|
361
|
+
|
|
362
|
+
## Responsive movement: netcode
|
|
363
|
+
|
|
364
|
+
For positions and aim — state sent many times a second where only the
|
|
365
|
+
newest value matters — use `@net-mesh/browser/netcode` beside the store.
|
|
366
|
+
The host runs a fixed tick; your own ship moves the instant you press a key
|
|
367
|
+
(prediction) and is corrected by the host only if you disagree
|
|
368
|
+
(reconciliation); everyone else's ships are drawn a few frames in the past,
|
|
369
|
+
between two host snapshots, so a lost packet never makes them jump.
|
|
370
|
+
|
|
371
|
+
```js
|
|
372
|
+
import { hostNetcode, joinNetcode } from '@net-mesh/browser/netcode';
|
|
373
|
+
|
|
374
|
+
// On the host:
|
|
375
|
+
hostNetcode({
|
|
376
|
+
transport: node, label: 'my-game.movement', tickRate: 30,
|
|
377
|
+
step: ({ inputs }) => { for (const [peer, list] of inputs) for (const { data } of list) moveShip(peer, data); },
|
|
378
|
+
snapshot: () => ships,
|
|
379
|
+
});
|
|
380
|
+
|
|
381
|
+
// On each player:
|
|
382
|
+
const net = joinNetcode({
|
|
383
|
+
transport: node, host: hostId, label: 'my-game.movement',
|
|
384
|
+
local: { id: node.nodeIdHex(), predict: (ship, input) => movedShip(ship, input) },
|
|
385
|
+
});
|
|
386
|
+
onKey(input => net.input(input));
|
|
387
|
+
function frame() { draw(net.view()); requestAnimationFrame(frame); }
|
|
388
|
+
```
|
|
389
|
+
|
|
390
|
+
Everything rides a **lossy stream** (`openStream({ reliability:
|
|
391
|
+
'fireAndForget', lossy: true })`): unordered, no retransmits, so one lost
|
|
392
|
+
packet never delays the next. Shooting games get lag compensation:
|
|
393
|
+
`host.rewind(input.seen)` returns the world as the shooter saw it, capped at
|
|
394
|
+
200 ms so faking lag gains nothing. A correction blends in over
|
|
395
|
+
`correctionSmoothingMs` (100 ms by default) instead of snapping, and each
|
|
396
|
+
player can ask for only nearby entities with `interest` keys. A snapshot too
|
|
397
|
+
big for one message is sent in chunks, so one lost packet costs a few
|
|
398
|
+
entities for one tick, not the whole frame.
|
|
399
|
+
|
|
400
|
+
## Worlds bigger than one host
|
|
401
|
+
|
|
402
|
+
`@net-mesh/browser/world` cuts the map into regions (`r:<x>:<z>` squares), each
|
|
403
|
+
its own store on whichever host announces it. A player holds the regions
|
|
404
|
+
around it and sees them as one world:
|
|
405
|
+
|
|
406
|
+
```js
|
|
407
|
+
import { joinWorld } from '@net-mesh/browser/world';
|
|
408
|
+
|
|
409
|
+
const world = joinWorld({
|
|
410
|
+
node, world: 'my-world', definition: region, collection: 'ships',
|
|
411
|
+
position: { x, z }, size: 256, key: 'player', maxEventBytes: 8104,
|
|
412
|
+
trustedHosts: HOSTS, positionOf: ship => ship,
|
|
413
|
+
});
|
|
414
|
+
bindEntities({ store: world, select: ships => ships, scene, binding });
|
|
415
|
+
world.setPosition(x, z); // regions come and go as you move
|
|
416
|
+
await world.act('fire', target); // goes to the region you are in
|
|
417
|
+
```
|
|
418
|
+
|
|
419
|
+
Region hosts, usually dedicated Node processes, move an entity across a border
|
|
420
|
+
**at most once**. It is frozen at the source, offered, and admitted once by the
|
|
421
|
+
destination; a crash leaves it frozen and reported, never duplicated. The
|
|
422
|
+
region hosts can also ask a neighbour to act on something it owns, and mirror
|
|
423
|
+
their border entities to it read-only. The
|
|
424
|
+
[world docs](https://ai2070.net/docs/sdk/browser/world) walk through a region
|
|
425
|
+
host.
|
|
426
|
+
|
|
427
|
+
## Reacting to players: one hook
|
|
428
|
+
|
|
429
|
+
Give the host an `onEvent` and it hears everything that happens to a player —
|
|
430
|
+
someone joins, someone leaves, someone walks into a different area. It runs on
|
|
431
|
+
the host like an action handler, so it can change the world, and every player
|
|
432
|
+
sees the change:
|
|
433
|
+
|
|
434
|
+
```js
|
|
435
|
+
const host = hostStore({
|
|
436
|
+
// …the options from step 3a…
|
|
437
|
+
areaOf: (state, peer) => (state.ships[peer]?.x ?? 0) < 50 ? 'harbour' : 'open-sea',
|
|
438
|
+
onEvent: (event, context) => {
|
|
439
|
+
const state = context.getState();
|
|
440
|
+
switch (event.type) {
|
|
441
|
+
case 'join': // a new player: a starter kit (add `inventories: {}` to your state)
|
|
442
|
+
context.setState({ inventories: { ...state.inventories,
|
|
443
|
+
[event.peer]: addItems(emptyInventory(), 'torpedo', 3) } });
|
|
444
|
+
break;
|
|
445
|
+
case 'leave': // event.reason: 'left', 'expired', 'refused' (kicked) or 'dropped'
|
|
446
|
+
break;
|
|
447
|
+
case 'area': // event.from → event.to, e.g. 'harbour' → 'open-sea'
|
|
448
|
+
break;
|
|
449
|
+
}
|
|
450
|
+
},
|
|
451
|
+
});
|
|
452
|
+
```
|
|
453
|
+
|
|
454
|
+
- `join` and `leave` are per player, and include the host's own player.
|
|
455
|
+
- `area` fires only when `areaOf`'s answer changes — the first time with
|
|
456
|
+
`from: null`. An area is any string you like: a zone, a room, a grid cell.
|
|
457
|
+
- A hook that throws changes nothing. Keep it quick and synchronous, like a
|
|
458
|
+
handler.
|
|
459
|
+
|
|
460
|
+
## Inventories (the basics)
|
|
461
|
+
|
|
462
|
+
An inventory is plain data in your world — item id → count — and the helpers
|
|
463
|
+
change it only on the host, refusing what breaks the rules:
|
|
464
|
+
|
|
465
|
+
```js
|
|
466
|
+
import { addItems, removeItems, countItems, hasItems, emptyInventory,
|
|
467
|
+
inventoryOf, onlyOwn, parseInventory } from '@net-mesh/browser';
|
|
468
|
+
|
|
469
|
+
const rules = { maxKinds: 20, maxCount: { torpedo: 10 } };
|
|
470
|
+
|
|
471
|
+
actions: {
|
|
472
|
+
fireTorpedo: (input, context) => {
|
|
473
|
+
const inventories = { ...context.getState().inventories };
|
|
474
|
+
// Throws when the player has none, which refuses the action and changes nothing.
|
|
475
|
+
inventories[context.peer] = removeItems(inventoryOf(inventories, context.peer), 'torpedo', 1);
|
|
476
|
+
context.setState({ inventories });
|
|
477
|
+
return { left: countItems(inventories[context.peer], 'torpedo') };
|
|
478
|
+
},
|
|
479
|
+
},
|
|
480
|
+
// Each player sees their own inventory and nobody else's:
|
|
481
|
+
projectFor: (state, { peer }) => ({ ...state, inventories: onlyOwn(state.inventories, peer) }),
|
|
482
|
+
```
|
|
483
|
+
|
|
484
|
+
Use `parseInventory(value, rules)` inside your store's `state` validator. Trading
|
|
485
|
+
between players is not built yet.
|
|
486
|
+
|
|
487
|
+
## Lobbies: a list, a code, a link
|
|
488
|
+
|
|
489
|
+
Steps 3a and 3b find each other by hand. A lobby does it for you: the host opens
|
|
490
|
+
one, other players pick it from a list or type its code, and the host keeps
|
|
491
|
+
playing in its own world.
|
|
492
|
+
|
|
493
|
+
```js
|
|
494
|
+
import { createLobby, listLobbies, joinLobby, lobbyCodeFromUrl } from '@net-mesh/browser';
|
|
495
|
+
|
|
496
|
+
// Host page: the same store options as step 3a, plus a game name, a lobby name and a size.
|
|
497
|
+
const lobby = await createLobby({
|
|
498
|
+
node, game: 'my-game', name: 'Friday arena', capacity: 8,
|
|
499
|
+
info: { map: 'dunes' }, // small public fields your list can show
|
|
500
|
+
definition: arena, initialState: { ships: {} },
|
|
501
|
+
actions, inputs,
|
|
502
|
+
});
|
|
503
|
+
lobby.code; // 'K7QP2M' — read it out, or share lobby.link()
|
|
504
|
+
const world = lobby.self; // the host's own player
|
|
505
|
+
lobby.subscribePlayers(players => showPlayers(players));
|
|
506
|
+
```
|
|
507
|
+
|
|
508
|
+
```js
|
|
509
|
+
// Everyone else: pick from the list…
|
|
510
|
+
const lobbies = await listLobbies({ node, game: 'my-game' });
|
|
511
|
+
// [{ code, name, players, capacity, info, host, store }]
|
|
512
|
+
const world = await joinLobby({ node, definition: arena, game: 'my-game', lobby: lobbies[0] });
|
|
513
|
+
// …or by typed code, or from the link: { …, code: 'K7QP2M' } / { …, code: lobbyCodeFromUrl() }
|
|
514
|
+
await world.ready();
|
|
515
|
+
```
|
|
516
|
+
|
|
517
|
+
- **Full lobbies turn players away** (`ready()` rejects with `forbidden`); the
|
|
518
|
+
host counts as one of `capacity`. `lobby.kick(playerId)` removes a player at
|
|
519
|
+
once and keeps them out.
|
|
520
|
+
- **`visibility: 'unlisted'`** keeps a lobby out of the list; it is joined by
|
|
521
|
+
code or link only. Unlisted is not a password — anyone with the code can
|
|
522
|
+
try, and your `authorize` decides who gets in.
|
|
523
|
+
- **A lobby list is what hosts claim about themselves.** Names and player counts
|
|
524
|
+
come from the host's own announcement; the host id does not, it is proven. If
|
|
525
|
+
two hosts claim one code, `joinLobby` refuses (`ambiguous`) rather than guess.
|
|
526
|
+
- **One lobby per node** — a lobby owns the node's announcements while it is
|
|
527
|
+
open. `lobby.close()` withdraws it.
|
|
528
|
+
- It all works on `@net-mesh/browser/local` too, so you can build the lobby
|
|
529
|
+
screen before you have an anchor.
|
|
530
|
+
|
|
531
|
+
## Things worth knowing
|
|
532
|
+
|
|
533
|
+
**Inputs vs actions.** `world.input(name, value)` never waits and never
|
|
534
|
+
answers. It tells you only what happened locally (`queued`, `replaced`, or
|
|
535
|
+
`dropped`), and a dropped input may simply be superseded by the next one — write
|
|
536
|
+
your game so that's fine. `world.act(name, input)` returns a promise with the
|
|
537
|
+
host's answer, or rejects with a reason.
|
|
538
|
+
|
|
539
|
+
**Hidden information.** Say what is secret on the definition, and the host
|
|
540
|
+
removes it from every player's copy before anything is sent — it is never in
|
|
541
|
+
their page to be found:
|
|
542
|
+
|
|
543
|
+
```js
|
|
544
|
+
import { defineStore, hiddenOr } from '@net-mesh/browser';
|
|
545
|
+
|
|
546
|
+
defineStore({
|
|
547
|
+
// …
|
|
548
|
+
visibility: {
|
|
549
|
+
'players.*.hand': 'owner', // only the player whose id is that key
|
|
550
|
+
'deck': 'nobody', // only the host
|
|
551
|
+
'deck.length': 'everyone', // …but everyone may count the cards
|
|
552
|
+
'waypoint': ['command'], // players who joined with the 'command' audience
|
|
553
|
+
},
|
|
554
|
+
// A hidden field arrives as the HIDDEN marker, so let your validator accept it:
|
|
555
|
+
state: v => ({ /* … */ hand: hiddenOr(parseHand)(v.hand) }),
|
|
556
|
+
});
|
|
557
|
+
```
|
|
558
|
+
|
|
559
|
+
- **`'owner'` needs no setup**: the key matched by the first `*` is the player's
|
|
560
|
+
id, which is how games already key players (`ships[context.peer]`).
|
|
561
|
+
- **Hidden entries of a collection are removed; hidden fields become `HIDDEN`**
|
|
562
|
+
(check with `isHidden(value)`), never a fake `0` or `""` your game would believe.
|
|
563
|
+
- **Presets**: `visibility: 'open'` says "everyone sees everything" on purpose,
|
|
564
|
+
and `'card-game'` hides hands (owner) and the deck (nobody, count visible).
|
|
565
|
+
Add overrides: `{ preset: 'card-game', 'players.*.score': 'nobody' }`.
|
|
566
|
+
- **Prove it in a test** with `assertHidden(definition, state, { peer, audience },
|
|
567
|
+
['players.bob.hand'])`, and pass `dev: true` to `hostStore` while developing:
|
|
568
|
+
it warns if every player is getting the whole world without you saying so.
|
|
569
|
+
- **The host sees everything.** If the host is a player, that player can read
|
|
570
|
+
every secret in their own browser. Games with real stakes need a host that
|
|
571
|
+
isn't a player: run the same `hostStore` on a server with `@net-mesh/sdk`'s
|
|
572
|
+
`meshStoreTransport` — your pages join it exactly as they join a player's.
|
|
573
|
+
|
|
574
|
+
For views the rules can't express, write the projection yourself:
|
|
575
|
+
`project(state, audience)` (one view per audience) or `projectFor(state, { peer,
|
|
576
|
+
audience })` (one per player). The declared rules still apply after it, so your
|
|
577
|
+
code can hide more but never reveal a declared secret.
|
|
578
|
+
|
|
579
|
+
**One tab per player.** All tabs of the same site in one browser profile share a
|
|
580
|
+
single identity, so two tabs are the *same* player — and a player can't join
|
|
581
|
+
itself. To test with two players on one machine, use two browser profiles, or
|
|
582
|
+
two different browsers.
|
|
583
|
+
|
|
584
|
+
**Announcements expire.** A host that announces once disappears from searches
|
|
585
|
+
a few seconds later. Keep re-announcing on a timer, as in step 3a.
|
|
586
|
+
|
|
587
|
+
**Several worlds on one host.** A host can run a lobby and a match at the same
|
|
588
|
+
time: give each `hostStore` a `store: 'lobby'` / `store: 'match'` name, and pass
|
|
589
|
+
the same name to `joinStore`.
|
|
590
|
+
|
|
591
|
+
**Cleaning up.** `ships.dispose()` removes everything the binding added to the
|
|
592
|
+
scene. `world.close()` leaves a world, `host.close()` stops hosting, and
|
|
593
|
+
`node.close()` disconnects.
|
|
594
|
+
|
|
595
|
+
---
|
|
596
|
+
|
|
597
|
+
## When something goes wrong
|
|
598
|
+
|
|
599
|
+
A refused store operation rejects with a `StoreError`. Check its `.code`:
|
|
600
|
+
|
|
601
|
+
| `code` | What it means |
|
|
602
|
+
|---|---|
|
|
603
|
+
| `forbidden` | Your `authorize` said no |
|
|
604
|
+
| `action-rejected` | The host refused the action — your handler threw |
|
|
605
|
+
| `invalid-data` | A message didn't pass the definition's validators |
|
|
606
|
+
| `version-mismatch` | Host and player were built from different game versions |
|
|
607
|
+
| `not-ready` | There's no synchronized copy yet (before `ready()`, or while reconnecting) |
|
|
608
|
+
| `timeout` | Your deadline passed before an answer arrived |
|
|
609
|
+
| `aborted` | The world handle closed, or a newer request replaced this one |
|
|
610
|
+
| `indeterminate` | No answer came back — the action *may* have happened. Don't retry blindly |
|
|
611
|
+
| `owner-lost` | The host stopped hosting this world. Join a new one |
|
|
612
|
+
| `closed` | This connection to the world can't be used any more — join again |
|
|
613
|
+
| `capacity` | A limit was reached (too much in flight at once) |
|
|
614
|
+
| `result-expired` | The request can't be run again and its original result is gone. It does **not** mean it succeeded |
|
|
615
|
+
|
|
616
|
+
Connection problems reject with a `LeafError` whose `.kind` says what happened —
|
|
617
|
+
for example `rpc-timeout`, `session-lost`, or `udp-blocked` (the player's network
|
|
618
|
+
blocks the direct connection). The
|
|
619
|
+
[errors guide](https://ai2070.net/docs/sdk/browser/errors) lists them all.
|
|
620
|
+
|
|
621
|
+
---
|
|
622
|
+
|
|
623
|
+
## Loading the WebAssembly
|
|
624
|
+
|
|
625
|
+
The package includes a small WebAssembly module, and by default loads it from
|
|
626
|
+
the same place as the package's JavaScript. If your build serves it from
|
|
627
|
+
somewhere else, say where:
|
|
628
|
+
|
|
629
|
+
```js
|
|
630
|
+
await connect({ credentialB64, wasmUrl: '/assets/net_leaf_bg.wasm' });
|
|
631
|
+
```
|
|
632
|
+
|
|
633
|
+
A page downloads about **240 kB** gzipped in total.
|
|
634
|
+
|
|
635
|
+
---
|
|
636
|
+
|
|
637
|
+
## Beyond games
|
|
638
|
+
|
|
639
|
+
The same connection also gives you request/response calls between players,
|
|
640
|
+
publish/subscribe channels, raw streams, and a mode where all tabs of a site
|
|
641
|
+
share one connection. See the [Browser SDK docs](https://ai2070.net/docs/sdk/browser).
|
|
642
|
+
|
|
643
|
+
## Learn more
|
|
644
|
+
|
|
645
|
+
- [Browser quickstart](https://ai2070.net/docs/sdk/browser/quickstart) — connecting, step by step
|
|
646
|
+
- [The store](https://ai2070.net/docs/sdk/browser/store) — hosting, joining, audiences, every option
|
|
647
|
+
- [Netcode](https://ai2070.net/docs/sdk/browser/netcode) — prediction, interpolation, lag compensation
|
|
648
|
+
- [Worlds](https://ai2070.net/docs/sdk/browser/world) — regions, region hosts, handoff across borders
|
|
649
|
+
- [Three.js](https://ai2070.net/docs/sdk/browser/three) — the binding in depth, and disposing resources properly
|
|
650
|
+
- [Errors](https://ai2070.net/docs/sdk/browser/errors) — every error and what to do about it
|
|
651
|
+
- [The demo](https://github.com/ai-2070/net/tree/master/net/crates/net/browser-ts/demo) — a small playable game using all of the above
|
|
652
|
+
|
|
653
|
+
## Working on this package
|
|
654
|
+
|
|
655
|
+
```sh
|
|
656
|
+
npm install
|
|
657
|
+
npm run build # needs the wasm leaf built first — see DESIGN.md
|
|
658
|
+
npm test
|
|
659
|
+
```
|
|
660
|
+
|
|
661
|
+
How the package is built, and why it works the way it does, is in
|
|
662
|
+
[DESIGN.md](https://github.com/ai-2070/net/blob/master/net/crates/net/browser-ts/DESIGN.md).
|
|
663
|
+
|
|
664
|
+
## License
|
|
665
|
+
|
|
666
|
+
MIT OR Apache-2.0
|
|
@@ -0,0 +1,26 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* The one-consumer async queue behind every async iterable in this
|
|
3
|
+
* package (`node.events()`, `stream`).
|
|
4
|
+
*
|
|
5
|
+
* Items that arrive while the consumer is not awaiting are buffered in
|
|
6
|
+
* arrival order; buffering stops the moment the loop is left (`break`,
|
|
7
|
+
* `return`, or an exception), which is what calls the `detach` hook, so
|
|
8
|
+
* a page that stops consuming stops paying. A page that starts a loop
|
|
9
|
+
* and then never awaits it will grow the buffer — leave the loop
|
|
10
|
+
* instead of abandoning it.
|
|
11
|
+
*/
|
|
12
|
+
export declare class AsyncQueue<T> implements AsyncIterableIterator<T> {
|
|
13
|
+
private readonly detach;
|
|
14
|
+
private readonly buffered;
|
|
15
|
+
private waiting;
|
|
16
|
+
private ended;
|
|
17
|
+
/** @param detach called once when the consumer stops iterating. */
|
|
18
|
+
constructor(detach?: () => void);
|
|
19
|
+
/** Hand an item to the consumer, or buffer it. */
|
|
20
|
+
push(item: T): void;
|
|
21
|
+
/** End the iteration once the buffer drains. */
|
|
22
|
+
end(): void;
|
|
23
|
+
next(): Promise<IteratorResult<T>>;
|
|
24
|
+
return(): Promise<IteratorResult<T>>;
|
|
25
|
+
[Symbol.asyncIterator](): AsyncIterableIterator<T>;
|
|
26
|
+
}
|