@homie-rocks/studio 0.30.2 → 0.31.1

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 (90) hide show
  1. package/CHANGELOG.md +108 -1
  2. package/README.md +349 -23
  3. package/assets/animate.ts +16 -6
  4. package/assets/assets.ts +14 -3
  5. package/assets/render-page.ts +10 -2
  6. package/assets/safety.mjs +7 -1
  7. package/bin/homie-studio.mjs +204 -124
  8. package/lib/art-cli.mjs +61 -18
  9. package/lib/art-tools.mjs +53 -10
  10. package/lib/asset-check.mjs +249 -46
  11. package/lib/asset-import.mjs +11 -5
  12. package/lib/asset-manifest.mjs +128 -51
  13. package/lib/build.mjs +239 -72
  14. package/lib/characters.mjs +119 -20
  15. package/lib/check.mjs +110 -7
  16. package/lib/chrome.mjs +11 -0
  17. package/lib/clips.mjs +54 -0
  18. package/lib/cloudflare.mjs +203 -10
  19. package/lib/codex.mjs +6 -0
  20. package/lib/collision.mjs +245 -0
  21. package/lib/decisions.mjs +2 -2
  22. package/lib/deploy-state.mjs +166 -0
  23. package/lib/dev.mjs +430 -0
  24. package/lib/directory.mjs +172 -9
  25. package/lib/doctor.mjs +12 -11
  26. package/lib/handoff.mjs +2 -6
  27. package/lib/library.mjs +1 -1
  28. package/lib/mcp-tools.mjs +42 -37
  29. package/lib/mcp.mjs +1 -1
  30. package/lib/net.mjs +81 -2
  31. package/lib/node-version.mjs +40 -0
  32. package/lib/office.mjs +6 -6
  33. package/lib/optimise.mjs +39 -4
  34. package/lib/parts-build.mjs +169 -0
  35. package/lib/parts-cli.mjs +104 -0
  36. package/lib/parts-store.mjs +342 -0
  37. package/lib/parts-tools.mjs +92 -0
  38. package/lib/parts.mjs +720 -0
  39. package/lib/perf.mjs +131 -31
  40. package/lib/port-check.mjs +40 -6
  41. package/lib/preview.mjs +77 -0
  42. package/lib/routes.mjs +240 -0
  43. package/lib/scaffold.mjs +83 -22
  44. package/lib/shoot.mjs +307 -0
  45. package/lib/site.mjs +1 -2
  46. package/lib/stage.mjs +118 -0
  47. package/lib/studio.mjs +0 -66
  48. package/lib/style-board.mjs +49 -6
  49. package/lib/template-history.json +101 -0
  50. package/lib/trailer.mjs +47 -0
  51. package/lib/typecheck.mjs +107 -0
  52. package/mcp/ui/lineup.js +3 -3
  53. package/mcp/ui/rights.js +1 -3
  54. package/netplay/NETPLAY.md +552 -16
  55. package/netplay/netplay.ts +950 -43
  56. package/package.json +10 -1
  57. package/parts/PARTS.md +169 -0
  58. package/port/index.ts +8 -2
  59. package/port/personas.ts +275 -0
  60. package/port/powerups.ts +357 -0
  61. package/port/probe.ts +38 -2
  62. package/port/room.ts +329 -40
  63. package/site/SITE.md +102 -20
  64. package/starters/ember-vale/index.html +1 -1
  65. package/starters/ember-vale/src/main.ts +7 -1
  66. package/starters/gem-rush/index.html +1 -1
  67. package/starters/gem-rush/src/main.ts +41 -12
  68. package/starters/gem-rush-3d/assets/RIGHTS.md +0 -24
  69. package/starters/gem-rush-3d/assets/manifest.json +0 -24
  70. package/starters/gem-rush-3d/index.html +1 -1
  71. package/starters/gem-rush-3d/src/main.ts +50 -11
  72. package/starters/hero-rush-3d/assets/RIGHTS.md +0 -19
  73. package/starters/hero-rush-3d/assets/manifest.json +0 -19
  74. package/starters/hero-rush-3d/index.html +1 -1
  75. package/starters/hero-rush-3d/src/main.ts +50 -11
  76. package/worker/arrival.mjs +4 -1
  77. package/worker/discover.mjs +11 -25
  78. package/worker/index.mjs +142 -62
  79. package/worker/license.mjs +45 -41
  80. package/worker/office-page.mjs +0 -4
  81. package/worker/office-schema.mjs +6 -0
  82. package/worker/office.mjs +19 -23
  83. package/worker/pages.mjs +156 -29
  84. package/worker/parts.mjs +275 -0
  85. package/worker/room.mjs +157 -14
  86. package/worker/schema.mjs +6 -8
  87. package/worker/seats.mjs +71 -0
  88. package/worker/site.mjs +51 -53
  89. package/worker/version.mjs +1 -1
  90. package/lib/remix-assets.mjs +0 -84
