@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.
Files changed (110) hide show
  1. package/CHANGELOG.md +517 -0
  2. package/LICENSE-APACHE +190 -0
  3. package/LICENSE-MIT +21 -0
  4. package/README.md +666 -0
  5. package/dist/async-queue.d.ts +26 -0
  6. package/dist/async-queue.js +62 -0
  7. package/dist/credential.d.ts +70 -0
  8. package/dist/credential.js +95 -0
  9. package/dist/errors.d.ts +400 -0
  10. package/dist/errors.js +726 -0
  11. package/dist/events.d.ts +232 -0
  12. package/dist/events.js +298 -0
  13. package/dist/identity.d.ts +40 -0
  14. package/dist/identity.js +78 -0
  15. package/dist/index.bundle.js +2 -0
  16. package/dist/index.d.ts +38 -0
  17. package/dist/index.js +46 -0
  18. package/dist/leader/events.d.ts +111 -0
  19. package/dist/leader/events.js +116 -0
  20. package/dist/leader/index.d.ts +14 -0
  21. package/dist/leader/index.js +11 -0
  22. package/dist/leader/session.d.ts +539 -0
  23. package/dist/leader/session.js +770 -0
  24. package/dist/leader/wasm.d.ts +123 -0
  25. package/dist/leader/wasm.js +39 -0
  26. package/dist/lobby.d.ts +183 -0
  27. package/dist/lobby.js +504 -0
  28. package/dist/local.d.ts +80 -0
  29. package/dist/local.js +160 -0
  30. package/dist/net_leaf.d.ts +1494 -0
  31. package/dist/net_leaf.js +3753 -0
  32. package/dist/net_leaf_bg.wasm +0 -0
  33. package/dist/net_leaf_bg.wasm.d.ts +137 -0
  34. package/dist/netcode/client.d.ts +102 -0
  35. package/dist/netcode/client.js +296 -0
  36. package/dist/netcode/clock.d.ts +45 -0
  37. package/dist/netcode/clock.js +54 -0
  38. package/dist/netcode/host.d.ts +118 -0
  39. package/dist/netcode/host.js +276 -0
  40. package/dist/netcode/index.d.ts +19 -0
  41. package/dist/netcode/index.js +14 -0
  42. package/dist/netcode/interpolate.d.ts +50 -0
  43. package/dist/netcode/interpolate.js +100 -0
  44. package/dist/netcode/wire.d.ts +125 -0
  45. package/dist/netcode/wire.js +117 -0
  46. package/dist/node.d.ts +908 -0
  47. package/dist/node.js +1085 -0
  48. package/dist/org.d.ts +412 -0
  49. package/dist/org.js +567 -0
  50. package/dist/peer-driver.d.ts +117 -0
  51. package/dist/peer-driver.js +224 -0
  52. package/dist/store/assembly.d.ts +176 -0
  53. package/dist/store/assembly.js +325 -0
  54. package/dist/store/chunker.d.ts +83 -0
  55. package/dist/store/chunker.js +190 -0
  56. package/dist/store/core.d.ts +136 -0
  57. package/dist/store/core.js +477 -0
  58. package/dist/store/definition.d.ts +23 -0
  59. package/dist/store/definition.js +83 -0
  60. package/dist/store/errors.d.ts +77 -0
  61. package/dist/store/errors.js +94 -0
  62. package/dist/store/host.d.ts +247 -0
  63. package/dist/store/host.js +675 -0
  64. package/dist/store/index.d.ts +50 -0
  65. package/dist/store/index.js +40 -0
  66. package/dist/store/interest.d.ts +40 -0
  67. package/dist/store/interest.js +77 -0
  68. package/dist/store/inventory.d.ts +81 -0
  69. package/dist/store/inventory.js +140 -0
  70. package/dist/store/join.d.ts +118 -0
  71. package/dist/store/join.js +724 -0
  72. package/dist/store/json.d.ts +50 -0
  73. package/dist/store/json.js +283 -0
  74. package/dist/store/ledger.d.ts +187 -0
  75. package/dist/store/ledger.js +292 -0
  76. package/dist/store/owner.d.ts +616 -0
  77. package/dist/store/owner.js +1711 -0
  78. package/dist/store/patch.d.ts +70 -0
  79. package/dist/store/patch.js +166 -0
  80. package/dist/store/player.d.ts +65 -0
  81. package/dist/store/player.js +261 -0
  82. package/dist/store/replica.d.ts +187 -0
  83. package/dist/store/replica.js +740 -0
  84. package/dist/store/state.d.ts +42 -0
  85. package/dist/store/state.js +165 -0
  86. package/dist/store/types.d.ts +200 -0
  87. package/dist/store/types.js +25 -0
  88. package/dist/store/visibility.d.ts +118 -0
  89. package/dist/store/visibility.js +282 -0
  90. package/dist/store/wire.d.ts +309 -0
  91. package/dist/store/wire.js +834 -0
  92. package/dist/stream.d.ts +174 -0
  93. package/dist/stream.js +238 -0
  94. package/dist/three/index.d.ts +109 -0
  95. package/dist/three/index.js +130 -0
  96. package/dist/udp-probe.d.ts +159 -0
  97. package/dist/udp-probe.js +245 -0
  98. package/dist/wasm.d.ts +642 -0
  99. package/dist/wasm.js +98 -0
  100. package/dist/world/border.d.ts +84 -0
  101. package/dist/world/border.js +99 -0
  102. package/dist/world/handoff.d.ts +187 -0
  103. package/dist/world/handoff.js +222 -0
  104. package/dist/world/index.d.ts +13 -0
  105. package/dist/world/index.js +9 -0
  106. package/dist/world/link.d.ts +179 -0
  107. package/dist/world/link.js +476 -0
  108. package/dist/world/regions.d.ts +154 -0
  109. package/dist/world/regions.js +314 -0
  110. package/package.json +72 -0
package/README.md ADDED
@@ -0,0 +1,666 @@
1
+ # @net-mesh/browser
2
+
3
+ [![npm](https://img.shields.io/npm/v/@net-mesh/browser.svg)](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
+ }