@homie-rocks/studio 0.43.0 → 0.44.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 CHANGED
@@ -17,9 +17,25 @@ To bring a studio up to date, tell Claude: "Upgrade my studio to the newest Homi
17
17
  `npx -y @homie-rocks/studio@latest upgrade`, which shows what's new since the version your studio pins (from this
18
18
  file) and what the upgrade would change, and changes nothing until you agree.
19
19
 
20
+ ## [0.44.0] - 2026-10-10
21
+
22
+ **Plugin 0.44.0** · [#83](https://github.com/homie-rocks/homie/pull/83)
23
+
24
+ Send each player the part of the room near their character.
25
+
26
+ ### Added
27
+
28
+ - Server-hosted rules games can choose `room.view.radiusM` in game.json. Views receive nearby entities and their own body, with compact state updates and fresh state on reconnect. Omit it to keep the whole-room view.
29
+ - Seeded tests cover spatial visibility, delta recovery and 300 simulated clients through the host and relay. The longer capacity workload runs in the extended suite.
30
+
31
+ ### Upgrade notes
32
+
33
+ - `room.each`, `enter` and `leave` describe visible entities when a radius is set. Dispose of departed meshes and recreate them when they return. Rules and movement need no changes; existing starters keep their whole-room views.
34
+ - This is the first bigger-rooms slice. The public build/join path still has 32 seats until Gates ship in the next slice. The 300-client proof is local, not a Cloudflare capacity measurement. Interest filtering does not hide secrets; private fields and tells follow later.
35
+
20
36
  ## [0.43.0] - 2026-10-09
21
37
 
22
- **Plugin 0.43.0**
38
+ **Plugin 0.43.0** · [release-2026-10-10-studio-0.43.0](https://github.com/homie-rocks/homie/releases/tag/release-2026-10-10-studio-0.43.0)
23
39
 
24
40
  Ask for a game and its rules run on your server.
25
41
 
package/README.md CHANGED
@@ -1259,3 +1259,17 @@ Homie as a short note you see and send yourself (`homie_feedback`, above).
1259
1259
  ## License
1260
1260
 
1261
1261
  Apache-2.0. See `LICENSE` and `NOTICE`.
1262
+
1263
+
1264
+ ### Spatial state delivery
1265
+
1266
+ Server-hosted rules games may set `room.view.radiusM` in game.json to send each
1267
+ player nearby entities and its own body. Omitted or null sends the whole room.
1268
+ Rules and movement stay the same; the view's `each`, `enter` and `leave` describe
1269
+ the visible set. Dispose of meshes on leave and recreate them on enter.
1270
+ Watchers, shared state, effects and rosters remain public. This is bandwidth
1271
+ filtering, not private state. Browser-hosted rooms send the whole room.
1272
+
1273
+ 0.44.0 delivers this first part of bigger rooms. Public admission above 32 seats
1274
+ arrives with Gates in the next slice. See [the milestone 2 notes](../../docs/rooms-milestone-2-notes.md)
1275
+ for the remaining slices and the local 300-client proof.
@@ -2385,6 +2385,47 @@
2385
2385
  }
2386
2386
  }
2387
2387
  }
