@homie-rocks/studio 0.21.0 → 0.23.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 (70) hide show
  1. package/CHANGELOG.md +68 -1
  2. package/README.md +92 -0
  3. package/assets/assets.ts +500 -0
  4. package/assets/render-page.ts +629 -0
  5. package/assets/safety.mjs +194 -0
  6. package/bin/homie-studio.mjs +81 -2
  7. package/chat/CHAT.md +101 -0
  8. package/chat/OWNERS.md +78 -0
  9. package/lib/art-cli.mjs +296 -0
  10. package/lib/art-tools.mjs +331 -0
  11. package/lib/asset-check.mjs +189 -0
  12. package/lib/asset-import.mjs +136 -0
  13. package/lib/asset-manifest.mjs +318 -0
  14. package/lib/build.mjs +32 -2
  15. package/lib/chat-cli.mjs +89 -0
  16. package/lib/cloudflare.mjs +5 -2
  17. package/lib/codex.mjs +43 -1
  18. package/lib/colour.mjs +160 -0
  19. package/lib/decisions.mjs +800 -0
  20. package/lib/directory.mjs +9 -1
  21. package/lib/library.mjs +169 -0
  22. package/lib/mcp-tools.mjs +22 -4
  23. package/lib/mcp.mjs +6 -3
  24. package/lib/optimise.mjs +380 -0
  25. package/lib/remix-assets.mjs +84 -0
  26. package/lib/render3d.mjs +81 -0
  27. package/lib/scaffold.mjs +39 -2
  28. package/lib/site.mjs +6 -1
  29. package/lib/studio.mjs +136 -9
  30. package/lib/style-board.mjs +227 -0
  31. package/lib/style-presets.mjs +121 -0
  32. package/lib/template-history.json +76 -0
  33. package/mcp/ui/card.css +15 -0
  34. package/mcp/ui/cast.js +62 -0
  35. package/mcp/ui/decision.js +61 -0
  36. package/mcp/ui/lineup.js +68 -0
  37. package/mcp/ui/rights.js +30 -0
  38. package/mcp/ui/style.js +154 -0
  39. package/netplay/NETPLAY.md +191 -3
  40. package/netplay/netplay.ts +150 -3
  41. package/package.json +15 -2
  42. package/port/index.ts +3 -2
  43. package/port/view.ts +238 -0
  44. package/site/SITE.md +11 -1
  45. package/starters/ember-vale/src/main.ts +22 -2
  46. package/starters/gem-rush/src/main.ts +21 -1
  47. package/starters/gem-rush-3d/assets/RIGHTS.md +203 -0
  48. package/starters/gem-rush-3d/assets/manifest.json +2318 -0
  49. package/starters/gem-rush-3d/credits.json +31 -0
  50. package/starters/gem-rush-3d/game.json +11 -0
  51. package/starters/gem-rush-3d/index.html +20 -0
  52. package/starters/gem-rush-3d/lab.json +19 -0
  53. package/starters/gem-rush-3d/src/main.ts +2107 -0
  54. package/starters/gem-rush-3d/style.json +66 -0
  55. package/starters/gem-rush-3d/tunables.json +13 -0
  56. package/worker/agents.mjs +3 -3
  57. package/worker/chat-page.mjs +556 -0
  58. package/worker/chat-store.mjs +161 -0
  59. package/worker/chat-words.mjs +46 -0
  60. package/worker/chat.mjs +419 -0
  61. package/worker/index.mjs +153 -8
  62. package/worker/office-page.mjs +115 -0
  63. package/worker/office.mjs +137 -6
  64. package/worker/pages.mjs +32 -9
  65. package/worker/qr.mjs +11 -0
  66. package/worker/room.mjs +288 -10
  67. package/worker/servers.mjs +5 -1
  68. package/worker/site.mjs +9 -4
  69. package/worker/stats.mjs +4 -1
  70. package/worker/version.mjs +1 -1
package/port/view.ts CHANGED
@@ -11,6 +11,10 @@
11
11
  * label is placed first and nothing covers it; a moved label keeps its new spot while it is
12
12
  * free (no flicker), and labels fade in and out rather than pop.
13
13
  *
14
+ * createBubbles() speech bubbles over characters (room chat, NETPLAY.md section 19): what a player said, over
15
+ * their body for a few seconds, the player's own placed first, never covering another bubble;
16
+ * `paintBubbles` draws them on a canvas, and their boxes keep the name labels off them.
17
+ *
14
18
  * Everything is in CSS pixels (screen) and world units; the game draws. See the starters (gem-rush, ember-vale).
15
19
  */
16
20
 
@@ -211,3 +215,237 @@ export function createLabels(opts: LabelOptions = {}): { place(labels: LabelIn[]
211
215
  },
212
216
  };
213
217
  }