@@ -49,6 +49,13 @@
49
49
  * byte). A model that is refused or not there is drawn as a stand-in in the same colours: a round never waits on, or
50
50
  * breaks for, a model.
51
51
  *
52
+ * HEADINGS, AND THERE ARE TWO: a body's facing (`fa`, drawn as `yaw`) is `Math.atan2(dx, dy)` in the game's own x and y, so yaw 0 faces +y
53
+ * (three.js's +z, the way the models are built) and it goes straight into `rotation.y`. That is also the yaw
54
+ * `@homie-rocks/camera` means (0 faces +Z, positive turns toward +X, radians), so a follow or chase camera from that
55
+ * package takes a body's facing as it stands. The knock and aim angles (`ang`, `base`) are the OTHER kind,
56
+ * `Math.atan2(dy, dx)`: 0 faces +x. Never hand one of those to a camera or to `rotation.y` without converting it:
57
+ * `headingFrom({ zero: '+x', toward: '+z' })` from `@homie-rocks/camera/heading.js` is the adapter.
58
+ *
52
59
  * PHONE BUDGETS: under 100 draw calls and 150k triangles a frame (the `drawCalls` and `triangles` probes read
53
60
  * renderer.info), the pixel ratio at most 1.5 on a phone and 2 on a computer, and no shadow map on a phone: a soft
54
61
  * disc under each body, coin, tree and rock (a computer draws the sun's real shadows). Repeated things are one
@@ -70,7 +77,7 @@ import { mergeGeometries } from 'three/examples/jsm/utils/BufferGeometryUtils.js
70
77
  import { createModels, instancedCopies, placeCopy, repaint, stylize, type Copies } from '@homie-rocks/studio/assets';
71
78
  // Characters that move well: clips from the skeleton's clip library, blended, layered and tuned (Game Lab tunables).
72
79
  import { crowd, loadCharacter, type Character } from '@homie-rocks/studio/animate';
73
- import { createNetplay, Roster, q, lerp, capMove, skillPreset, PALETTE, AI_MARK, type RoleChange, type RoundInfo, type RoundResult, type Skill, type Slot, type Snapshot } from '@homie-rocks/studio/netplay';
80
+ import { createNetplay, guardGestures, Roster, q, lerp, capMove, skillPreset, PALETTE, AI_MARK, type Peer, type RoleChange, type RoundInfo, type RoundResult, type Skill, type Slot, type Snapshot } from '@homie-rocks/studio/netplay';
74
81
  // The port toolkit: its probe (what `homie-studio port check` and `perf` read, and sandbox + audio shims), and name
75
82
  // labels that never pile up (port/view.ts).
76
83
  import { createBubbles, createLabels, exposePort, paintBubbles, type BubbleIn, type BubbleOut, type LabelIn, type LabelOut } from '@homie-rocks/studio/port';
@@ -153,7 +160,7 @@ interface Gem { id: number; x: number; y: number }
153
160
  /** Slow state, on the keyed state channel (net.state('zone', ...)), not in the 20 Hz snapshot. */
154
161
  interface Zone { n: number; x: number; y: number; r: number; until: number }
155
162
  /** `firstRound`: each person's first whole round here (seat, round), so a promoted host eases the same round. */
156
- interface Ckpt { round: RoundInfo; bodies: Body[]; gems: Gem[]; roster: Slot[]; tick: number; gemSeq: number; firstRound?: [number, number][] }
163
+ interface Ckpt { round: RoundInfo; bodies: Body[]; gems: Gem[]; roster: Slot[]; tick: number; gemSeq: number; occ?: [number, number][]; firstRound?: [number, number][] }
157
164
 
158
165
  /* --------------------------------------------------------------- the net */
159
166
  /**
@@ -165,6 +172,9 @@ const MOVEMENT: 'owner' | 'host' = (() => { try { return new URLSearchParams(loc
165
172
  // caps: its bots read the dial ('skill'), and its Roster takes an AI's slot for it ('agents': the join passes p.agent).
166
173
  // arrival 'game': the play page's arrival card stays until this game says it is playable (THE ARRIVAL, by the HUD).
167
174
  const net = createNetplay<Snap, Avatar, Ckpt>({ game: 'hero-rush-3d', maxPlayers: MAX_SLOTS, movement: MOVEMENT, snapshotHz: 20, inputHz: 20, checkpointMs: 1000, caps: ['skill', 'agents'], arrival: 'game', checkpoint: () => checkpoint() });
175
+ // A touch game guards its own page (NETPLAY.md section 24): a long press on the canvas never selects text or raises
176
+ // the copy/paste callout on a phone, and a touch on it never pans or zooms the page.
177
+ guardGestures({ touch: 'canvas' });
168
178
  /** The Roster keeps the server's AI seats (revision 6): the policy is read whenever it fills. */
169
179
  const policy = () => net.policy;
170
180
 
@@ -243,9 +253,12 @@ function newZone(n: number): Zone {
243
253
  function setZone(z: Zone): void { zone = z; net.state('zone', z); }
244
254
 
245
255
  /** A round starts with every hero on its spawn ring facing the camera (fa 0): a line-up the hero intro opens on. */
246
- function startRound(n: number): void {
256
+ function startRound(n: number, rollover = false): void {
247
257
  const now = net.now();
248
258
  roster.trim();
259
+ // The next round of a running room: the trim is what makes room (the seats a server no longer keeps for AI go
260
+ // here), so whoever was seated with no body is given one now, before the bodies are placed.
261
+ if (rollover && !net.offline) seatWaiting();
249
262
  syncBodiesFromRoster();
250
263
  let i = 0;
251
264
  for (const b of [...bodies.values()].sort((a, c) => a.slot - c.slot)) {
@@ -290,7 +303,9 @@ function becomeHost(e: RoleChange<Snap, Ckpt>): void {
290
303
  welcome(seat);
291
304
  }
292
305
  // Whoever is connected now is who plays: seats that left during the gap become bots.
293
- const peers = net.offline ? [{ seat: 0, name: 'You' }] : [...net.peers.values()].filter((p) => p.seat !== null).map((p) => ({ seat: p.seat, name: p.name, agent: p.agent ?? null }));
306
+ const peers = net.offline ? [{ seat: 0, name: 'You' }] : [...net.peers.values()].filter((p) => p.seat !== null).map((p) => ({ seat: p.seat, name: p.name, agent: p.agent ?? null, occ: p.occ ?? null }));
307
+ // `occ` (NETPLAY.md section 25): which stay in its seat each peer's is, so a seat number that changed hands while
308
+ // nobody was hosting is claimed afresh instead of inheriting the last player's body in silence.
294
309
  const { claimed } = roster.reconcile(peers);
295
310
  syncBodiesFromRoster();
296
311
  for (const s of claimed) welcome(s.seat);
@@ -308,7 +323,7 @@ function becomeHost(e: RoleChange<Snap, Ckpt>): void {
308
323
  function restore(e: RoleChange<Snap, Ckpt>): void {
309
324
  const ck = e.ckpt?.d ?? null;
310
325
  if (ck) {
311
- roster = Roster.from(ck.roster, { min: MIN_SLOTS, max: MAX_SLOTS, botName, policy });
326
+ roster = Roster.from(ck.roster, { min: MIN_SLOTS, max: MAX_SLOTS, botName, policy }, ck.occ ?? null);
312
327
  bodies = new Map(ck.bodies.map((b) => [b.slot, { ...b }]));
313
328
  gems = ck.gems.map((g) => ({ ...g }));
314
329
  gemSeq = ck.gemSeq;
@@ -352,6 +367,8 @@ function checkpoint(): Ckpt {
352
367
  bodies: [...bodies.values()].map((b) => ({ ...b })),
353
368
  gems: gems.map((g) => ({ ...g })),
354
369
  roster: roster.toJSON(),
370
+ // Whose stay each body is, beside the roster: how the next host tells a player who came back from a new one.
371
+ occ: roster.occupants(),
355
372
  tick,
356
373
  gemSeq,
357
374
  firstRound: [...firstRound],
@@ -362,16 +379,31 @@ net.on('role', (e) => {
362
379
  if (e.role === 'host') becomeHost(e);
363
380
  else { hosting = false; }
364
381
  });
365
- net.on('join', (p) => {
366
- if (!hosting || p.seat === null) return;
382
+ /** Host: a body for somebody the relay seated. False when every body is somebody's (they wait: see seatWaiting). */
383
+ function giveBody(p: Peer): boolean {
384
+ if (p.seat === null) return false;
367
385
  // An AI takes a seat kept for AI, a person never does (revision 6: the Roster needs p.agent for that).
368
- const c = roster.claim(p.seat, p.name, p.agent ? { role: p.agent.role, hands: p.agent.hands } : null);
369
- if (!c) return; // full: they watch
386
+ const c = roster.claim(p.seat, p.name, p.agent ? { role: p.agent.role, hands: p.agent.hands } : null, p.occ ?? null);
387
+ if (!c) return false;
370
388
  welcome(p.seat);
371
389
  syncBodiesFromRoster();
372
390
  const b = bodies.get(c.slot.slot);
373
391
  // The arriving human takes over the bot's body where it stands, score and all (reset = "adopt this position").
374
392
  if (b) { b.vx = 0; b.vy = 0; b.kvx = 0; b.kvy = 0; b.knockUntil = 0; hostMoved(b); }
393
+ return true;
394
+ }
395
+ /**
396
+ * SEAT WHOEVER IS WAITING (NETPLAY.md section 28). The relay seats a person; this host gives them a body. A claim that
397
+ * found every body taken (a server's AI seats were still kept, say) used to be tried once: that player watched for the
398
+ * rest of the visit, online, with nothing said. It is tried again whenever a body frees up and at every round start.
399
+ */
400
+ function seatWaiting(): boolean {
401
+ let any = false;
402
+ for (const p of net.peers.values()) if (p.seat !== null && !roster.bySeat(p.seat) && giveBody(p)) any = true;
403
+ return any;
404
+ }
405
+ net.on('join', (p) => {
406
+ if (!hosting || !giveBody(p)) return;
375
407
  publishRoster();
376
408
  net.snapshot(buildSnap(), tick, true);
377
409
  });
@@ -380,7 +412,10 @@ net.on('leave', (p) => {
380
412
  roster.release(p.seat); // their body stays, driven by a bot
381
413
  firstRound.delete(p.seat); // whoever sits in this seat next is new here
382
414
  syncBodiesFromRoster();
415
+ // A body just went to a bot: somebody seated with none takes it now.
416
+ const seated = seatWaiting();
383
417
  publishRoster();
418
+ if (seated) net.snapshot(buildSnap(), tick, true);
384
419
  });
385
420
  // The server's policy changed: its AI seats come (or go between rounds) at once.
386
421
  net.on('policy', () => { if (!hosting) return; roster.fill(); syncBodiesFromRoster(); publishRoster(); });
@@ -882,7 +917,7 @@ function stepHost(dt: number): void {
882
917
  }
883
918
  }
884
919
  if (now >= round.endsAt) endRound();
885
- } else if (round && round.phase === 'over' && now >= round.endsAt) startRound(round.n + 1);
920
+ } else if (round && round.phase === 'over' && now >= round.endsAt) startRound(round.n + 1, true);
886
921
  if (net.snapshotDue()) net.snapshot(buildSnap(), tick);
887
922
  }
888
923
 
@@ -2475,7 +2510,8 @@ function hud(cw: number, ch: number, phone: boolean, list: { slot: number; seat:
2475
2510
  ctx.fillText(`${TOGETHER ? '' : `${row.place}. `}${mine && !net.watching ? 'You' : label(row.name, row.bot)}${mine && net.watching ? ' ◂' : ''} — ${row.score}`, cw / 2, ry + 1);
2476
2511
  });
2477
2512
  }
2478
- if (!net.offline && !net.connected && net.role !== 'host') {
2513
+ // Only a browser that WAS in its room is reconnecting; one still joining, or stopped for good, is not (section 22).
2514
+ if (net.link === 'reconnecting' && net.role !== 'host') {
2479
2515
  ctx.textAlign = 'center'; ctx.font = `700 15px ${FONT_BODY}`;
2480
2516
  const text = 'Reconnecting…'; const tw = ctx.measureText(text).width;
2481
2517
  chip(cw / 2 - tw / 2 - 12, ch - pad - 24, tw + 24, 26);
@@ -2576,6 +2612,9 @@ exposePort(net, {
2576
2612
  self: () => (me.has && mySeat() !== null ? { x: me.x, y: me.y } : null),
2577
2613
  size: R_AV,
2578
2614
  score: () => { const seat = mySeat(); const b = hosting ? [...bodies.values()].find((x) => x.seat === seat) : [...drawn.values()].find((x) => x.seat === seat); return b ? b.score : null; },
2615
+ // The renderer's own counters, by the names `homie-studio perf` and the playtest read on the port probe (measured
2616
+ // scene cost while playing; port/probe.ts PortExtra). Nobody dies in this game, so there is no `alive` to say.
2617
+ extra: { drawCalls: () => renderer.info.render.calls, triangles: () => renderer.info.render.triangles },
2579
2618
  });
2580
2619
 
2581
2620
  void net.ready.then(() => {
@@ -136,7 +136,10 @@ export const ARRIVAL_JS = String.raw`(function () {
136
136
  var stepEl = el.querySelector('[data-arrive-step]');
137
137
  var roomEl = el.querySelector('[data-arrive-room]');
138
138
  var bar = el.querySelector('[data-arrive-bar]');
139
- var a = { phase: 'room', step: stepEl.textContent, p: 0.06, lookMs: null, liftedMs: null, by: null, mode: null };
139
+ // mode: who was to say when the game is playable ('auto': the helper, 'game': the game, 'seat': an older helper);
140
+ // explicitMs: when the game itself said so (the page's clock; null: it never did); lateMs: how long after an
141
+ // automatic arrival had already lifted the card that was (null: it was not late). The shell's script fills both.
142
+ var a = { phase: 'room', step: stepEl.textContent, p: 0.06, lookMs: null, liftedMs: null, by: null, mode: null, explicitMs: null, lateMs: null };
140
143
  api.state = a;
141
144
  // The card's first frame on the screen: what perf reads as the time to the first meaningful frame.
142
145
  frame(function () { frame(function () { a.lookMs = Math.round(now()); mark('homie:look'); }); });
@@ -8,17 +8,17 @@
8
8
  * /sitemap.xml every public page (Home, Games and each landing, Music, Videos, Rooms, Posts, the studio's own
9
9
  * pages), with lastmod only where a real date says when it changed
10
10
  * /llms.txt the llms.txt convention (an H1, a > summary, then ## sections of links): what the studio is, each
11
- * public game with its pitch, players, Play, Watch, the big screen and whether and how it can be
12
- * remixed (the licence, the source, the words to say to Claude Code or Codex with Homie), songs,
11
+ * public game with its pitch, players, Play, Watch, the big screen, the licence it names and the
12
+ * game it was based on (when it has either), songs,
13
13
  * videos, posts with their feeds, the studio's own pages, and a link to homie.rocks's llms.txt
14
14
  * /llms-full.txt the same, with each game's whole description, how to play, controls and credits, and every
15
15
  * post's text
16
16
  *
17
- * Only public games are ever named (a private or invite-only game is in none of them), and a game is offered for
18
- * remixing only when its owner's remix switch is on, its source is shared and its licence allows it. A file of the
17
+ * Only public games are ever named (a private or invite-only game is in none of them), and no game is offered to
18
+ * be taken whole (the pieces a studio shares are its parts, at /parts/, worker/parts.mjs). A file of the
19
19
  * studio's own in site/public (robots.txt, sitemap.xml, llms.txt, llms-full.txt) wins over the made one.
20
20
  */
21
- import { licenseOf, LICENSE_KINDS, remixRow } from './license.mjs';
21
+ import { basedOnRow, licenseLabel } from './license.mjs';
22
22
 
23
23
  export const DISCOVERY_FILES = ['/robots.txt', '/sitemap.xml', '/llms.txt', '/llms-full.txt'];
24
24
 
@@ -111,15 +111,6 @@ export function sitemapXml(cat, origin, opts = {}) {
111
111
 
112
112
  /* ------------------------------------------------------------------ llms.txt */
113
113
 
114
- const HOMIE_INSTALL = 'in Claude Code, `/plugin marketplace add homie-rocks/homie` and then `/plugin install homie@homie` (Codex takes the same marketplace)';
115
-
116
- function licenceWords(g) {
117
- const l = licenseOf(g.license);
118
- const name = `${LICENSE_KINDS[l.kind]}${l.spdx ? `, ${l.spdx}` : ''}`;
119
- if (l.kind === 'remix-freely') return `${name}: a credit is welcome, not asked for`;
120
- return `${name}: a remix says "Remix of ${one(g.name, 80)} by ${one(g.studio, 80)}" with a link back`;
121
- }
122
-
123
114
  function summaryOf(cat, origin) {
124
115
  const name = studioName(cat);
125
116
  const tag = one(cat.studio?.tagline, 140);
@@ -137,8 +128,8 @@ function summaryOf(cat, origin) {
137
128
  ];
138
129
  }
139
130
 
140
- /** /llms.txt. `remixable(g)`: the owner's remix switch is on, the source is shared, and the licence allows it. */
141
- export function llmsTxt(cat, origin, { remixable = () => false, directory = 'https://homie.rocks', full = false, posts = null, all = null } = {}) {
131
+ /** /llms.txt (and, with `full`, /llms-full.txt). */
132
+ export function llmsTxt(cat, origin, { directory = 'https://homie.rocks', full = false, posts = null, all = null } = {}) {
142
133
  const name = studioName(cat);
143
134
  const games = (cat.games ?? []).map((g) => ({ ...g, studio: name }));
144
135
  const songs = cat.songs ?? [];
@@ -154,9 +145,10 @@ export function llmsTxt(cat, origin, { remixable = () => false, directory = 'htt
154
145
  const L = g.landing ?? {};
155
146
  const doors = [`Play: ${origin}/${g.id}/play`, ...(watchable(g) ? [`Watch a live room: ${origin}/${g.id}/watch`] : []), ...(tv(g) ? [`On a TV: ${origin}/${g.id}/tv`] : [])];
156
147
  const facts = [players(g), rounds(g.roundSeconds), ...(Array.isArray(g.genre) ? g.genre : g.genre ? [g.genre] : [])].filter(Boolean).join(', ');
157
- const lineage = remixRow(g.remixOf);
158
- const open = remixable(g);
159
- out.push(`- [${label(g.name)}](${origin}/${g.id}/): ${one(L.pitch || g.blurb, 240)}${/[.!?]$/.test(one(L.pitch || g.blurb)) ? '' : '.'} ${facts}.${lineage ? ` A remix of ${one(lineage.name, 80)}${lineage.studio ? ` by ${one(lineage.studio, 80)}` : ''}.` : ''} ${doors.join('. ')}. ${open ? `Open to remix (${LICENSE_KINDS[licenseOf(g.license).kind]}${licenseOf(g.license).spdx ? `, ${licenseOf(g.license).spdx}` : ''}): see Remix below.` : 'Not open to remix.'}`);
148
+ // The credit a game owes the one it was made from, and the licence it names for itself: facts, when it has them.
149
+ const lineage = basedOnRow(g.basedOn ?? g.remixOf);
150
+ const licence = licenseLabel(g.license);
151
+ out.push(`- [${label(g.name)}](${origin}/${g.id}/): ${one(L.pitch || g.blurb, 240)}${/[.!?]$/.test(one(L.pitch || g.blurb)) ? '' : '.'} ${facts}.${lineage ? ` Based on ${one(lineage.name, 80)}${lineage.studio ? ` by ${one(lineage.studio, 80)}` : ''}.` : ''} ${doors.join('. ')}.${licence ? ` Licence: ${licence}.` : ''}`);
160
152
  if (full) {
161
153
  const C = L.credits ?? {};
162
154
  const lines = [];
@@ -173,12 +165,6 @@ export function llmsTxt(cat, origin, { remixable = () => false, directory = 'htt
173
165
  for (const l of lines) out.push(` ${l}`);
174
166
  }
175
167
  }
176
- const open = games.filter((g) => remixable(g));
177
- if (open.length) {
178
- out.push('', '## Remix', '');
179
- out.push(`Homie is a plugin for Claude Code and Codex that makes multiplayer games in a studio of your own, on your own free Cloudflare account. To remix one of these games, ${HOMIE_INSTALL}, then say "Remix <game> from <its source address> into a game of my own in my Homie studio". The plugin's \`game_remix\` tool (or \`npx --no-install homie-studio game remix <source address> --id <new-id>\` in a studio's folder) copies it in and writes the credit its licence asks for.`, '');
180
- for (const g of open) out.push(`- [${label(g.name)} source](${origin}/games/${g.id}/source.json): ${licenceWords(g)}. Say: "Remix ${one(g.name, 80)} from ${origin}/games/${g.id}/source.json into a game of my own in my Homie studio".`);
181
- }
182
168
  }
183
169
  if (songs.length) {
184
170
  out.push('', '## Music', '');