2388
+ },
2389
+ "0.44.0": {
2390
+ "files": {
2391
+ "CLAUDE.md": "ddf2b2a0376202f6",
2392
+ "README.md": "684b3cca8001673c",
2393
+ "HANDOFF.md": "29337e2ace95b2e7",
2394
+ "games/README.md": "5ad32fe80c61f117",
2395
+ "music/README.md": "a3dc042dadd39df8",
2396
+ "videos/README.md": "16babc3437d5545b",
2397
+ "posts/README.md": "37841aae476bd540",
2398
+ "site/README.md": "732894273a32dc3f",
2399
+ "site/src/worker.mjs": "34444a6ca4b14437"
2400
+ },
2401
+ "sections": {
2402
+ "AGENTS.md": {
2403
+ "(intro)": "214aa46eb3a895d5",
2404
+ "## Layout": "f3a280b8c96cd068",
2405
+ "## Apps: one screen, roles and parts": "8596f2b25edda3e2",
2406
+ "## Commands (all through the pinned CLI in node_modules)": "159151b8861fc051",
2407
+ "## The site": "0cb8dd9a6f8a0096",
2408
+ "## Making games": "9e44559de535d842",
2409
+ "## The Game Codex and progress": "0738ef719cef3a4c",
2410
+ "## Art direction and models": "a48fff7bf0a5f2fc",
2411
+ "## Running live games (the back office)": "51ebf2ab756f6e4d",
2412
+ "## Servers and AI seats": "d0028361fe567add",
2413
+ "## Selling things (the shop)": "9e020d69dd6a954e",
2414
+ "## Continuing a build from the Claude app": "0d68081e924ca419",
2415
+ "## Rules": "2e6b29afb85dcf7e",
2416
+ "## Telling Homie": "80b46fdd9e578db8",
2417
+ "## Beta": "9d02638207a26e73"
2418
+ }
2419
+ },
2420
+ "named": {
2421
+ "Homie Arcade": {
2422
+ "sections": {
2423
+ "AGENTS.md": {
2424
+ "## Commands (all through the pinned CLI in node_modules)": "2cdd530e48170bfd"
2425
+ }
2426
+ }
2427
+ }
2428
+ }
2388
2429
  }
2389
2430
  }
2390
2431
  }
@@ -2628,6 +2628,9 @@ export function createNetplay<S = unknown, A = unknown, C = unknown>(opts: Netpl
2628
2628
  setRole(next, wasOffline && roleKnown ? 'reconnected' : String(m['why'] ?? 'welcome'), { ckpt, snap, round: roundInfo, roster: slots });
2629
2629
  if (next === 'host' && snap) tick = Math.max(tick, snap.k);
2630
2630
  }
2631
+ // A reconnect to the same role emits no role event, but its welcome is a new
2632
+ // connection's keyframe. Rules views must adopt it before the first delta.
2633
+ if (rulesGame && snap && next !== 'host') emit('snapshot', snap);
2631
2634
  if (continuing) { rulesHost?.sync(rulesPeers()); reannounce(); catchUp(had, hadSeat); }
2632
2635
  if (link === 'online') paintLink(); else setLink('online', 'welcome');
2633
2636
  // A build older than the live one, let in to its own room (section 23).
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@homie-rocks/studio",
3
- "version": "0.43.0",
3
+ "version": "0.44.0",
4
4
  "description": "A studio for games, music and video on its own Cloudflare: scaffold, multiplayer starters on the netplay contract, a port toolkit that makes existing single-player web games multiplayer, the site Worker with public rooms (Table/Lobby Durable Objects and D1, all on the free Workers plan, no payment method), a site with the hub's sections (Home, Games, Music, Videos, Rooms, Posts with feeds) in the studio's own look, an epic landing page for every game, big songs and videos in the studio's own R2 once it has storage (uploaded, checked by SHA-256, served at the same addresses), deploy, and the homie.rocks directory.",
5
5
  "type": "module",
6
6
  "license": "Apache-2.0",