218
+
219
+ /* ------------------------------------------------------------------ speech bubbles (room chat, NETPLAY.md section 19) */
220
+
221
+ export interface BubbleOptions {
222
+ /** A line's width as drawn, CSS px: `(t) => ctx.measureText(t).width` with the bubble's font set (paintBubbles uses BUBBLE_FONT). */
223
+ measure: (text: string) => number;
224
+ /** A text line's height, CSS px (default 17). */
225
+ lineHeight?: number;
226
+ /** The widest a bubble's text gets before it wraps, CSS px (default 190). */
227
+ maxWidth?: number;
228
+ /** Lines at most; the rest is cut with "…" (default 3). */
229
+ maxLines?: number;
230
+ /** How long a message stays, ms: default 4 s and 50 ms a character (at most 9 s); an emoji 2.6 s. */
231
+ ms?: (b: { text: string; kind: 'text' | 'line' | 'react' }) => number;
232
+ /** Space inside the bubble, CSS px (default x 8, y 5). */
233
+ pad?: { x: number; y: number };
234
+ /** The tail under the bubble, CSS px (default 7). */
235
+ tail?: number;
236
+ /** CSS px kept clear between two bubbles (default 4). */
237
+ gap?: number;
238
+ /** The screen, so a bubble is never pushed off it (default the window). */
239
+ screen?: () => { w: number; h: number };
240
+ /** Milliseconds now (default performance.now()). */
241
+ now?: () => number;
242
+ /** Boxes bubbles keep clear of, CSS px (a game's own speech bubbles, a panel): a bubble moves up past them. */
243
+ avoid?: () => LabelBox[];
244
+ }
245
+
246
+ export interface BubbleIn {
247
+ /** Whose bubble: the key `say(key, …)` was given (a seat, a slot). */
248
+ key: string | number;
249
+ /** Where the tail points, CSS px: just over the body's name label (its box's top middle) or its head. */
250
+ x: number;
251
+ y: number;
252
+ /** The player's own (or the player a watcher follows): placed first. */
253
+ self?: boolean;
254
+ }
255
+
256
+ export interface BubbleOut extends LabelBox {
257
+ key: string | number;
258
+ id: string;
259
+ kind: 'text' | 'line' | 'react';
260
+ /** The text, wrapped: one string per line (one line with the glyph for an emoji). */
261
+ lines: string[];
262
+ /** An emoji-only bubble (a reaction): draw it bigger, no words. */
263
+ emoji: boolean;
264
+ lineHeight: number;
265
+ pad: { x: number; y: number };
266
+ /** Where the tail points (the anchor), and the bubble's middle. */
267
+ tip: { x: number; y: number };
268
+ cx: number;
269
+ cy: number;
270
+ /** 0..1: fading in and out. `scale` pops in from 0.86. */
271
+ alpha: number;
272
+ scale: number;
273
+ }
274
+
275
+ export interface Bubbles {
276
+ /** A message over `key`'s character: it replaces what that key was saying. `id` lets `remove(id)` take it down. */
277
+ say(key: string | number, text: string, o?: { id?: string; kind?: 'text' | 'line' | 'react' }): void;
278
+ /** The studio took a message down (net.on('unchat')): its bubble goes at once. */
279
+ remove(id: string): void;
280
+ /** Every bubble, or one key's, goes. */
281
+ clear(key?: string | number): void;
282
+ /** Each frame: where every speaking key's anchor is now. Keys not in the list are not drawn (off screen). */
283
+ place(anchors: BubbleIn[], dt?: number): BubbleOut[];
284
+ /** The boxes placed last frame: give them to createLabels' `avoid` so names keep off the bubbles. */
285
+ boxes(): LabelBox[];
286
+ /** How many keys are speaking now. */
287
+ readonly size: number;
288
+ }
289
+
290
+ /** The bubble's font (paintBubbles draws with it; measure with it too). */
291
+ export const BUBBLE_FONT = '600 14px ui-sans-serif, system-ui, -apple-system, "Segoe UI", sans-serif';
292
+
293
+ /** Words to lines no wider than `max` (a word wider than a line is broken), at most `n` lines (the last cut with …). */
294
+ export function wrapText(text: string, max: number, measure: (t: string) => number, n = 3): string[] {
295
+ const words = String(text ?? '').replace(/\s+/g, ' ').trim().split(' ').filter(Boolean);
296
+ const lines: string[] = [];
297
+ let cur = '';
298
+ const push = (s: string): void => { lines.push(s); };
299
+ for (const w0 of words) {
300
+ let w = w0;
301
+ // A word wider than a line: broken by characters.
302
+ while (measure(w) > max) {
303
+ let cut = [...w].length - 1;
304
+ const chars = [...w];
305
+ while (cut > 1 && measure(chars.slice(0, cut).join('')) > max) cut -= 1;
306
+ if (cur) { push(cur); cur = ''; }
307
+ push(chars.slice(0, cut).join(''));
308
+ w = chars.slice(cut).join('');
309
+ }
310
+ const next = cur ? `${cur} ${w}` : w;
311
+ if (measure(next) <= max) cur = next; else { if (cur) push(cur); cur = w; }
312
+ }
313
+ if (cur) push(cur);
314
+ if (lines.length <= n) return lines;
315
+ const kept = lines.slice(0, n);
316
+ let last = `${kept[n - 1]}…`;
317
+ while (measure(last) > max && last.length > 2) last = `${[...last].slice(0, -2).join('')}…`;
318
+ kept[n - 1] = last;
319
+ return kept;
320
+ }
321
+
322
+ /**
323
+ * Speech bubbles over characters: what a player said in the room's chat, over their body, for a few seconds. Feed it
324
+ * from the netplay helper (`net.on('say', (s) => bubbles.say(s.seat, s.text, { id: s.id, kind: s.kind }))` and
325
+ * `net.on('unchat', (e) => e.ids.forEach((id) => bubbles.remove(id)))`), place it each frame with every speaking
326
+ * body's anchor (over its name label), and draw what comes back (`paintBubbles` on a canvas, or your own). Bubbles
327
+ * never cover each other: a bubble that would sits above the one in its way; the player's own is placed first.
328
+ */
329
+ export function createBubbles(opts: BubbleOptions): Bubbles {
330
+ const lh = opts.lineHeight ?? 17;
331
+ const maxW = opts.maxWidth ?? 190;
332
+ const maxLines = opts.maxLines ?? 3;
333
+ const pad = opts.pad ?? { x: 8, y: 5 };
334
+ const tail = opts.tail ?? 7;
335
+ const gap = opts.gap ?? 4;
336
+ const clock = opts.now ?? ((): number => (typeof performance !== 'undefined' ? performance.now() : Date.now()));
337
+ const screen = opts.screen ?? ((): { w: number; h: number } => ({ w: typeof innerWidth === 'number' ? innerWidth : 1e9, h: typeof innerHeight === 'number' ? innerHeight : 1e9 }));
338
+ const life = opts.ms ?? ((b: { text: string; kind: string }): number => (b.kind === 'react' ? 2600 : Math.min(9000, 4000 + 50 * [...b.text].length)));
339
+ type Live = { id: string; key: string | number; kind: 'text' | 'line' | 'react'; text: string; lines: string[]; w: number; h: number; at: number; until: number };
340
+ const live = new Map<string | number, Live>();
341
+ let last: LabelBox[] = [];
342
+ let seq = 0;
343
+ return {
344
+ say(key, text, o = {}) {
345
+ const kind = o.kind ?? 'text';
346
+ const t = String(text ?? '').trim();
347
+ if (!t) return;
348
+ const emoji = kind === 'react';
349
+ const lines = emoji ? [t] : wrapText(t, maxW, opts.measure, maxLines);
350
+ const w = emoji ? lh * 1.6 : Math.max(...lines.map((l) => opts.measure(l)));
351
+ const h = emoji ? lh * 1.6 : lines.length * lh;
352
+ const at = clock();
353
+ seq += 1;
354
+ live.set(key, { id: o.id ?? `b${seq}`, key, kind, text: t, lines, w, h, at, until: at + life({ text: t, kind }) });
355
+ },
356
+ remove(id) { for (const [k, b] of live) if (b.id === id) live.delete(k); },
357
+ clear(key) { if (key === undefined) live.clear(); else live.delete(key); },
358
+ get size() { return live.size; },
359
+ boxes() { return last; },
360
+ place(anchors) {
361
+ const t = clock();
362
+ for (const [k, b] of live) if (t >= b.until) live.delete(k);
363
+ const { w: sw, h: sh } = screen();
364
+ const keep = opts.avoid?.() ?? [];
365
+ const placed: LabelBox[] = [];
366
+ const out: BubbleOut[] = [];
367
+ // The player's own first, then the newest: a new message keeps its spot and an older one moves up.
368
+ const order = anchors.filter((a) => live.has(a.key)).sort((a, b) => Number(Boolean(b.self)) - Number(Boolean(a.self)) || (live.get(b.key)!.at - live.get(a.key)!.at));
369
+ for (const a of order) {
370
+ const b = live.get(a.key)!;
371
+ const bw = b.w + pad.x * 2;
372
+ const bh = b.h + pad.y * 2;
373
+ const left = Math.max(2, Math.min(sw - bw - 2, a.x - bw / 2));
374
+ let bottom = a.y - tail;
375
+ let box = { left, top: bottom - bh, right: left + bw, bottom };
376
+ const hits = (x: LabelBox): boolean => box.left < x.right + gap && x.left < box.right + gap && box.top < x.bottom + gap && x.top < box.bottom + gap;
377
+ for (let i = 0; i < 6; i += 1) {
378
+ const other = placed.find(hits) ?? keep.find(hits);
379
+ if (!other) break;
380
+ bottom = other.top - gap;
381
+ box = { left, top: bottom - bh, right: left + bw, bottom };
382
+ }
383
+ if (box.top < 0 || box.left > sw || box.right < 0 || a.y > sh + bh) continue;
384
+ placed.push(box);
385
+ const age = t - b.at;
386
+ const leftMs = b.until - t;
387
+ const alpha = Math.max(0, Math.min(1, age / 120, leftMs / 400));
388
+ const scale = 0.86 + 0.14 * Math.min(1, age / 160);
389
+ out.push({ ...box, key: b.key, id: b.id, kind: b.kind, lines: b.lines, emoji: b.kind === 'react', lineHeight: lh, pad, tip: { x: Math.max(box.left + 8, Math.min(box.right - 8, a.x)), y: a.y }, cx: (box.left + box.right) / 2, cy: (box.top + box.bottom) / 2, alpha: Math.round(alpha * 100) / 100, scale: Math.round(scale * 1000) / 1000 });
390
+ }
391
+ last = placed;
392
+ return out;
393
+ },
394
+ };
395
+ }
396
+
397
+ export interface BubbleStyle {
398
+ font?: string;
399
+ /** The bubble's paper and ink (default white paper, near-black ink), its edge, and the player's own bubble's edge. */
400
+ paper?: string;
401
+ ink?: string;
402
+ edge?: string;
403
+ selfEdge?: string;
404
+ /** Corner radius, CSS px (default 10). */
405
+ radius?: number;
406
+ }
407
+
408
+ /** Draw placed bubbles on a 2D canvas (CSS px, the context's transform already set for the device pixel ratio). */
409
+ export function paintBubbles(ctx: CanvasRenderingContext2D, list: BubbleOut[], style: BubbleStyle = {}): void {
410
+ const font = style.font ?? BUBBLE_FONT;
411
+ const paper = style.paper ?? '#ffffff';
412
+ const ink = style.ink ?? '#15171f';
413
+ const edge = style.edge ?? 'rgba(0,0,0,0.18)';
414
+ const r = style.radius ?? 10;
415
+ for (const b of list) {
416
+ if (b.alpha <= 0) continue;
417
+ ctx.save();
418
+ ctx.globalAlpha = b.alpha;
419
+ const cx = b.cx;
420
+ const cy = b.bottom;
421
+ ctx.translate(cx, cy);
422
+ ctx.scale(b.scale, b.scale);
423
+ ctx.translate(-cx, -cy);
424
+ ctx.fillStyle = paper;
425
+ ctx.strokeStyle = edge;
426
+ ctx.lineWidth = 1;
427
+ ctx.beginPath();
428
+ ctx.roundRect(b.left, b.top, b.right - b.left, b.bottom - b.top, Math.min(r, (b.bottom - b.top) / 2));
429
+ ctx.fill();
430
+ ctx.stroke();
431
+ // The tail: a small triangle from the bubble's bottom to where it points.
432
+ const tx = b.tip.x;
433
+ ctx.beginPath();
434
+ ctx.moveTo(tx - 6, b.bottom - 1);
435
+ ctx.lineTo(tx, Math.min(b.tip.y, b.bottom + 7));
436
+ ctx.lineTo(tx + 6, b.bottom - 1);
437
+ ctx.closePath();
438
+ ctx.fill();
439
+ ctx.fillStyle = ink;
440
+ ctx.textAlign = 'center';
441
+ ctx.textBaseline = 'middle';
442
+ if (b.emoji) {
443
+ ctx.font = font.replace(/\d+(?:\.\d+)?px/, `${Math.round(b.lineHeight * 1.35)}px`);
444
+ ctx.fillText(b.lines[0] ?? '', b.cx, b.cy + 1);
445
+ } else {
446
+ ctx.font = font;
447
+ b.lines.forEach((line, i) => ctx.fillText(line, b.cx, b.top + b.pad.y + b.lineHeight * (i + 0.5) + 0.5));
448
+ }
449
+ ctx.restore();
450
+ }
451
+ }
package/site/SITE.md CHANGED
@@ -30,7 +30,17 @@ songs (each with a `cover`), videos (each with a `poster`), the latest posts, an
30
30
  (`games[].played`, `{ "days": 7, "plays", "rounds" }`). To keep the studio's rooms off the hub, studio.json takes
