@homie-rocks/studio 0.18.1 → 0.19.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/lib/scaffold.mjs CHANGED
@@ -110,6 +110,7 @@ directory lists its games; homie.rocks does not host them.
110
110
  | \`site/\` | The studio's site: its look (\`theme.json\`), and anything of its own that wins over the generated pages (\`site/README.md\`); the Worker (\`src/worker.mjs\`) and its D1 migrations. |
111
111
  | \`wrangler.jsonc\` | The Worker's Cloudflare config (the Worker, D1, the Table and Lobby Durable Objects, and \`previews\` for branch Previews), at the root, where Cloudflare's Workers Builds reads it. A studio made before 0.10.0 keeps it in \`site/\` and deploys from a computer; every command finds either. |
112
112
  | \`changes/\` | One small file per change that went out through a pull request (\`homie-studio progress pr\` writes it): the site lists the newest, so the Claude app can tell when a merged change is live. |
113
+ | \`perf/\` | Performance reports, one folder per game (the Homie plugin's \`perf\` skill): \`README.md\` (the goal, before and after with the noise, every change tried and why it was kept or reverted), \`numbers.json\` and \`before-after/\`. The raw runs, screenshots and CPU profiles stay in \`.perf/\`, which git ignores. |
113
114
  | \`studio.json\` | The studio's name, slug, Cloudflare resource names, custom domain and stats sharing. \`.studio/\` (git-ignored) is this computer's own state. |
114
115
  | \`.claude/skills/\` | Skills only this studio uses. Homie's own skills come from the Homie plugin. |
115
116
 
@@ -134,6 +135,11 @@ studio's pinned copy, never a registry lookup of the bare name.
134
135
  never \`pkill\` by name, which stops other projects' dev servers too).
135
136
  - \`npx --no-install homie-studio check <id> --url <site>\` — two headless browsers press Play and must
136
137
  land in the same room and finish a round. Run it before you say a game works.
138
+ - \`npx --no-install homie-studio perf <id> --url <site>\` — how fast a game runs, on a computer and an emulated
139
+ phone, with two browsers in a room (the host and a replica): frame times, the game's JavaScript and the main thread
140
+ per frame, time to playable, what it downloads, the heap, netplay messages a second (files under \`.perf/\`). The
141
+ Homie plugin's \`perf\` skill runs the whole loop: one change at a time, kept only when it is better beyond the noise
142
+ and \`check\` still passes, and a report in \`perf/<id>/\`.
137
143
  - \`npx --no-install homie-studio deploy --plan\` — says what deploy will create on Cloudflare and what it
138
144
  costs, and changes nothing. Tell the person before the first deploy.
139
145
  - \`npm run deploy\` — the site on this studio's Cloudflare: one Worker, one D1 database, two
@@ -456,6 +462,8 @@ site/.wrangler/
456
462
  games/*/.port/
457
463
  # Screenshots from check and look runs.
458
464
  .checks/
465
+ # Performance runs (homie-studio perf, the perf skill): numbers, screenshots and CPU profiles of each run.
466
+ .perf/
459
467
  # Large media lives in this studio's storage (R2, after storage add: media move) or is served by the site; the manifests beside it (which name each file's R2 copy) are committed.
460
468
  # Working files of the music and video skills (frames, captures, provider answers) stay on this computer.
461
469
  music/**/work/
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "v": 1,
3
- "about": "Fingerprints of every studio template version (scripts/studio-template-history.mjs): homie-studio upgrade replaces a file or an AGENTS.md section only when it is exactly one of these.",
3
+ "about": "Fingerprints of every studio template version (scripts/studio-template-history.mjs): homie-studio upgrade replaces a file or an AGENTS.md section only when it is exactly one of these. A version's named entries are for a studio the template itself names (lib/upgrade.mjs NAMED_IN_TEMPLATE), as that studio reads them.",
4
4
  "versions": {
5
5
  "0.2.0": {
6
6
  "files": {
@@ -138,14 +138,23 @@
138
138
  "music/README.md": "b8a828de9311bd28",
139
139
  "videos/README.md": "70614388cbb203c7",
140
140
  "posts/README.md": "37841aae476bd540",
141
- "site/README.md": "0fd152a20a3eb55f",
141
+ "site/README.md": [
142
+ "0fd152a20a3eb55f",
143
+ "ca131cd0472464fb"
144
+ ],
142
145
  "site/src/worker.mjs": "ca3f1630f2420ad3"
143
146
  },
144
147
  "sections": {
145
148
  "AGENTS.md": {
146
149
  "(intro)": "214aa46eb3a895d5",
147
- "## Layout": "b5f801e112d163a9",
148
- "## Commands (all through the pinned CLI in node_modules)": "63833b7006efdef4",
150
+ "## Layout": [
151
+ "b5f801e112d163a9",
152
+ "dc8ce5f6276d369e"
153
+ ],
154
+ "## Commands (all through the pinned CLI in node_modules)": [
155
+ "2af7c804c1e81d86",
156
+ "63833b7006efdef4"
157
+ ],
149
158
  "## The site": "7d3525ba39b7f262",
150
159
  "## Making games": "c204473d9b468f49",
151
160
  "## Rules": "969318425c99f5a5",
@@ -252,24 +261,44 @@
252
261
  "files": {
253
262
  "CLAUDE.md": "ddf2b2a0376202f6",
254
263
  "README.md": "684b3cca8001673c",
255
- "games/README.md": "5e5e942baa7e0372",
264
+ "games/README.md": [
265
+ "5ad32fe80c61f117",
266
+ "5e5e942baa7e0372"
267
+ ],
256
268
  "music/README.md": "b8a828de9311bd28",
257
269
  "videos/README.md": "70614388cbb203c7",
258
270
  "posts/README.md": "37841aae476bd540",
259
271
  "site/README.md": "ca131cd0472464fb",
260
- "site/src/worker.mjs": "ca3f1630f2420ad3"
272
+ "site/src/worker.mjs": "ca3f1630f2420ad3",
273
+ "HANDOFF.md": "ce7a8fd91c6bbee2"
261
274
  },
262
275
  "sections": {
263
276
  "AGENTS.md": {
264
277
  "(intro)": "214aa46eb3a895d5",
265
278
  "## Layout": "f14d641159480e6c",
266
- "## Commands (all through the pinned CLI in node_modules)": "3a3d3bef7e8d1a50",
267
- "## The site": "7d3525ba39b7f262",
279
+ "## Commands (all through the pinned CLI in node_modules)": [
280
+ "3a3d3bef7e8d1a50",
281
+ "e579cd6ce3ce342a"
282
+ ],
283
+ "## The site": [
284
+ "7c1618431bae6324",
285
+ "7d3525ba39b7f262"
286
+ ],
268
287
  "## Making games": "63069b31c87a2aa2",
269
288
  "## The Game Codex and progress": "0738ef719cef3a4c",
270
289
  "## Running live games (the back office)": "f0566f07cff04113",
271
290
  "## Rules": "969318425c99f5a5",
272
- "## Beta": "9d02638207a26e73"
291
+ "## Beta": "9d02638207a26e73",
292
+ "## Continuing a build from the Claude app": "0d68081e924ca419"
293
+ }
294
+ },
295
+ "named": {
296
+ "Homie Arcade": {
297
+ "sections": {
298
+ "AGENTS.md": {
299
+ "## Commands (all through the pinned CLI in node_modules)": "befe520937e71984"
300
+ }
301
+ }
273
302
  }
274
303
  }
275
304
  },
@@ -298,6 +327,15 @@
298
327
  "## Rules": "969318425c99f5a5",
299
328
  "## Beta": "9d02638207a26e73"
300
329
  }
330
+ },
331
+ "named": {
332
+ "Homie Arcade": {
333
+ "sections": {
334
+ "AGENTS.md": {
335
+ "## Commands (all through the pinned CLI in node_modules)": "befe520937e71984"
336
+ }
337
+ }
338
+ }
301
339
  }
302
340
  },
303
341
  "0.14.1": {
@@ -325,6 +363,15 @@
325
363
  "## Rules": "969318425c99f5a5",
326
364
  "## Beta": "9d02638207a26e73"
327
365
  }
366
+ },
367
+ "named": {
368
+ "Homie Arcade": {
369
+ "sections": {
370
+ "AGENTS.md": {
371
+ "## Commands (all through the pinned CLI in node_modules)": "befe520937e71984"
372
+ }
373
+ }
374
+ }
328
375
  }
329
376
  },
330
377
  "0.14.2": {
@@ -352,6 +399,15 @@
352
399
  "## Rules": "969318425c99f5a5",
353
400
  "## Beta": "9d02638207a26e73"
354
401
  }
402
+ },
403
+ "named": {
404
+ "Homie Arcade": {
405
+ "sections": {
406
+ "AGENTS.md": {
407
+ "## Commands (all through the pinned CLI in node_modules)": "befe520937e71984"
408
+ }
409
+ }
410
+ }
355
411
  }
356
412
  },
357
413
  "0.14.3": {
@@ -379,6 +435,15 @@
379
435
  "## Rules": "969318425c99f5a5",
380
436
  "## Beta": "9d02638207a26e73"
381
437
  }
438
+ },
439
+ "named": {
440
+ "Homie Arcade": {
441
+ "sections": {
442
+ "AGENTS.md": {
443
+ "## Commands (all through the pinned CLI in node_modules)": "befe520937e71984"
444
+ }
445
+ }
446
+ }
382
447
  }
383
448
  },
384
449
  "0.14.4": {
@@ -406,6 +471,15 @@
406
471
  "## Rules": "969318425c99f5a5",
407
472
  "## Beta": "9d02638207a26e73"
408
473
  }
474
+ },
475
+ "named": {
476
+ "Homie Arcade": {
477
+ "sections": {
478
+ "AGENTS.md": {
479
+ "## Commands (all through the pinned CLI in node_modules)": "befe520937e71984"
480
+ }
481
+ }
482
+ }
409
483
  }
410
484
  },
411
485
  "0.15.0": {
@@ -433,6 +507,15 @@
433
507
  "## Rules": "969318425c99f5a5",
434
508
  "## Beta": "9d02638207a26e73"
435
509
  }
510
+ },
511
+ "named": {
512
+ "Homie Arcade": {
513
+ "sections": {
514
+ "AGENTS.md": {
515
+ "## Commands (all through the pinned CLI in node_modules)": "befe520937e71984"
516
+ }
517
+ }
518
+ }
436
519
  }
437
520
  },
438
521
  "0.16.0": {
@@ -461,6 +544,15 @@
461
544
  "## Rules": "969318425c99f5a5",
462
545
  "## Beta": "9d02638207a26e73"
463
546
  }
547
+ },
548
+ "named": {
549
+ "Homie Arcade": {
550
+ "sections": {
551
+ "AGENTS.md": {
552
+ "## Commands (all through the pinned CLI in node_modules)": "befe520937e71984"
553
+ }
554
+ }
555
+ }
464
556
  }
465
557
  },
466
558
  "0.16.1": {
@@ -489,6 +581,15 @@
489
581
  "## Rules": "969318425c99f5a5",
490
582
  "## Beta": "9d02638207a26e73"
491
583
  }
584
+ },
585
+ "named": {
586
+ "Homie Arcade": {
587
+ "sections": {
588
+ "AGENTS.md": {
589
+ "## Commands (all through the pinned CLI in node_modules)": "befe520937e71984"
590
+ }
591
+ }
592
+ }
492
593
  }
493
594
  },
494
595
  "0.17.0": {
@@ -517,6 +618,15 @@
517
618
  "## Rules": "969318425c99f5a5",
518
619
  "## Beta": "9d02638207a26e73"
519
620
  }
621
+ },
622
+ "named": {
623
+ "Homie Arcade": {
624
+ "sections": {
625
+ "AGENTS.md": {
626
+ "## Commands (all through the pinned CLI in node_modules)": "befe520937e71984"
627
+ }
628
+ }
629
+ }
520
630
  }
521
631
  },
522
632
  "0.18.0": {
@@ -545,6 +655,15 @@
545
655
  "## Rules": "969318425c99f5a5",
546
656
  "## Beta": "9d02638207a26e73"
547
657
  }
658
+ },
659
+ "named": {
660
+ "Homie Arcade": {
661
+ "sections": {
662
+ "AGENTS.md": {
663
+ "## Commands (all through the pinned CLI in node_modules)": "eedef844b7aaf7ad"
664
+ }
665
+ }
666
+ }
548
667
  }
549
668
  },
550
669
  "0.18.1": {
@@ -573,6 +692,89 @@
573
692
  "## Rules": "969318425c99f5a5",
574
693
  "## Beta": "9d02638207a26e73"
575
694
  }
695
+ },
696
+ "named": {
697
+ "Homie Arcade": {
698
+ "sections": {
699
+ "AGENTS.md": {
700
+ "## Commands (all through the pinned CLI in node_modules)": "eedef844b7aaf7ad"
701
+ }
702
+ }
703
+ }
704
+ }
705
+ },
706
+ "0.18.2": {
707
+ "files": {
708
+ "CLAUDE.md": "ddf2b2a0376202f6",
709
+ "README.md": "684b3cca8001673c",
710
+ "HANDOFF.md": "ce7a8fd91c6bbee2",
711
+ "games/README.md": "5ad32fe80c61f117",
712
+ "music/README.md": "a3dc042dadd39df8",
713
+ "videos/README.md": "16babc3437d5545b",
714
+ "posts/README.md": "37841aae476bd540",
715
+ "site/README.md": "ca131cd0472464fb",
716
+ "site/src/worker.mjs": "ca3f1630f2420ad3"
717
+ },
718
+ "sections": {
719
+ "AGENTS.md": {
720
+ "(intro)": "214aa46eb3a895d5",
721
+ "## Layout": "eb1942414b8af7f8",
722
+ "## Commands (all through the pinned CLI in node_modules)": "9c51f189412e6a9f",
723
+ "## The site": "ab922bb6bde23293",
724
+ "## Making games": "a8a893eeada33bf5",
725
+ "## The Game Codex and progress": "0738ef719cef3a4c",
726
+ "## Running live games (the back office)": "f0566f07cff04113",
727
+ "## Servers and AI seats": "0a332b07e22d3fce",
728
+ "## Continuing a build from the Claude app": "0d68081e924ca419",
729
+ "## Rules": "969318425c99f5a5",
730
+ "## Beta": "9d02638207a26e73"
731
+ }
732
+ },
733
+ "named": {
734
+ "Homie Arcade": {
735
+ "sections": {
736
+ "AGENTS.md": {
737
+ "## Commands (all through the pinned CLI in node_modules)": "eedef844b7aaf7ad"
738
+ }
739
+ }
740
+ }
741
+ }
742
+ },
743
+ "0.19.0": {
744
+ "files": {
745
+ "CLAUDE.md": "ddf2b2a0376202f6",
746
+ "README.md": "684b3cca8001673c",
747
+ "HANDOFF.md": "ce7a8fd91c6bbee2",
748
+ "games/README.md": "5ad32fe80c61f117",
749
+ "music/README.md": "a3dc042dadd39df8",
750
+ "videos/README.md": "16babc3437d5545b",
751
+ "posts/README.md": "37841aae476bd540",
752
+ "site/README.md": "ca131cd0472464fb",
753
+ "site/src/worker.mjs": "ca3f1630f2420ad3"
754
+ },
755
+ "sections": {
756
+ "AGENTS.md": {
757
+ "(intro)": "214aa46eb3a895d5",
758
+ "## Layout": "8dc97df0d77e655e",
759
+ "## Commands (all through the pinned CLI in node_modules)": "a06ab13fc385ab25",
760
+ "## The site": "ab922bb6bde23293",
761
+ "## Making games": "a8a893eeada33bf5",
762
+ "## The Game Codex and progress": "0738ef719cef3a4c",
763
+ "## Running live games (the back office)": "f0566f07cff04113",
764
+ "## Servers and AI seats": "0a332b07e22d3fce",
765
+ "## Continuing a build from the Claude app": "0d68081e924ca419",
766
+ "## Rules": "969318425c99f5a5",
767
+ "## Beta": "9d02638207a26e73"
768
+ }
769
+ },
770
+ "named": {
771
+ "Homie Arcade": {
772
+ "sections": {
773
+ "AGENTS.md": {
774
+ "## Commands (all through the pinned CLI in node_modules)": "677ee39dc13485e0"
775
+ }
776
+ }
777
+ }
576
778
  }
577
779
  }
578
780
  }
package/lib/upgrade.mjs CHANGED
@@ -9,8 +9,8 @@
9
9
  *
10
10
  * added a file, an AGENTS.md section, a .gitignore line or a package.json script the studio does not have;
11
11
  * updated a file or an AGENTS.md section the studio never changed since an older template wrote it (its text
12
- * is exactly an earlier version's: lib/template-history.json keeps a fingerprint of every one), so the
13
- * template's newer text replaces it;
12
+ * is exactly an earlier version's: lib/template-history.json keeps a fingerprint of every one, and of
13
+ * every one as a studio the template itself names reads it), so the template's newer text replaces it;
14
14
  * kept anything the studio changed, or wrote itself: never touched. The plan says it differs, and
15
15
  * `--diff` prints the difference, so the person can take the template's words by hand.
16
16
  *
@@ -42,6 +42,14 @@ export const ADD_ONLY = ['music/manifest.json', 'videos/manifest.json', '.claude
42
42
  const MIGRATIONS = /^site\/migrations\/[^/]+\.sql$/;
43
43
  /** The name and slug stand-ins the history is fingerprinted with. */
44
44
  export const STAND_IN = { name: '{{studio.name}}', slug: '{{studio.slug}}' };
45
+ /**
46
+ * Studios the template's own words name: `demo` plays a live game "on Homie Arcade" (the Commands section). A
47
+ * studio's text is compared with its own name and slug made neutral (normalize), so for the studio that IS Homie
48
+ * Arcade that mention turns into the stand-in too, and no stand-in fingerprint could ever match its untouched text:
49
+ * it read as changed by the studio, and kept the template's older words for good. The history also keeps, for each of
50
+ * these names, the fingerprints that differ when the template is written for that studio (`named`).
51
+ */
52
+ export const NAMED_IN_TEMPLATE = Object.freeze([Object.freeze({ name: 'Homie Arcade', slug: 'homie-arcade' })]);
45
53
  const INTRO = '(intro)';
46
54
 
47
55
  /** A text as the template compares it: line ends, trailing spaces and the studio's own name and slug made neutral. */
@@ -64,9 +72,9 @@ export function sections(text) {
64
72
  return out.map((s) => ({ heading: s.heading, text: s.lines.join('\n') })).filter((s, i) => i > 0 || s.text.trim());
65
73
  }
66
74
 
67
- /** What the history keeps of one template: a fingerprint of every whole file and every AGENTS.md section. */
68
- export function templatePrint(files) {
69
- const who = STAND_IN;
75
+ /** What the history keeps of one template: a fingerprint of every whole file and every AGENTS.md section, as `who`
76
+ * (the stand-ins, or a studio the template names) reads them. */
77
+ export function templatePrint(files, who = STAND_IN) {
70
78
  return {
71
79
  files: Object.fromEntries(TEMPLATE_FILES.filter((f) => f !== 'AGENTS.md' && files[f] !== undefined).map((f) => [f, fingerprint(files[f], who)])),
72
80
  sections: { 'AGENTS.md': Object.fromEntries(sections(files['AGENTS.md']).map((s) => [s.heading, fingerprint(s.text, who)])) },
@@ -77,14 +85,33 @@ export function readHistory() {
77
85
  try { return JSON.parse(readFileSync(new URL('./template-history.json', import.meta.url), 'utf8')); } catch { return { v: 1, versions: {} }; }
78
86
  }
79
87
 
80
- /** Every fingerprint an older template wrote, per file and per AGENTS.md section. */
81
- function known(history) {
88
+ /**
89
+ * A template as the studios it names read it (NAMED_IN_TEMPLATE): for each, the fingerprints that differ from the
90
+ * stand-ins' (only a file or section that names it does). `render(who)` is what `homie-studio new` writes for `who`.
91
+ */
92
+ export function namedPrint(render, plain = templatePrint(render(STAND_IN))) {
93
+ const named = {};
94
+ for (const who of NAMED_IN_TEMPLATE) {
95
+ const p = templatePrint(render(who), who);
96
+ const files = Object.fromEntries(Object.entries(p.files).filter(([f, h]) => plain.files[f] !== h));
97
+ const secs = Object.fromEntries(Object.entries(p.sections['AGENTS.md']).filter(([s, h]) => plain.sections['AGENTS.md'][s] !== h));
98
+ if (Object.keys(files).length || Object.keys(secs).length) named[who.name] = { ...(Object.keys(files).length ? { files } : {}), sections: { 'AGENTS.md': secs } };
99
+ }
100
+ return named;
101
+ }
102
+
103
+ /** Every fingerprint an older template wrote, per file and per AGENTS.md section, as this studio (`who`) reads it. */
104
+ function known(history, who = {}) {
82
105
  const files = new Map();
83
106
  const secs = new Map();
84
107
  for (const v of Object.values(history.versions ?? {})) {
85
- // A version may hold more than one hash for a file (two templates published under it): a list.
86
- for (const [f, h] of Object.entries(v.files ?? {})) for (const x of [h].flat()) (files.get(f) ?? files.set(f, new Set()).get(f)).add(x);
87
- for (const [heading, h] of Object.entries(v.sections?.['AGENTS.md'] ?? {})) for (const x of [h].flat()) (secs.get(heading) ?? secs.set(heading, new Set()).get(heading)).add(x);
108
+ // A studio the template names reads those mentions as its own name: its fingerprints are kept beside the rest.
109
+ const as = who.name && v.named && Object.hasOwn(v.named, who.name) ? v.named[who.name] : null;
110
+ for (const p of as ? [v, as] : [v]) {
111
+ // A version may hold more than one hash for a file (two templates published under it): a list.
112
+ for (const [f, h] of Object.entries(p.files ?? {})) for (const x of [h].flat()) (files.get(f) ?? files.set(f, new Set()).get(f)).add(x);
113
+ for (const [heading, h] of Object.entries(p.sections?.['AGENTS.md'] ?? {})) for (const x of [h].flat()) (secs.get(heading) ?? secs.set(heading, new Set()).get(heading)).add(x);
114
+ }
88
115
  }
89
116
  return { files, secs };
90
117
  }
@@ -137,7 +164,7 @@ export function upgradePlan(root, { history = readHistory() } = {}) {
137
164
  if (!name || !slug) return { ok: false, command: 'upgrade', why: 'studio.json has no name or slug; this does not look like a studio made with homie-studio new' };
138
165
  const who = { name, slug };
139
166
  const tmpl = studioFiles({ name, slug, homie: studio.homie?.directory ?? 'https://homie.rocks' });
140
- const { files: oldFiles, secs: oldSecs } = known(history);
167
+ const { files: oldFiles, secs: oldSecs } = known(history, who);
141
168
  const changes = [];
142
169
  const kept = [];
143
170
 
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@homie-rocks/studio",
3
- "version": "0.18.1",
3
+ "version": "0.19.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/port/room.ts CHANGED
@@ -34,7 +34,7 @@ import {
34
34
  export interface BodyBase { slot: number; seat: number | null; name: string; bot: boolean; score: number; agent?: Slot['agent'] }
35
35
 
36
36
  export interface RoomSnap<F = unknown> { r: [n: number, phase: number, startedAt: number, endsAt: number]; b: number[][]; w?: F }
37
- export interface RoomCkpt<B, S> { round: RoundInfo | null; roster: Slot[]; bodies: B[]; world: S | null; tick: number }
37
+ export interface RoomCkpt<B, S> { round: RoundInfo | null; roster: Slot[]; bodies: B[]; world: S | null; tick: number; spawns?: [slot: number, index: number][] }
38
38
 
39
39
  export interface RoomOptions<B extends BodyBase, F = unknown, S = unknown> {
40
40
  /** The game id (the relay's logs). */
@@ -50,7 +50,11 @@ export interface RoomOptions<B extends BodyBase, F = unknown, S = unknown> {
50
50
  /** Results on screen between rounds (default 7). */
51
51
  breakSeconds?: number;
52
52
  botName?: (slot: number) => string;
53
- /** A fresh body for a slot. `index` counts bodies at a round start (spread the spawns with it). */
53
+ /**
54
+ * A fresh body for a slot. Spread the spawns with `index`: at a round start the bodies get 0, 1, 2… in slot order; a
55
+ * body that arrives mid-round (a joiner with no bot to take over, a guide's seat) gets the lowest index no body in
56
+ * the room holds, so two bodies never get the same one.
57
+ */
54
58
  spawn: (slot: Slot, index: number) => B;
55
59
  /** The body's fast fields for the snapshot, after [slot, seat, score]: small numbers only (use q()). */
56
60
  pack: (b: B) => number[];
@@ -143,6 +147,8 @@ export function createRoom<B extends BodyBase, F = unknown, S = unknown>(opts: R
143
147
  const policy = (): Policy => net.policy;
144
148
  let roster = new Roster({ min, max, botName, policy });
145
149
  let bodies = new Map<number, B>();
150
+ /** The index each body was spawned with (slot → index), so a body that arrives mid-round never gets one in use. */
151
+ let spawned = new Map<number, number>();
146
152
  let round: RoundInfo | null = null;
147
153
  let hosting = false;
148
154
  let tick = 0;
@@ -150,7 +156,7 @@ export function createRoom<B extends BodyBase, F = unknown, S = unknown>(opts: R
150
156
  const net = createNetplay<RoomSnap<F>, unknown[], RoomCkpt<B, S>>({
151
157
  game: opts.game, maxPlayers: max, movement: opts.movement ?? 'owner',
152
158
  snapshotHz: 20, inputHz: 20, checkpointMs: 1000,
153
- checkpoint: () => ({ round, roster: roster.toJSON(), bodies: [...bodies.values()].map((b) => ({ ...b })), world: opts.saveWorld ? opts.saveWorld() : null, tick }),
159
+ checkpoint: () => ({ round, roster: roster.toJSON(), bodies: [...bodies.values()].map((b) => ({ ...b })), world: opts.saveWorld ? opts.saveWorld() : null, tick, spawns: [...spawned].filter(([slot]) => bodies.has(slot)) }),
154
160
  ...(opts.netplay ?? {}),
155
161
  // createRoom claims an AI's slot for it (its join passes `p.agent`), so its host can move an AI's body.
156
162
  caps: [...new Set([...(opts.netplay?.caps ?? []), 'agents' as const])],
@@ -163,17 +169,31 @@ export function createRoom<B extends BodyBase, F = unknown, S = unknown>(opts: R
163
169
  return null;
164
170
  };
165
171
 
166
- function syncBodies(atIndex = 0): void {
172
+ /** The lowest spawn index no body in the room holds. */
173
+ function freeIndex(): number {
174
+ const held = new Set<number>();
175
+ for (const slot of bodies.keys()) { const i = spawned.get(slot); if (i !== undefined) held.add(i); }
176
+ let i = 0;
177
+ while (held.has(i)) i += 1;
178
+ return i;
179
+ }
180
+
181
+ function syncBodies(): void {
167
182
  const seen = new Set<number>();
168
- let i = atIndex;
169
183
  for (const s of roster.slots) {
170
184
  seen.add(s.slot);
171
185
  const b = bodies.get(s.slot);
172
- if (!b) { const nb = opts.spawn(s, i++); nb.slot = s.slot; nb.seat = s.seat; nb.name = s.name; nb.bot = s.bot; nb.score = nb.score ?? 0; if (s.agent) nb.agent = { ...s.agent }; bodies.set(s.slot, nb); continue; }
186
+ if (!b) {
187
+ // Mid-round (a joiner, a guide's seat): never an index another body holds, so never another body's spot.
188
+ const i = freeIndex();
189
+ const nb = opts.spawn(s, i); nb.slot = s.slot; nb.seat = s.seat; nb.name = s.name; nb.bot = s.bot; nb.score = nb.score ?? 0; if (s.agent) nb.agent = { ...s.agent };
190
+ bodies.set(s.slot, nb); spawned.set(s.slot, i);
191
+ continue;
192
+ }
173
193
  b.seat = s.seat; b.name = s.name; b.bot = s.bot;
174
194
  if (s.agent) b.agent = { ...s.agent }; else delete b.agent;
175
195
  }
176
- for (const k of [...bodies.keys()]) if (!seen.has(k)) bodies.delete(k);
196
+ for (const k of [...bodies.keys()]) if (!seen.has(k)) { bodies.delete(k); spawned.delete(k); }
177
197
  }
178
198
 
179
199
  function moved(b: B): void {
@@ -195,7 +215,9 @@ export function createRoom<B extends BodyBase, F = unknown, S = unknown>(opts: R
195
215
  roster.trim();
196
216
  syncBodies();
197
217
  let i = 0;
218
+ spawned = new Map();
198
219
  for (const s of [...roster.slots].sort((a, c) => a.slot - c.slot)) {
220
+ spawned.set(s.slot, i);
199
221
  const fresh = opts.spawn(s, i++);
200
222
  fresh.slot = s.slot; fresh.seat = s.seat; fresh.name = s.name; fresh.bot = s.bot; fresh.score = 0;
201
223
  if (s.agent) fresh.agent = { ...s.agent };
@@ -228,11 +250,14 @@ export function createRoom<B extends BodyBase, F = unknown, S = unknown>(opts: R
228
250
  if (ck) {
229
251
  roster = Roster.from(ck.roster, { min, max, botName, policy });
230
252
  bodies = new Map(ck.bodies.map((b) => [b.slot, { ...b }]));
253
+ // A checkpoint from before spawns were kept: the round start's order (slot order) is the best guess.
254
+ spawned = new Map(ck.spawns ?? [...bodies.keys()].sort((a, c) => a - c).map((slot, i) => [slot, i]));
231
255
  round = ck.round;
232
256
  tick = ck.tick;
233
257
  } else {
234
258
  roster = Roster.from(e.roster ?? [], { min, max, botName, policy });
235
259
  bodies = new Map();
260
+ spawned = new Map();
236
261
  syncBodies();
237
262
  }
238
263
  // The snapshot is newer than the checkpoint (20 Hz vs 1 Hz): positions and scores from it.
@@ -243,8 +268,9 @@ export function createRoom<B extends BodyBase, F = unknown, S = unknown>(opts: R
243
268
  let b = bodies.get(slot);
244
269
  if (!b) {
245
270
  const sl = roster.slots.find((r) => r.slot === slot) ?? { slot, seat: seat >= 0 ? seat : null, name: seat >= 0 ? `Player ${seat + 1}` : botName(slot), bot: seat < 0 };
246
- b = opts.spawn(sl, slot); b.slot = slot; b.seat = sl.seat; b.name = sl.name; b.bot = sl.bot;
247
- bodies.set(slot, b);
271
+ const i = freeIndex();
272
+ b = opts.spawn(sl, i); b.slot = slot; b.seat = sl.seat; b.name = sl.name; b.bot = sl.bot;
273
+ bodies.set(slot, b); spawned.set(slot, i);
248
274
  }
249
275
  b.score = score;
250
276
  opts.unpack(row.slice(3), b);
@@ -268,6 +294,7 @@ export function createRoom<B extends BodyBase, F = unknown, S = unknown>(opts: R
268
294
  else {
269
295
  roster = new Roster({ min, max, botName, policy });
270
296
  bodies = new Map();
297
+ spawned = new Map();
271
298
  const seat = mySeat();
272
299
  if (seat !== null) roster.claim(seat, net.offline ? 'You' : net.name);
273
300
  syncBodies();
package/saves/SAVES.md CHANGED
@@ -23,7 +23,10 @@ The pattern (the `ember-vale` starter does exactly this):
23
23
  1. When the player arrives, **load** the hero from saves; until it loads, play as a level-1 body.
24
24
  2. The host decides what happened (who killed what) and tells **that player's own browser** (a netplay event
25
25
  to its seat). Only the hero's own browser changes the hero and **saves** it.
26
- 3. The room carries what the others need to see (my level, so my health bar is right), never the save itself.
26
+ 3. The room carries what the others need to see (my level, so my health bar is right; my hero's name, so it is
27
+ the name over my head), never the save itself. The name goes to the host as an event, and the host keeps every
28
+ seat's in keyed state (`net.state('heroes', { [seat]: name })`), which a new host and a late joiner already have.
29
+ On a kids server nobody's typed name reaches anyone else: the others keep seeing the room's handles.
27
30
 
28
31
  ## Quick start
29
32