package/rules/host.ts CHANGED
@@ -99,6 +99,7 @@ export interface Host {
99
99
  readonly running: boolean;
100
100
  readonly paused: boolean;
101
101
  readonly people: number;
102
+ readonly viewRadiusM: number | null;
102
103
  frame(m: Record<string, unknown>): void;
103
104
  /** Start the tick loop (the first person has joined). */
104
105
  start(): void;
@@ -767,6 +768,7 @@ export function createHost(o: HostOptions): Host {
767
768
  }
768
769
 
769
770
  return {
771
+ get viewRadiusM() { return c.settings.view?.radiusM ?? null; },
770
772
  get tick() { return core.tick; },
771
773
  get epoch() { return epoch; },
772
774
  get running() { return running; },
@@ -0,0 +1,79 @@
1
+ /** Per-connection state delivery. Pure data, shared by the relay and the view.
2
+ * Deltas name a keyframe, never an unacknowledged previous delta. Losing a delta
3
+ * therefore loses no baseline. A new keyframe bounds recovery to one second.
4
+ * No history grows with time: one keyframe per connection, no per-frame queue.
5
+ */
6
+ export function interestSnapshot(snap, seat, radiusM) {
7
+ const [round, entities] = snap.d;
8
+ const own = seat === null ? null : entities.find(e => e[9] === seat && e[10] === 0);
9
+ const visible = own && radiusM !== null ? entities.filter(e => {
10
+ const p = e[3], q = own[3];
11
+ return e === own || (p[0] - q[0]) ** 2 + (p[1] - q[1]) ** 2 + ((p[2] ?? 0) - (q[2] ?? 0)) ** 2 <= radiusM ** 2;
12
+ }) : seat === null ? entities : own ? entities : [];
13
+ return { ...snap, d: [round, visible], c: (snap.c ?? []).filter(row => row[0] === seat) };
14
+ }
15
+
16
+ export function snapshotEncoder(keyframeTicks) {
17
+ let base = null;
18
+ let rows = new Map();
19
+ return {
20
+ reset() { base = null; rows.clear(); },
21
+ encode(snap) {
22
+ if (!base || snap.e !== base.e || snap.k <= base.k || snap.k - base.k >= keyframeTicks) {
23
+ base = snap;
24
+ rows = new Map(snap.d[1].map(e => [e[0], e.map(v => JSON.stringify(v))]));
25
+ return snap;
26
+ }
27
+ const present = new Set(), changed = [];
28
+ for (const entity of snap.d[1]) {
29
+ const id = entity[0]; present.add(id);
30
+ const before = rows.get(id);
31
+ if (!before) { changed.push(entity); continue; }
32
+ let mask = 0; const values = [];
33
+ for (let i = 1; i < entity.length; i++) if (JSON.stringify(entity[i]) !== before[i]) { mask |= 1 << i; values.push(entity[i]); }
34
+ if (mask) changed.push([id, mask, values]);
35
+ }
36
+ return { ...snap, d: { base: base.k, round: snap.d[0], changed, removed: [...rows.keys()].filter(id => !present.has(id)) } };
37
+ },
38
+ };
39
+ }
40
+
41
+ export function snapshotDecoder() {
42
+ let base = null;
43
+ let rows = new Map();
44
+ return {
45
+ reset() { base = null; rows.clear(); },
46
+ decode(snap) {
47
+ if (!snap || typeof snap !== 'object') return null;
48
+ if (Array.isArray(snap.d)) {
49
+ if (!Array.isArray(snap.d[0]) || !Array.isArray(snap.d[1]) ||
50
+ !snap.d[1].every(e => Array.isArray(e) && e.length >= 9 && typeof e[0] === 'string')) return null;
51
+ // A late duplicate keyframe must not replace the newer baseline.
52
+ if (!base || snap.e !== base.e || snap.k >= base.k) {
53
+ base = snap; rows = new Map(snap.d[1].map(e => [e[0], e]));
54
+ }
55
+ return snap;
56
+ }
57
+ const d = snap.d;
58
+ if (!base || !d || d.base !== base.k || snap.e !== base.e || snap.k <= base.k) return null;
59
+ if (!Array.isArray(d.round) || !Array.isArray(d.removed) || !Array.isArray(d.changed)) return null;
60
+ const next = new Map(rows);
61
+ for (const id of d.removed) next.delete(id);
62
+ for (const change of d.changed) {
63
+ if (!Array.isArray(change) || typeof change[0] !== 'string') return null;
64
+ if (change.length !== 3) {
65
+ if (change.length < 9) return null;
66
+ next.set(change[0], change); continue;
67
+ }
68
+ if (!Number.isInteger(change[1]) || change[1] < 0 || !Array.isArray(change[2])) return null;
69
+ const old = rows.get(change[0]);
70
+ if (!old) return null;
71
+ const entity = old.slice(); let j = 0;
72
+ for (let i = 1; i < entity.length; i++) if (change[1] & (1 << i)) entity[i] = change[2][j++];
73
+ if (j !== change[2].length) return null;
74
+ next.set(entity[0], entity);
75
+ }
76
+ return { ...snap, d: [d.round, [...next.values()]] };
77
+ },
78
+ };
79
+ }
package/rules/rules.ts CHANGED
@@ -37,7 +37,7 @@ export const SAVE_REVISION = 2;
37
37
  export const REACH_M = 64;
38
38
  /** The most entities one room holds. A spawn past it throws in the handler that asked. */
39
39
  export const ENTITY_MAX = 2048;
40
- /** The most seats one room holds (worker/seats.mjs `SEAT_MAX`). */
40
+ /** The current bot-fill ceiling; public admission lives in worker/seats.mjs. */
41
41
  export const SEATS_MAX = 32;
42
42
  /** The most values one declared field may hold when it is full (`cellsOf`), and the most all the fields of one kind of entity may. */
43
43
  export const FIELD_CELLS_MAX = 16_384;
@@ -176,6 +176,8 @@ export interface RoomSettings {
176
176
  offline: boolean;
177
177
  tickHz: number;
178
178
  inputHz: number;
179
+ /** Omit the radius to send the whole room. No implicit visibility or bandwidth cap. */
180
+ view: { radiusM: number | null };
179
181
  durability: { movementSeconds: number };
180
182
  budget: { tick: number };
181
183
  predict: { catchM: number | null; catchUp: number; snapM: number | null; blendMs: number; interpMs: number | null };
@@ -191,7 +193,7 @@ export const BUDGET_TICK = 500_000;
191
193
  export const BUDGET_SECOND = 10_000_000;
192
194
  export const budgetFor = (tickHz: number): number => Math.min(BUDGET_TICK, Math.floor(BUDGET_SECOND / tickHz));
193
195
  export const ROOM_DEFAULTS: RoomSettings = deepFreeze({
194
- host: 'server', offline: true, tickHz: 20, inputHz: 20, durability: { movementSeconds: 1 }, budget: { tick: BUDGET_TICK },
196
+ host: 'server', offline: true, tickHz: 20, inputHz: 20, view: { radiusM: null }, durability: { movementSeconds: 1 }, budget: { tick: BUDGET_TICK },
195
197
  predict: { catchM: null, catchUp: 1.25, snapM: null, blendMs: 100, interpMs: null },
196
198
  });
197
199
 
@@ -212,18 +214,24 @@ export function roomSettings(raw: unknown): { settings: RoomSettings; problems:
212
214
  if (!Number.isFinite(n) || n < 0) { problems.push(`"room.${name}" is a number, zero or more; using its default`); return d; }
213
215
  return n;
214
216
  };
215
- const known = new Set(['host', 'offline', 'tickHz', 'inputHz', 'durability', 'budget', 'predict']);
216
- for (const key of Object.keys(r)) if (!known.has(key)) problems.push(`"room.${key}" is not a setting (host, offline, tickHz, inputHz, durability, budget, predict)`);
217
+ const known = new Set(['host', 'offline', 'tickHz', 'inputHz', 'durability', 'budget', 'predict', 'view']);
218
+ for (const key of Object.keys(r)) if (!known.has(key)) problems.push(`"room.${key}" is not a setting (host, offline, tickHz, inputHz, durability, budget, predict, view)`);
217
219
  let host: 'server' | 'browser' = 'server';
218
220
  if (r.host !== undefined) { if (r.host === 'server' || r.host === 'browser') host = r.host; else problems.push('"room.host" is "server" or "browser"; using "server"'); }
219
221
  let offline = true;
220
222
  if (r.offline !== undefined) { if (typeof r.offline === 'boolean') offline = r.offline; else problems.push('"room.offline" is true or false; using true'); }
221
223
  const tickHz = whole('tickHz', r.tickHz, 1, 60, 20);
222
224
  const inputHz = whole('inputHz', r.inputHz, 1, tickHz, tickHz);
225
+ if (host === 'browser' && r.view?.radiusM != null) problems.push('"room.view.radiusM" needs room.host "server"; browser hosting sends the whole room');
226
+ if (r.view !== undefined) {
227
+ if (!r.view || typeof r.view !== 'object' || Array.isArray(r.view)) problems.push('"room.view" is an object with an optional radiusM');
228
+ else for (const key of Object.keys(r.view)) if (key !== 'radiusM') problems.push(`"room.view.${key}" is not a setting; use radiusM`);
229
+ }
223
230
  const p = r.predict && typeof r.predict === 'object' ? r.predict : {};
224
231
  return {
225
232
  settings: {
226
233
  host, offline, tickHz, inputHz,
234
+ view: { radiusM: r.view?.radiusM === null ? null : num('view.radiusM', r.view?.radiusM, null) },
227
235
  durability: { movementSeconds: whole('durability.movementSeconds', r.durability?.movementSeconds, 1, 60, 1) },
228
236
  budget: { tick: Math.max(1, Math.floor(num('budget.tick', r.budget?.tick, budgetFor(tickHz)) as number)) },
229
237
  predict: { catchM: num('predict.catchM', p.catchM, null), catchUp: num('predict.catchUp', p.catchUp, 1.25) as number, snapM: num('predict.snapM', p.snapM, null), blendMs: num('predict.blendMs', p.blendMs, 100) as number, interpMs: num('predict.interpMs', p.interpMs, null) },
@@ -540,7 +548,7 @@ export function compileRules(def: RulesDef, env: CompileEnv = {}): Compiled {
540
548
  if (!a.questions || typeof a.questions !== 'object') throw new Error(`asks.${key}.questions is required; for example { advance: { type: 'noul', instructions: 'Should the party advance?' } }`);
541
549
  asks[key] = { ...a, stateFields: fieldList(a.state, `asks.${key}.state`) };
542
550
  }
543
- const seats = Math.max(1, Math.min(SEATS_MAX, Math.floor(Number(env.seats)) || 8));
551
+ const seats = Math.max(1, Math.min(Number.MAX_SAFE_INTEGER, Math.floor(Number(env.seats)) || 8));
544
552
  return {
545
553
  contract: RULES_CONTRACT, save: SAVE_REVISION, dims, kinds, kindOf, events, commands, effects, effectNames: Object.keys(effects),
546
554
  shared: fieldList(d.shared, 'shared'), view, rounds, bots: Math.min(keep, seats), start: room.start ?? null, join: room.join ?? null, roomOn, asks,
package/rules/view.ts CHANGED
@@ -1,3 +1,4 @@
1
+ import { snapshotDecoder } from './interest.mjs';
1
2
  import { rulesOutput } from '../netplay/rules-output.mjs';
2
3
  import { rulesCaps, rulesRates } from '../worker/limits.mjs';
3
4
  /*
@@ -162,11 +163,12 @@ export function openRoom<R = unknown>(opts: OpenRoomOptions = {}): Room<R> {
162
163
 
163
164
  /* ---------------------------------------------------------------- frames in */
164
165
 
166
+ const delivery = snapshotDecoder();
165
167
  function frameOf(s: Snapshot<unknown> | null): Frame | null {
166
168
  if (!s || typeof s !== 'object') return null;
167
169
  if (frames.has(s)) return frames.get(s) ?? null;
168
170
  let f: Frame | null = null;
169
- const d = s.d as [unknown, unknown] | null;
171
+ const d = delivery.decode(s)?.d as [unknown, unknown] | null;
170
172
  if (Array.isArray(d) && Array.isArray(d[0]) && Array.isArray(d[1])) {
171
173
  const ents = new Map<string, Unpacked>();
172
174
  for (const w of d[1] as unknown[]) { const e = unpackEntity(schema.kinds, w, dims); if (e) ents.set(e.id, e); }
package/worker/room.mjs CHANGED
@@ -1,3 +1,4 @@
1
+ import { interestSnapshot, snapshotEncoder } from '../rules/interest.mjs';
1
2
  /*
2
3
  * room.mjs — the netplay v1 relay semantics, with no transport in it.
3
4
  * =============================================================================
@@ -460,7 +461,7 @@ export class NetRoom {
460
461
  hostSnap(c, m, now, text = null) {
461
462
  // The server's stamp is this relay's own clock; a browser's is its estimate of it, and is checked.
462
463
  const snap = { k: Number(m.k) || 0, st: c ? this.stamp(m.st) : Number(m.st) || now, d: m.d ?? null };
463
- if (Array.isArray(m.c)) snap.c = m.c.slice(0, 64);
464
+ if (Array.isArray(m.c)) snap.c = m.c.slice(0, this.server ? this.seatCap : 64);
464
465
  // Revision 10: a rules game's snapshot names the room's epoch.
465
466
  if ((!c || this.rules && c.rules) && Number.isFinite(m.e)) snap.e = m.e;
466
467
  this.lastSnap = snap;
@@ -477,10 +478,19 @@ export class NetRoom {
477
478
  if (this.lite(o)) continue; // an agent with no game client draws nothing
478
479
  // A congested socket skips a snapshot rather than queueing a stale one.
479
480
  if (o.conn.buffered && o.conn.buffered() > 256 * 1024) { this.stats.drops += 1; continue; }
480
- this.sendText(o.conn, out);
481
+ this.sendText(o.conn, this.server?.viewRadiusM != null ? JSON.stringify({ t: 'snap', from: null, ...this.playerSnapshot(o, snap) }) : out);
481
482
  }
482
483
  }
483
484
 
485
+ /** The same per-player delivery role will run in Gates. Keyframes belong to sockets, not saved seats. */
486
+ playerSnapshot(client, snap, welcome = false) {
487
+ if (!snap || this.server?.viewRadiusM == null) return snap;
488
+ const selected = interestSnapshot(snap, client.seat, this.server.viewRadiusM);
489
+ client.snapEncoder ??= snapshotEncoder(this.tickHz);
490
+ if (welcome) client.snapEncoder.reset();
491
+ return client.snapEncoder.encode(selected);
492
+ }
493
+
484
494
  /** One key of the slow state channel from the host. `bytes`: the frame's size, counted against the room's cap for a browser host. */
485
495
  hostState(c, m, bytes) {
486
496
  const key = typeof m.k === 'string' ? m.k.slice(0, 64) : '';
@@ -530,7 +540,7 @@ export class NetRoom {
530
540
  hostRoster(c, m) {
531
541
  if (!Array.isArray(m.slots)) return;
532
542
  // A host cannot hide an AI: a seat an agent holds is named and marked so, a slot with no seat is a bot.
533
- this.lastRoster = this.labelRoster(m.slots.slice(0, 64));
543
+ this.lastRoster = this.labelRoster(m.slots.slice(0, this.server ? this.seatCap : 64));
534
544
  this.persistDirty = true;
535
545
  for (const o of (c && this.rules && c.rules ? this.live() : this.others(c))) this.send(o, { t: 'roster', slots: this.lastRoster });
536
546
  this.tellWatchers();
@@ -1102,7 +1112,7 @@ export class NetRoom {
1102
1112
  t: 'welcome', v: NET_VERSION, rev: NET_REVISION, id: c.id, room: this.code, seat: c.seat, token: c.token, name: c.name, colour: c.colour,
1103
1113
  ...(this.gameVer ? { ver: this.gameVer } : {}), ...(behind ? { stale: { ver: this.currentVer } } : {}), stall: this.stallMs,
1104
1114
  role, why, host: this.hostRef(), peers: this.peers(), ...(this.rules ? { held: this.heldPeers() } : {}), st: now, max: this.maxPlayers,
1105
- round: this.lastRound, roster: this.lastRoster, snap: lite ? null : this.lastSnap, state: lite ? {} : this.stateObject(),
1115
+ round: this.lastRound, roster: this.lastRoster, snap: lite ? null : this.playerSnapshot(c, this.lastSnap, true), state: lite ? {} : this.stateObject(),
1106
1116
  ...(role === 'host' ? { ckpt: this.lastCkpt } : {}),
1107
1117
  ...(full ? { full: true } : {}),
1108
1118
  ...(this.liveAnnouncement() ? { announce: this.liveAnnouncement() } : {}),
@@ -1803,7 +1813,7 @@ export class NetRoom {
1803
1813
 
1804
1814
  /** A round's results, labelled the same way (`agent: true` on every AI's row). */
1805
1815
  labelResults(rows) {
1806
- return rows.slice(0, 64).map((raw) => this.labelOne(raw, true)).filter(Boolean);
1816
+ return rows.slice(0, this.server ? this.seatCap : 64).map((raw) => this.labelOne(raw, true)).filter(Boolean);
1807
1817
  }
1808
1818
 
1809
1819
  labelOne(raw, result) {
@@ -1,2 +1,2 @@
1
1
  /** The @homie-rocks/studio version this Worker code is (package.json's; a test keeps them equal). Cache-busts /_homie/site.js. */
2
- export const STUDIO_VERSION_TAG = '0.43.0';
2
+ export const STUDIO_VERSION_TAG = '0.44.0';