31
31
  `"rooms": { "share": false }`: the manifest then names no `rooms` (the studio's own pages still list them).
32
32
  Each room in `/api/rooms` carries `play` and, unless its game says `"watch": false`, `watch` (its watch door), so
33
- the hub offers Watch beside Join.
33
+ the hub offers Watch beside Join. From 0.23.0 a room whose chat is on and whose rules let homie.rocks show it
34
+ (`hub`, on by default) also says `chat: true`: the hub's room card then has Chat, which opens the room's own watch
35
+ socket from the visitor's browser and shows its lines and reactions live (NETPLAY.md section 19). A visitor there
36
+ sends what a watcher may send (reactions and quick lines when the room's `react` is `anyone`), never types.
37
+
38
+ ## Room chat on the play, watch and TV pages
39
+
40
+ Every play page, big screen and watch page carries room chat (worker/chat-page.mjs; chat/CHAT.md): the Chat pill
41
+ and its sheet, the float, the ticker, the big screen's corner. Reports go to `POST /<id>/api/chat/report` (this
42
+ site's pages only); the owner's tools are `/_studio/api/chat`, `/_studio/api/chat/rules`, `/_studio/api/chat/remove`,
43
+ `/_studio/api/chat/report` (dismiss) and `/_studio/api/chat/budget`, and `mute`/`kick` take a chat line.
34
44
 
35
45
  ## A game's landing: `/<id>/`
36
46
 
@@ -43,7 +43,7 @@
43
43
  * The take "strike" (lab.json) stands a big training slime in front of the hero (lab.stage "dummy"). Outside the lab
44
44
  * every lab call is a no-op.
45
45
  */
46
- import { AI_MARK, createControls, createLabels, createRoom, createSaves, easeView, exposePort, fitView, jitter, q, standoff, stripAi, type BodyBase, type Fit, type LabelIn, type LabelOut, type NetEvent, type Skill } from '@homie-rocks/studio/port';
46
+ import { AI_MARK, BUBBLE_FONT, createBubbles, createControls, createLabels, createRoom, paintBubbles, type BubbleIn, type BubbleOut, createSaves, easeView, exposePort, fitView, jitter, q, standoff, stripAi, type BodyBase, type Fit, type LabelIn, type LabelOut, type NetEvent, type Skill } from '@homie-rocks/studio/port';
47
47
  import { useAgents, type Goal, type Vocabulary } from '@homie-rocks/studio/agents';
48
48
  import vocabulary from '../agents.json';
49
49
  // The Game Lab: tunables, phases, tracks and overlays (no-ops outside the lab).
@@ -712,8 +712,14 @@ const INSET = { top: 124, bottom: 100 }; // the hero panel and its pills; STRIKE
712
712
  let cam: Fit | null = null;
713
713
  let lastDraw = 0;
714
714
  // Names keep clear of the guides' bubbles and of the ask panel (a page element over the canvas).
715
- const labels = createLabels({ screen: () => ({ w: vw, h: vh }), avoid: () => { const p = document.getElementById('asks'); return p && !p.hidden ? [...bubbleBoxes, p.getBoundingClientRect()] : bubbleBoxes; } });
715
+ const labels = createLabels({ screen: () => ({ w: vw, h: vh }), avoid: () => { const p = document.getElementById('asks'); const all = [...bubbleBoxes, ...chatBubbles.boxes()]; return p && !p.hidden ? [...all, p.getBoundingClientRect()] : all; } });
716
716
  let bubbleBoxes: { left: number; top: number; right: number; bottom: number }[] = [];
717
+ // Room chat (NETPLAY.md section 19): what a hero's player says in the room's chat shows over the hero for a few seconds,
718
+ // clear of the guides' own bubbles. The studio taking a message down takes its bubble too.
719
+ const chatBubbles = createBubbles({ measure: (t) => { ctx.font = BUBBLE_FONT; return ctx.measureText(t).width; }, screen: () => ({ w: vw, h: vh }), avoid: () => bubbleBoxes });
720
+ net.on('say', (e) => chatBubbles.say(e.seat, e.text, { id: e.id, kind: e.kind }));
721
+ net.on('unchat', (e) => { for (const id of e.ids) chatBubbles.remove(id); });
722
+ let shownChat: BubbleOut[] = [];
717
723
  let shownLabels: LabelOut[] = [];
718
724
  function aim(): Fit {
719
725
  const v = lookOnly ? room.viewBody() : null;
@@ -772,6 +778,7 @@ function draw(t: number): void {
772
778
  ctx.font = `600 ${fs}px ui-sans-serif, system-ui, sans-serif`;
773
779
  const names: LabelIn[] = [];
774
780
  const said: { x: number; y: number; head: number; text: string }[] = [];
781
+ const heads = new Map<number, { x: number; y: number; seat: number; self: boolean }>();
775
782
  const mine: ScreenBox[] = []; // your own hero and its name: no bubble covers them
776
783
  const meAt = (() => { const v = room.view().find((b) => !b.bot && b.seat === viewS); return v ? { x: v.x, y: v.y } : null; })();
777
784
  for (const b of room.view()) {
@@ -801,6 +808,7 @@ function draw(t: number): void {
801
808
  const body = { left: sx(x - 24), top: sy(y - R - 12), right: sx(x + 24), bottom: sy(y + R) };
802
809
  if (body.right < 0 || body.left > vw || body.bottom < 0 || body.top > vh) continue; // off screen: no name at the edge
803
810
  if (self) { const w = ctx.measureText(text).width; mine.push(body, { left: sx(x) - w / 2 - 6, top: body.top - 4 - fs * 1.2, right: sx(x) + w / 2 + 6, bottom: body.top }); }
811
+ if (!b.bot && b.seat !== null && b.seat !== undefined) heads.set(b.slot, { x: sx(x), y: body.top - 3, seat: b.seat, self });
804
812
  names.push({ key: b.slot, text, x: sx(x), y: body.top - 3, w: ctx.measureText(text).width, h: fs * 1.2, below: body.bottom + 4 + fs * 1.2, body, self, rank: (b.bot ? (isGuide ? 1 : 2) : 0) * 10_000 + near });
805
813
  const line = bubbles.get(b.slot);
806
814
  // The bubble's tail stops just above the guide's own name, so the name keeps its spot while it speaks.
@@ -830,6 +838,16 @@ function draw(t: number): void {
830
838
  ctx.fillText(l.text, l.cx, l.cy);
831
839
  }
832
840
  ctx.globalAlpha = 1; ctx.textBaseline = 'alphabetic';
841
+ // Room chat's bubbles: over each speaking hero's name (your own placed first), clear of the guides' bubbles.
842
+ if (chatBubbles.size) {
843
+ const anchors: BubbleIn[] = [];
844
+ for (const [slot, h] of heads) {
845
+ const l = shownLabels.find((x) => x.key === slot && x.alpha > 0 && !x.moved);
846
+ anchors.push({ key: h.seat, x: l ? l.cx : h.x, y: l ? l.top - 1 : h.y, self: h.self });
847
+ }
848
+ shownChat = chatBubbles.place(anchors);
849
+ paintBubbles(ctx, shownChat, { paper: '#f6efdc', ink: '#1a1710', edge: 'rgba(40,30,10,.35)' });
850
+ } else shownChat = [];
833
851
  ctx.setTransform(1, 0, 0, 1, 0, 0);
834
852
  hud(t);
835
853
  }
@@ -1116,6 +1134,8 @@ function frame(t: number): void {
1116
1134
 
1117
1135
  // Tonight's experience per hero: the watch page's live scores, and who Auto follows when nobody is slaying.
1118
1136
  net.expose({
1137
+ // Room chat's bubbles on this screen now: whose, what, and where (an end-to-end test reads them).
1138
+ chatBubbles: () => shownChat.map((b) => ({ seat: b.key, text: b.lines.join(' '), alpha: b.alpha, left: Math.round(b.left), top: Math.round(b.top), right: Math.round(b.right), bottom: Math.round(b.bottom) })),
1119
1139
  scores: () => room.view().map((b) => ({ slot: b.slot, seat: b.seat, bot: b.bot, score: b.score })),
1120
1140
  // The guides, for the e2e probe: each guide's goal now, and the log of goals, asks and lines (seats and ids only).
1121
1141
  guides: () => ({
@@ -39,7 +39,7 @@
39
39
  import { createNetplay, Roster, q, lerp, capMove, PALETTE, AI_MARK, type RoleChange, type RoundInfo, type RoundResult, type Skill, type Slot, type Snapshot } from '@homie-rocks/studio/netplay';
40
40
  // The port toolkit: its probe (what `homie-studio port check` reads for the owner tests, and sandbox + audio shims),
41
41
  // and a flat world on every screen (port/view.ts: the camera, and name labels that never pile up).
42
- import { createLabels, exposePort, fitView, type LabelIn, type LabelOut } from '@homie-rocks/studio/port';
42
+ import { BUBBLE_FONT, createBubbles, createLabels, exposePort, fitView, paintBubbles, type BubbleIn, type BubbleOut, type LabelIn, type LabelOut } from '@homie-rocks/studio/port';
43
43
  // The Game Lab: tunables, phases, tracks and overlays (no-ops outside the lab).
44
44
  import { lab } from '@homie-rocks/studio/lab';
45
45
  import tuning from '../tunables.json';
@@ -768,6 +768,12 @@ resize();
768
768
 
769
769
  const cam = { x: W / 2, y: H / 2, scale: 0 };
770
770
  const labels = createLabels({ screen: () => ({ w: innerWidth, h: innerHeight }) });
771
+ // Room chat (NETPLAY.md section 19): what a player says in the room's chat (the play page's Chat, or a quick line) shows
772
+ // over their body for a few seconds, when they want it there. The studio taking a message down takes its bubble too.
773
+ const bubbles = createBubbles({ measure: (t) => { ctx.font = BUBBLE_FONT; return ctx.measureText(t).width; }, screen: () => ({ w: innerWidth, h: innerHeight }) });
774
+ net.on('say', (s) => bubbles.say(s.seat, s.text, { id: s.id, kind: s.kind }));
775
+ net.on('unchat', (e) => { for (const id of e.ids) bubbles.remove(id); });
776
+ let shownBubbles: BubbleOut[] = [];
771
777
  let shownLabels: LabelOut[] = [];
772
778
  let lastDraw = 0;
773
779
  /** Where a seat's body is drawn now (host: the real body; replica: interpolated), or null. */
@@ -868,6 +874,7 @@ function draw(t: number): void {
868
874
  const fs = Math.round(Math.max(15, Math.min(22, (15 * Math.min(cw, ch)) / 720)));
869
875
  ctx.font = `600 ${fs}px ui-sans-serif, system-ui, sans-serif`;
870
876
  const tags: LabelIn[] = [];
877
+ const heads = new Map<number, { x: number; y: number; seat: number; mine: boolean }>();
871
878
  const viewAt = list.find((a) => a.mine) ?? null;
872
879
  for (const a of list) {
873
880
  const colour = colourOf(a.slot, a.bot ? null : a.seat);
@@ -884,6 +891,7 @@ function draw(t: number): void {
884
891
  const text = a.mine && !net.watching ? 'You' : label(a.name, a.bot);
885
892
  const sx = cw / 2 + (a.x - cam.x) * scale; const sy = ch / 2 + (a.y - cam.y) * scale; const r = (R_AV + (a.mine ? 7 : 0)) * scale;
886
893
  if (sx + r < 0 || sx - r > cw || sy + r < 0 || sy - r > ch) continue; // off screen: no name at the edge
894
+ if (a.seat !== null && !a.bot) heads.set(a.slot, { x: sx, y: sy - r - 5, seat: a.seat, mine: a.mine });
887
895
  // People before bots, nearer the view's body first; each keeps off the others' bodies when it can.
888
896
  tags.push({ key: a.slot, text, x: sx, y: sy - r - 5, w: ctx.measureText(text).width, h: fs * 1.2, below: sy + r + 4 + fs * 1.2, body: { left: sx - r, top: sy - r, right: sx + r, bottom: sy + r }, self: a.mine, rank: (a.bot ? 10_000 : 0) + (viewAt ? Math.hypot(a.x - viewAt.x, a.y - viewAt.y) : 0) });
889
897
  }
@@ -909,6 +917,16 @@ function draw(t: number): void {
909
917
  ctx.fillText(l.text, l.cx, l.cy);
910
918
  }
911
919
  ctx.globalAlpha = 1; ctx.textBaseline = 'alphabetic';
920
+ // Speech bubbles (room chat): over each speaker's name, the view's own player first.
921
+ if (bubbles.size) {
922
+ const anchors: BubbleIn[] = [];
923
+ for (const [slot, h] of heads) {
924
+ const l = shownLabels.find((x) => x.key === slot && x.alpha > 0 && !x.moved);
925
+ anchors.push({ key: h.seat, x: l ? l.cx : h.x, y: l ? l.top - 1 : h.y, self: h.mine });
926
+ }
927
+ shownBubbles = bubbles.place(anchors);
928
+ paintBubbles(ctx, shownBubbles);
929
+ } else shownBubbles = [];
912
930
 
913
931
  hud(cw, ch, phone, list);
914
932
  if (stick.active) {
@@ -985,6 +1003,8 @@ function frame(t: number): void {
985
1003
  }
986
1004
 
987
1005
  net.expose({
1006
+ // Room chat's bubbles on this screen now: whose, what, and where (an end-to-end test reads them).
1007
+ bubbles: () => shownBubbles.map((b) => ({ seat: b.key, text: b.lines.join(' '), alpha: b.alpha, left: Math.round(b.left), top: Math.round(b.top), right: Math.round(b.right), bottom: Math.round(b.bottom) })),
988
1008
  self: () => (me.has ? { x: me.x, y: me.y } : null),
989
1009
  peer: (seat: number) => {
990
1010
  if (hosting) { const b = [...bodies.values()].find((x) => x.seat === seat); return b ? { x: b.x, y: b.y } : null; }
@@ -0,0 +1,203 @@
1
+ # Rights: Gem Rush 3D
2
+
3
+ Every file this game ships, where it came from, its licence and what that allows. Written by `homie-studio assets rights gem-rush-3d` from `assets/manifest.json`; do not edit it by hand.
4
+
5
+ ## What the providers' terms say
6
+
7
+ Read on the dates shown. Terms change: read the live page again before publishing anything commercial.
8
+
9
+ - **Kenney** (https://kenney.nl/support, read 2026-10-02): Public domain (CC0): use in any project, commercial or not; credit is not required (kindly given anyway).
10
+
11
+ ## Files
12
+
13
+ ### animal-cat
14
+
15
+ - **What:** character (Players/Cat): `public/models/animal-cat.glb`
16
+ - **From:** from the Homie starter library: Kenney Cube Pets, animal-cat (https://kenney.nl/assets/cube-pets); library, in game (src/main.ts)
17
+ - **Licence:** CC0 1.0 (public domain) (https://creativecommons.org/publicdomain/zero/1.0/); owner: Kenney
18
+ - **A remix:** a remix gets this file (fetched from this studio's site, checked by SHA-256)
19
+ - **Notes:** From the Homie starter library (kenney-cube-pets); the pack's own licence text: licenses/kenney-cube-pets.txt. `game new` fetches the file; the repository holds no models.
20
+
21
+ ### animal-caterpillar
22
+
23
+ - **What:** character (Players/Caterpillar): `public/models/animal-caterpillar.glb`
24
+ - **From:** from the Homie starter library: Kenney Cube Pets, animal-caterpillar (https://kenney.nl/assets/cube-pets); library, in game (src/main.ts)
25
+ - **Licence:** CC0 1.0 (public domain) (https://creativecommons.org/publicdomain/zero/1.0/); owner: Kenney
26
+ - **A remix:** a remix gets this file (fetched from this studio's site, checked by SHA-256)
27
+ - **Notes:** From the Homie starter library (kenney-cube-pets); the pack's own licence text: licenses/kenney-cube-pets.txt. `game new` fetches the file; the repository holds no models.
28
+
29
+ ### animal-chick
30
+
31
+ - **What:** character (Players/Chick): `public/models/animal-chick.glb`
32
+ - **From:** from the Homie starter library: Kenney Cube Pets, animal-chick (https://kenney.nl/assets/cube-pets); library, in game (src/main.ts)
33
+ - **Licence:** CC0 1.0 (public domain) (https://creativecommons.org/publicdomain/zero/1.0/); owner: Kenney
34
+ - **A remix:** a remix gets this file (fetched from this studio's site, checked by SHA-256)
35
+ - **Notes:** From the Homie starter library (kenney-cube-pets); the pack's own licence text: licenses/kenney-cube-pets.txt. `game new` fetches the file; the repository holds no models.
36
+
37
+ ### animal-crab
38
+
39
+ - **What:** character (Players/Crab): `public/models/animal-crab.glb`
40
+ - **From:** from the Homie starter library: Kenney Cube Pets, animal-crab (https://kenney.nl/assets/cube-pets); library, in game (src/main.ts)
41
+ - **Licence:** CC0 1.0 (public domain) (https://creativecommons.org/publicdomain/zero/1.0/); owner: Kenney
42
+ - **A remix:** a remix gets this file (fetched from this studio's site, checked by SHA-256)
43
+ - **Notes:** From the Homie starter library (kenney-cube-pets); the pack's own licence text: licenses/kenney-cube-pets.txt. `game new` fetches the file; the repository holds no models.
44
+
45
+ ### animal-elephant
46
+
47
+ - **What:** character (Players/Elephant): `public/models/animal-elephant.glb`
48
+ - **From:** from the Homie starter library: Kenney Cube Pets, animal-elephant (https://kenney.nl/assets/cube-pets); library, in game (src/main.ts)
49
+ - **Licence:** CC0 1.0 (public domain) (https://creativecommons.org/publicdomain/zero/1.0/); owner: Kenney
50
+ - **A remix:** a remix gets this file (fetched from this studio's site, checked by SHA-256)
51
+ - **Notes:** From the Homie starter library (kenney-cube-pets); the pack's own licence text: licenses/kenney-cube-pets.txt. `game new` fetches the file; the repository holds no models.
52
+
53
+ ### animal-fox
54
+
55
+ - **What:** character (Players/Fox): `public/models/animal-fox.glb`
56
+ - **From:** from the Homie starter library: Kenney Cube Pets, animal-fox (https://kenney.nl/assets/cube-pets); library, in game (src/main.ts)
57
+ - **Licence:** CC0 1.0 (public domain) (https://creativecommons.org/publicdomain/zero/1.0/); owner: Kenney
58
+ - **A remix:** a remix gets this file (fetched from this studio's site, checked by SHA-256)
59
+ - **Notes:** From the Homie starter library (kenney-cube-pets); the pack's own licence text: licenses/kenney-cube-pets.txt. `game new` fetches the file; the repository holds no models.
60
+
61
+ ### animal-panda
62
+
63
+ - **What:** character (Players/Panda): `public/models/animal-panda.glb`
64
+ - **From:** from the Homie starter library: Kenney Cube Pets, animal-panda (https://kenney.nl/assets/cube-pets); library, in game (src/main.ts)
65
+ - **Licence:** CC0 1.0 (public domain) (https://creativecommons.org/publicdomain/zero/1.0/); owner: Kenney
66
+ - **A remix:** a remix gets this file (fetched from this studio's site, checked by SHA-256)
67
+ - **Notes:** From the Homie starter library (kenney-cube-pets); the pack's own licence text: licenses/kenney-cube-pets.txt. `game new` fetches the file; the repository holds no models.
68
+
69
+ ### animal-pig
70
+
71
+ - **What:** character (Players/Pig): `public/models/animal-pig.glb`
72
+ - **From:** from the Homie starter library: Kenney Cube Pets, animal-pig (https://kenney.nl/assets/cube-pets); library, in game (src/main.ts)
73
+ - **Licence:** CC0 1.0 (public domain) (https://creativecommons.org/publicdomain/zero/1.0/); owner: Kenney
74
+ - **A remix:** a remix gets this file (fetched from this studio's site, checked by SHA-256)
75
+ - **Notes:** From the Homie starter library (kenney-cube-pets); the pack's own licence text: licenses/kenney-cube-pets.txt. `game new` fetches the file; the repository holds no models.
76
+
77
+ ### bush
78
+
79
+ - **What:** prop (Places/Bush): `public/models/bush.glb`
80
+ - **From:** from the Homie starter library: Kenney Nature Kit, tree-fat (https://kenney.nl/assets/nature-kit); library, in game (src/main.ts)
81
+ - **Licence:** CC0 1.0 (public domain) (https://creativecommons.org/publicdomain/zero/1.0/); owner: Kenney
82
+ - **A remix:** a remix gets this file (fetched from this studio's site, checked by SHA-256)
83
+ - **Notes:** From the Homie starter library (kenney-nature-kit); the pack's own licence text: licenses/kenney-nature-kit.txt. `game new` fetches the file; the repository holds no models.
84
+
85
+ ### campfire
86
+
87
+ - **What:** prop (Places/Campfire): `public/models/campfire.glb`
88
+ - **From:** from the Homie starter library: Kenney Nature Kit, campfire-stones (https://kenney.nl/assets/nature-kit); library, in game (src/main.ts)
89
+ - **Licence:** CC0 1.0 (public domain) (https://creativecommons.org/publicdomain/zero/1.0/); owner: Kenney
90
+ - **A remix:** a remix gets this file (fetched from this studio's site, checked by SHA-256)
91
+ - **Notes:** From the Homie starter library (kenney-nature-kit); the pack's own licence text: licenses/kenney-nature-kit.txt. `game new` fetches the file; the repository holds no models.
92
+
93
+ ### fence
94
+
95
+ - **What:** kit (Places/Fence): `public/models/fence.glb`
96
+ - **From:** from the Homie starter library: Kenney Nature Kit, fence-simple (https://kenney.nl/assets/nature-kit); library, in game (src/main.ts)
97
+ - **Licence:** CC0 1.0 (public domain) (https://creativecommons.org/publicdomain/zero/1.0/); owner: Kenney
98
+ - **A remix:** a remix gets this file (fetched from this studio's site, checked by SHA-256)
99
+ - **Notes:** From the Homie starter library (kenney-nature-kit); the pack's own licence text: licenses/kenney-nature-kit.txt. `game new` fetches the file; the repository holds no models.
100
+
101
+ ### firewood
102
+
103
+ - **What:** prop (Places/Firewood): `public/models/firewood.glb`
104
+ - **From:** from the Homie starter library: Kenney Nature Kit, campfire-logs (https://kenney.nl/assets/nature-kit); library, in game (src/main.ts)
105
+ - **Licence:** CC0 1.0 (public domain) (https://creativecommons.org/publicdomain/zero/1.0/); owner: Kenney
106
+ - **A remix:** a remix gets this file (fetched from this studio's site, checked by SHA-256)
107
+ - **Notes:** From the Homie starter library (kenney-nature-kit); the pack's own licence text: licenses/kenney-nature-kit.txt. `game new` fetches the file; the repository holds no models.
108
+
109
+ ### flower-purple
110
+
111
+ - **What:** prop (Places/Purple flower): `public/models/flower-purple.glb`
112
+ - **From:** from the Homie starter library: Kenney Nature Kit, flower-purple-a (https://kenney.nl/assets/nature-kit); library, in game (src/main.ts)
113
+ - **Licence:** CC0 1.0 (public domain) (https://creativecommons.org/publicdomain/zero/1.0/); owner: Kenney
114
+ - **A remix:** a remix gets this file (fetched from this studio's site, checked by SHA-256)
115
+ - **Notes:** From the Homie starter library (kenney-nature-kit); the pack's own licence text: licenses/kenney-nature-kit.txt. `game new` fetches the file; the repository holds no models.
116
+
117
+ ### flower-red
118
+
119
+ - **What:** prop (Places/Red flower): `public/models/flower-red.glb`
120
+ - **From:** from the Homie starter library: Kenney Nature Kit, flower-red-a (https://kenney.nl/assets/nature-kit); library, in game (src/main.ts)
121
+ - **Licence:** CC0 1.0 (public domain) (https://creativecommons.org/publicdomain/zero/1.0/); owner: Kenney
122
+ - **A remix:** a remix gets this file (fetched from this studio's site, checked by SHA-256)
123
+ - **Notes:** From the Homie starter library (kenney-nature-kit); the pack's own licence text: licenses/kenney-nature-kit.txt. `game new` fetches the file; the repository holds no models.
124
+
125
+ ### flower-yellow
126
+
127
+ - **What:** prop (Places/Yellow flower): `public/models/flower-yellow.glb`
128
+ - **From:** from the Homie starter library: Kenney Nature Kit, flower-yellow-a (https://kenney.nl/assets/nature-kit); library, in game (src/main.ts)
129
+ - **Licence:** CC0 1.0 (public domain) (https://creativecommons.org/publicdomain/zero/1.0/); owner: Kenney
130
+ - **A remix:** a remix gets this file (fetched from this studio's site, checked by SHA-256)
131
+ - **Notes:** From the Homie starter library (kenney-nature-kit); the pack's own licence text: licenses/kenney-nature-kit.txt. `game new` fetches the file; the repository holds no models.
132
+
133
+ ### gem
134
+
135
+ - **What:** prop (Items/Gem): `public/models/gem.glb`
136
+ - **From:** from the Homie starter library: Kenney Platformer Kit, jewel (https://kenney.nl/assets/platformer-kit); library, in game (src/main.ts)
137
+ - **Licence:** CC0 1.0 (public domain) (https://creativecommons.org/publicdomain/zero/1.0/); owner: Kenney
138
+ - **A remix:** a remix gets this file (fetched from this studio's site, checked by SHA-256)
139
+ - **Notes:** From the Homie starter library (kenney-platformer-kit); the pack's own licence text: licenses/kenney-platformer-kit.txt. `game new` fetches the file; the repository holds no models.
140
+
141
+ ### grass
142
+
143
+ - **What:** prop (Places/Grass): `public/models/grass.glb`
144
+ - **From:** from the Homie starter library: Kenney Nature Kit, grass-leafs-large (https://kenney.nl/assets/nature-kit); library, in game (src/main.ts)
145
+ - **Licence:** CC0 1.0 (public domain) (https://creativecommons.org/publicdomain/zero/1.0/); owner: Kenney
146
+ - **A remix:** a remix gets this file (fetched from this studio's site, checked by SHA-256)
147
+ - **Notes:** From the Homie starter library (kenney-nature-kit); the pack's own licence text: licenses/kenney-nature-kit.txt. `game new` fetches the file; the repository holds no models.
148
+
149
+ ### log
150
+
151
+ - **What:** prop (Places/Log seat): `public/models/log.glb`
152
+ - **From:** from the Homie starter library: Kenney Nature Kit, log-large (https://kenney.nl/assets/nature-kit); library, in game (src/main.ts)
153
+ - **Licence:** CC0 1.0 (public domain) (https://creativecommons.org/publicdomain/zero/1.0/); owner: Kenney
154
+ - **A remix:** a remix gets this file (fetched from this studio's site, checked by SHA-256)
155
+ - **Notes:** From the Homie starter library (kenney-nature-kit); the pack's own licence text: licenses/kenney-nature-kit.txt. `game new` fetches the file; the repository holds no models.
156
+
157
+ ### mushrooms
158
+
159
+ - **What:** prop (Places/Mushrooms): `public/models/mushrooms.glb`
160
+ - **From:** from the Homie starter library: Kenney Nature Kit, mushroom-red-group (https://kenney.nl/assets/nature-kit); library, in game (src/main.ts)
161
+ - **Licence:** CC0 1.0 (public domain) (https://creativecommons.org/publicdomain/zero/1.0/); owner: Kenney
162
+ - **A remix:** a remix gets this file (fetched from this studio's site, checked by SHA-256)
163
+ - **Notes:** From the Homie starter library (kenney-nature-kit); the pack's own licence text: licenses/kenney-nature-kit.txt. `game new` fetches the file; the repository holds no models.
164
+
165
+ ### rock
166
+
167
+ - **What:** prop (Places/Rock): `public/models/rock.glb`
168
+ - **From:** from the Homie starter library: Kenney Nature Kit, stone-large-d (https://kenney.nl/assets/nature-kit); library, in game (src/main.ts)
169
+ - **Licence:** CC0 1.0 (public domain) (https://creativecommons.org/publicdomain/zero/1.0/); owner: Kenney
170
+ - **A remix:** a remix gets this file (fetched from this studio's site, checked by SHA-256)
171
+ - **Notes:** From the Homie starter library (kenney-nature-kit); the pack's own licence text: licenses/kenney-nature-kit.txt. `game new` fetches the file; the repository holds no models.
172
+
173
+ ### stone
174
+
175
+ - **What:** prop (Places/Standing stone): `public/models/stone.glb`
176
+ - **From:** from the Homie starter library: Kenney Nature Kit, stone-tall-c (https://kenney.nl/assets/nature-kit); library, in game (src/main.ts)
177
+ - **Licence:** CC0 1.0 (public domain) (https://creativecommons.org/publicdomain/zero/1.0/); owner: Kenney
178
+ - **A remix:** a remix gets this file (fetched from this studio's site, checked by SHA-256)
179
+ - **Notes:** From the Homie starter library (kenney-nature-kit); the pack's own licence text: licenses/kenney-nature-kit.txt. `game new` fetches the file; the repository holds no models.
180
+
181
+ ### tree-oak
182
+
183
+ - **What:** prop (Places/Oak tree): `public/models/tree-oak.glb`
184
+ - **From:** from the Homie starter library: Kenney Nature Kit, tree-oak (https://kenney.nl/assets/nature-kit); library, in game (src/main.ts)
185
+ - **Licence:** CC0 1.0 (public domain) (https://creativecommons.org/publicdomain/zero/1.0/); owner: Kenney
186
+ - **A remix:** a remix gets this file (fetched from this studio's site, checked by SHA-256)
187
+ - **Notes:** From the Homie starter library (kenney-nature-kit); the pack's own licence text: licenses/kenney-nature-kit.txt. `game new` fetches the file; the repository holds no models.
188
+
189
+ ### tree-pine
190
+
191
+ - **What:** prop (Places/Pine): `public/models/tree-pine.glb`
192
+ - **From:** from the Homie starter library: Kenney Nature Kit, tree-pine-round-a (https://kenney.nl/assets/nature-kit); library, in game (src/main.ts)
193
+ - **Licence:** CC0 1.0 (public domain) (https://creativecommons.org/publicdomain/zero/1.0/); owner: Kenney
194
+ - **A remix:** a remix gets this file (fetched from this studio's site, checked by SHA-256)
195
+ - **Notes:** From the Homie starter library (kenney-nature-kit); the pack's own licence text: licenses/kenney-nature-kit.txt. `game new` fetches the file; the repository holds no models.
196
+
197
+ ### tree-round
198
+
199
+ - **What:** prop (Places/Round tree): `public/models/tree-round.glb`
200
+ - **From:** from the Homie starter library: Kenney Nature Kit, tree-default (https://kenney.nl/assets/nature-kit); library, in game (src/main.ts)
201
+ - **Licence:** CC0 1.0 (public domain) (https://creativecommons.org/publicdomain/zero/1.0/); owner: Kenney
202
+ - **A remix:** a remix gets this file (fetched from this studio's site, checked by SHA-256)
203
+ - **Notes:** From the Homie starter library (kenney-nature-kit); the pack's own licence text: licenses/kenney-nature-kit.txt. `game new` fetches the file; the repository holds no models.