@homie-rocks/studio 0.25.0 → 0.26.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/CHANGELOG.md CHANGED
@@ -17,9 +17,31 @@ To bring a studio up to date, tell Claude: "Upgrade my studio to the newest Homi
17
17
  `npx -y @homie-rocks/studio@latest upgrade`, which shows what's new since the version your studio pins (from this
18
18
  file) and what the upgrade would change, and changes nothing until you agree.
19
19
 
20
+ ## [0.26.0] - 2026-10-03
21
+
22
+ **Plugin 0.27.0** · [#40](https://github.com/homie-rocks/homie/pull/40)
23
+
24
+ A game's first minute: Play never opens on a blank screen, a bright game gets a bright landing in its own palette, and in Hero Rush 3D a new player's first round is a race they are in.
25
+
26
+ ### Added
27
+
28
+ - The arrival card. From the play page's first paint (and the big screen's), the game's own look covers its frame while the room connects and the game loads: its title and pitch, its key art (the landing's hero still, a trailer's poster or its cover, drifting slowly), a progress line that says what is happening ("Finding a room…", "Room 4 · 3 playing · 2 AI", "Loading the game…", "Joining Room 4…", the game's own "Loading the heroes… 60%"), and the controls for this device from `landing.controls`. It is drawn in the game's palette (style.json), else its landing's colours, else the studio's, and it fades the moment the game is playable. `?arrive=0` leaves it out.
29
+ - The netplay helper tells the play page when the game is playable (NETPLAY.md section 21): by itself once the browser is seated and has the room's state, or, for a game that keeps loading after that (models, textures, a baked world), at the game's own `net.playable()` (`createNetplay({ arrival: 'game' })`), with `net.loading(fraction, 'the heroes')` for the progress line. Nothing crosses the relay; the wire stays revision 8.
30
+ - `homie-studio perf` measures the time to the first meaningful frame (`load.look`: the arrival card on the screen), and its time to playable waits for the card to lift. The perf skill's goals include it ("it opens on a blank screen").
31
+
32
+ ### Changed
33
+
34
+ - A game with its own palette (style.json) and no `landing.scheme` or `landing.theme` of its own gets its landing in that palette: its paper as the page, its ink as the words, its accents, light or dark as its paper is, when the ink reads on the paper (4.5:1). Its footage keeps its colour, and the words sit on a soft panel of its paper. A bright game is no longer shown on the studio's dark page. A scheme or colours the owner set, a page of the studio's own and `site/theme.css` still win; a game without a style.json keeps the studio's look.
35
+ - Hero Rush 3D: the bots never swamp a new player. In somebody's first round in the room, when nobody set the AI dial, the bots play a level easier. A bot far ahead of the best person in the room eases off (it walks, reacts later, swings less, then races for the coins near that person, or keeps near them, up their screen, without taking coins) and plays at its dial again once it is within its lead: Rookie −1, Steady 1, Fair 2, Strong 5, Maxed none. Scores stay honest (no score is ever changed, no coin given, taken or faked), and every bot is labelled, the phone's compact board too ("bot" or "AI" after the name). A casual player's first round at the default dial ended 61 coins behind the best bot before, and about 4 behind now; a player who chases the coins still wins clearly, and Maxed bots still beat a casual player.
36
+ - Hero Rush 3D on a phone held upright: the camera follows lower and closer (30 degrees, about 5.5 m across), so every hero reads as a character (a mage was the top of its hat), and your name chip says what you are ("You · Mage"). The game passes `arrival: 'game'`: the card lifts on your own hero in its real model, never a stand-in. Your own hero is asked for first, the moment your seat is known, then the other heroes, then the skeletons (on an emulated slow phone your real hero is drawn at about 1.4 s instead of 2.1 s, the same bytes), the in-game title card is gone (the arrival card has the title), the "×2" mark moves off your hero and the controls hint sits above the touch buttons.
37
+
38
+ ### Upgrade notes
39
+
40
+ - Nothing to do: `homie-studio upgrade` brings the arrival card to every game, and a game built before 0.26.0 lifts it once it has a seat. A game whose world keeps loading after its first state can say when it is ready: `createNetplay({ …, arrival: 'game' })`, `net.loading(p, 'what')` while it loads, and `net.playable()` once its world and the player's own body are drawn. A game made from Hero Rush 3D before 0.26.0 keeps its own code: tell Claude "bring the starter's first-round bots and phone camera into my game" if you want them.
41
+
20
42
  ## [0.25.0] - 2026-10-03
21
43
 
22
- **Plugin 0.26.0** · [#38](https://github.com/homie-rocks/homie/pull/38)
44
+ **Plugin 0.26.0** · [#38](https://github.com/homie-rocks/homie/pull/38) · [release-2026-10-03-studio-0.25.0](https://github.com/homie-rocks/homie/releases/tag/release-2026-10-03-studio-0.25.0)
23
45
 
24
46
  Animated characters for 3D games: free CC0 heroes or one generated and auto-rigged on your fal account, on one skeleton standard with the library's clips retargeted onto it, and a shared player that runs, jumps and swings with IK feet and springs.
25
47
 
package/README.md CHANGED
@@ -96,11 +96,14 @@ licence its owner picked in game.json `"license"`: `"remix-with-credit"` (the de
96
96
  `"no-remix"`, which `game remix` refuses. Every page ends with "Made with Homie". Anything the studio puts in `site/` wins: a whole page, a partial, its tokens, its CSS, its
97
97
  files. `site/SITE.md` says all of it.
98
98
 
99
- The play page writes its room into the address, and a small room button at the edge shares it: Invite, Big
100
- screen and the room code. game.json's `screen.share` puts it where each game has room, per device (a corner or
99
+ The play page never opens blank (0.26.0): from its first paint an arrival card shows the game's own look (its title,
100
+ pitch and key art in its palette, a progress line that says what is happening, and the controls for the device) until
101
+ the game says it is playable (`NETPLAY.md` section 21; `worker/arrival.mjs`). It writes its room into the address, and
102
+ a small room button at the edge shares it: Invite, Big screen and the room code. game.json's `screen.share` puts it where each game has room, per device (a corner or
101
103
  the top's middle, moved in by `x` / `y`, or kept a small icon), so it never covers a scoreboard. A game whose
102
104
  picture is white or cream gets a light landing (`landing.scheme: "light"`). Every card and the directory show a
103
- game's landing still. Every HTML answer is `no-transform`, and the site's pages are never framed by another
105
+ game's landing still. A game with its own palette (style.json) and no landing colours of its own gets its landing
106
+ in that palette, light or dark as its paper is (0.26.0). Every HTML answer is `no-transform`, and the site's pages are never framed by another
104
107
  site. A game made with Homie's arcade controls that knocks for a Homie box (`/__homie/call`) is told
105
108
  `not-a-homie`, at the site's root as under the game.
106
109
 
@@ -519,7 +522,8 @@ rules, the bots, the snapshots) and a replica, both a computer (1280x800 at 2x)
519
522
  on a fast computer is less than its rate). They play the same seeded presses through a warm-up and a measured
520
523
  window, and each run's JSON says, per browser: the time between animation frames (median, p95, p99, the share over 33
521
524
  and 50 ms), the game's JavaScript per frame (every requestAnimationFrame callback timed), the main thread per frame
522
- (Chrome's TaskDuration), time to the first frame, to a seat and to playable, the game's files on the wire, the heap
525
+ (Chrome's TaskDuration), time to the first meaningful frame (`load.look`: the play page's arrival card), to the
526
+ game's first frame, to a seat and to playable, the game's files on the wire, the heap
523
527
  after a garbage collection, and netplay messages and kilobytes a second each way. `--profile` adds a CPU profile per
524
528
  browser in a window of its own (a `.cpuprofile` for Chrome DevTools) and its hottest functions, read through the
525
529
  game's source map when the build kept one (`homie-studio build --maps` keeps it in `.studio/maps/<id>/`, never in
@@ -758,7 +762,7 @@ npx homie-studio port check my-game --url http://127.0.0.1:8787 # the owner te
758
762
  | `lib/port-check.mjs` | `port check`: held and alternating directions on keys, Android Chrome and iPhone WebKit touch, UI cover, two browsers finishing a round, a killed host, a late joiner, the big screen. |
759
763
  | `port/` | The port toolkit (`@homie-rocks/studio/port`, or `window.HomiePort` from `homie-port.js` in a static game): `createRoom`, the touch kit, keys, camera rules, bots, a HUD, sandbox shims, first-touch audio, `exposePort`; `fitView` and `createLabels` (`port/view.ts`): a flat world on every screen (the whole of it where it reads, else filling a phone held upright and following the player) and names that never pile up when bodies crowd (yours placed first, the rest moved or faded). |
760
764
  | `worker/index.mjs` | The site Worker and the `Table` (netplay relay, `room.mjs`) and `Lobby` Durable Objects; `/<game>/tv` is the big screen with a join QR (`qr.mjs`); `/<game>/watch` watches a live room from any player's view (NETPLAY.md section 16); `/music/<slug>/` and `/videos/<slug>/` are song and video pages (their files served with byte ranges, from the site's files or, once moved, from the studio's R2 at the same address; a loose file from `media put` at `/media/<key>`). `seats.mjs`: room sizes (up to 32). |
761
- | `worker/site.mjs`, `lib/site.mjs`, `lib/markdown.mjs`, `site/SITE.md` | The site: its sections, each game's landing, posts and their feeds, the look (theme tokens) and what the studio's `site/` folder overrides; the safe markdown posts are written in. `worker/pages.mjs`: the play page. |
765
+ | `worker/site.mjs`, `lib/site.mjs`, `lib/markdown.mjs`, `site/SITE.md` | The site: its sections, each game's landing, posts and their feeds, the look (theme tokens) and what the studio's `site/` folder overrides; the safe markdown posts are written in. `worker/pages.mjs`: the play page; `worker/arrival.mjs`: its arrival card. |
762
766
  | `worker/stats.mjs`, `worker/stats-page.mjs`, `lib/stats.mjs` | The studio's own stats: what is counted and how, the owner-only `/api/stats` and `/_studio/stats`, and `homie-studio stats`. |
763
767
  | `lib/media.mjs`, `media/MEDIA.md` | The `music/` and `videos/` manifests: which entries get a page, and where each file's bytes come from (the site itself up to 25 MiB a file, the studio's R2, or a link). `media list` shows it; `media move` puts the big ones in R2 and records each one's SHA-256; `media put` uploads a loose file to `/media/<key>`. |
764
768
  | `netplay/` | The netplay contract (`NETPLAY.md`) and its game helper (`@homie-rocks/studio/netplay`). |
package/lib/perf.mjs CHANGED
@@ -15,9 +15,11 @@
15
15
  * Each browser plays (the same seeded presses every run) through a warm-up and then a measured window, and the run
16
16
  * writes, per browser (host and replica):
17
17
  *
18
- * load ms from opening the page to the game's first animation frame, to a seat, and to playable (seated with a
19
- * body that the game's port probe reports, or seated and drawing when it has none); what the game's own
20
- * files weighed on the wire, the biggest first
18
+ * load ms from opening the page to the first meaningful frame (`look`: the play page's arrival card on the
19
+ * screen, the game's title and art; a page without one, the game's first animation frame), to the game's
20
+ * first animation frame, to a seat, and to playable (seated with a body that the game's port probe reports,
21
+ * or seated and drawing when it has none, and the arrival card lifted); what the game's own files weighed on
22
+ * the wire, the biggest first
21
23
  * frames the time between animation frames: median, 95th and 99th percentile, the worst, and the share of frames
22
24
  * over 33 ms (a dropped frame at 60 Hz, twice) and over 50 ms (a hitch anyone sees)
23
25
  * work the game's JavaScript inside each animation frame (every requestAnimationFrame callback, timed)
@@ -197,7 +199,7 @@ async function openPlayer(puppeteer, chrome, device, playUrl, label, log, cpu) {
197
199
  let seatedAt = null; let playableAt = null; let shell = null; let body = null;
198
200
  const deadline = Date.now() + 90_000;
199
201
  while (Date.now() < deadline && playableAt === null) {
200
- shell = await T(page.evaluate(() => { const s = window.__shell; return s ? { room: s.room, seat: s.seat, role: s.stats?.role ?? null } : null; }), 5000);
202
+ shell = await T(page.evaluate(() => { const s = window.__shell; const a = s?.arrival; return s ? { room: s.room, seat: s.seat, role: s.stats?.role ?? null, arrival: a ? { phase: a.phase, lookMs: a.lookMs, liftedMs: a.liftedMs, by: a.by } : null } : null; }), 5000);
201
203
  const now = Date.now();
202
204
  if (seatedAt === null && shell?.room && Number.isInteger(shell.seat) && shell.role) seatedAt = now;
203
205
  const g = await inGame(h, () => {
@@ -206,7 +208,9 @@ async function openPlayer(puppeteer, chrome, device, playUrl, label, log, cpu) {
206
208
  if (p && p.view !== 'board') { const r = p.rows(p.now() - 400); const last = r[r.length - 1]; self = last ? Number.isFinite(last[1]) : false; }
207
209
  return { first: window.__perf?.first?.() ?? null, port: Boolean(p), view: p?.view ?? null, self };
208
210
  });
209
- if (seatedAt !== null && g?.first !== null && g?.first !== undefined) {
211
+ // The play page's arrival card (0.26.0) covers the game until the game says it is playable: not playable before.
212
+ const lifted = !shell?.arrival || shell.arrival.phase === 'done';
213
+ if (seatedAt !== null && g?.first !== null && g?.first !== undefined && lifted) {
210
214
  // A body the game reports (its port probe), or no body to wait for (a board, or a game with no probe).
211
215
  if (!g.port || g.view === 'board' || g.self) { playableAt = now; body = g.port && g.view !== 'board' ? 'reported' : 'none to report'; }
212
216
  else if (now - seatedAt > 10_000) { playableAt = null; body = 'never reported by the port probe in 10 s'; break; }
@@ -223,7 +227,11 @@ async function openPlayer(puppeteer, chrome, device, playUrl, label, log, cpu) {
223
227
  const at = (epoch) => (epoch && origin ? Math.round(epoch - origin) : null);
224
228
  const files = [...h.requests.values()].filter((r) => r.done);
225
229
  const gameFiles = files.filter((r) => /\/__game\//.test(r.url));
230
+ // The first meaningful frame: the arrival card's first frame (the page's own clock), else the game's first frame.
231
+ const look = Number.isFinite(shell?.arrival?.lookMs) ? shell.arrival.lookMs : at(g?.first);
226
232
  h.loadInfo = {
233
+ lookMs: look,
234
+ arrival: shell?.arrival ? { by: shell.arrival.by ?? null, liftedMs: shell.arrival.liftedMs ?? null } : null,
227
235
  firstFrameMs: at(g?.first),
228
236
  seatedMs: at(seatedAt),
229
237
  playableMs: at(playableAt),
@@ -518,7 +526,7 @@ export function metricsOfRun(run) {
518
526
  put('work.p50', b.work?.p50); put('work.p95', b.work?.p95); put('work.mean', b.work?.mean);
519
527
  put('busy', b.main?.busyPerFrame); put('script', b.main?.scriptMsPerS);
520
528
  put('heap', b.heap?.afterGcMb); put('heap.growth', b.heap?.gcGrowthMbPerMin);
521
- put('load.firstFrame', b.load?.firstFrameMs); put('load.seated', b.load?.seatedMs); put('load.playable', b.load?.playableMs); put('load.gameKb', b.load?.gameKb);
529
+ put('load.look', b.load?.lookMs); put('load.firstFrame', b.load?.firstFrameMs); put('load.seated', b.load?.seatedMs); put('load.playable', b.load?.playableMs); put('load.gameKb', b.load?.gameKb);
522
530
  put('net.msgsOut', b.net?.msgsOut); put('net.msgsIn', b.net?.msgsIn); put('net.kbOut', b.net?.kbOut); put('net.kbIn', b.net?.kbIn);
523
531
  }
524
532
  return out;
@@ -541,7 +549,7 @@ function headlineOf(summary) {
541
549
  for (const role of ['host', 'replica']) {
542
550
  const m = (k) => summary.metrics[`${device}.${role}.${k}`]?.median;
543
551
  if (m('frame.p50') === undefined) continue;
544
- out.push(`${device} ${role}: frames ${m('frame.p50')} ms median, ${m('frame.p95')} ms p95, ${m('frame.over50') ?? 0}% over 50 ms; game JS ${m('work.p50')} ms a frame (p95 ${m('work.p95')}); main thread ${m('busy')} ms a frame; playable at ${m('load.playable') ?? '?'} ms; netplay ${m('net.msgsOut')} out / ${m('net.msgsIn')} in a second; heap ${m('heap')} MB`);
552
+ out.push(`${device} ${role}: frames ${m('frame.p50')} ms median, ${m('frame.p95')} ms p95, ${m('frame.over50') ?? 0}% over 50 ms; game JS ${m('work.p50')} ms a frame (p95 ${m('work.p95')}); main thread ${m('busy')} ms a frame; first look at ${m('load.look') ?? '?'} ms, playable at ${m('load.playable') ?? '?'} ms; netplay ${m('net.msgsOut')} out / ${m('net.msgsIn')} in a second; heap ${m('heap')} MB`);
545
553
  }
546
554
  }
547
555
  return out;
package/lib/site.mjs CHANGED
@@ -419,8 +419,19 @@ export function landingOf(g, out, { videos = [], songs = [], log = () => {} } =
419
419
  // The landing's own colours (landing.theme): only colours, and the ink on its own accent worked out like the studio's.
420
420
  // Else the game's own palette (games/<id>/style.json, its art-direction decisions): one look from the game to its page.
421
421
  const style = !L.theme ? readJson(join(g.dir, 'style.json'))?.palette : null;
422
- // Its accents only: the page keeps the studio's light or dark scheme, so its text always reads.
423
- const fromStyle = style && typeof style === 'object' ? { accent: style.accent, glow: style.accent2 } : null;
422
+ // 0.26.0: the whole landing in that palette (its paper as the page, its ink as the words, its accents) when the game
423
+ // sets no landing colours or scheme of its own and its ink reads on its paper (4.5:1): a bright game is no longer
424
+ // shown on the studio's dark page. The scheme follows the paper. Anything less readable: its accents only, on the
425
+ // studio's scheme, as before.
426
+ let fromStyle = style && typeof style === 'object' ? { accent: style.accent, glow: style.accent2 } : null;
427
+ let ownScheme = null;
428
+ if (fromStyle && L.scheme === undefined) {
429
+ const paper = luminance(style.bg); const ink = luminance(style.ink);
430
+ if (paper !== null && ink !== null && (Math.max(paper, ink) + 0.05) / (Math.min(paper, ink) + 0.05) >= 4.5) {
431
+ fromStyle = { ...fromStyle, bg: style.bg, fg: style.ink };
432
+ ownScheme = paper > ink ? 'light' : 'dark';
433
+ }
434
+ }
424
435
  const raw = L.theme && typeof L.theme === 'object' ? L.theme : fromStyle;
425
436
  const theme = raw ? Object.fromEntries(['accent', 'glow', 'bg', 'fg'].filter((k) => COLOR.test(String(raw[k] ?? ''))).map((k) => [k, raw[k]])) : null;
426
437
  if (theme?.accent) { const l = luminance(theme.accent); if (l !== null) theme.accentInk = l > 0.4 ? '#0b0b10' : '#ffffff'; }
@@ -443,7 +454,7 @@ export function landingOf(g, out, { videos = [], songs = [], log = () => {} } =
443
454
  source: g.share?.source !== false,
444
455
  tv: g.screen?.tv !== false && L.tv !== false,
445
456
  theme: theme && Object.keys(theme).length ? theme : null,
446
- ...(L.scheme === 'light' || L.scheme === 'dark' ? { scheme: L.scheme } : {}),
457
+ ...(L.scheme === 'light' || L.scheme === 'dark' ? { scheme: L.scheme } : ownScheme ? { scheme: ownScheme, schemeFrom: 'style' } : {}),
447
458
  };
448
459
  }
449
460
 
@@ -1234,6 +1234,45 @@
1234
1234
  }
1235
1235
  }
1236
1236
  }
1237
+ },
1238
+ "0.26.0": {
1239
+ "files": {
1240
+ "CLAUDE.md": "ddf2b2a0376202f6",
1241
+ "README.md": "684b3cca8001673c",
1242
+ "HANDOFF.md": "ce7a8fd91c6bbee2",
1243
+ "games/README.md": "5ad32fe80c61f117",
1244
+ "music/README.md": "a3dc042dadd39df8",
1245
+ "videos/README.md": "16babc3437d5545b",
1246
+ "posts/README.md": "37841aae476bd540",
1247
+ "site/README.md": "ca131cd0472464fb",
1248
+ "site/src/worker.mjs": "ca3f1630f2420ad3"
1249
+ },
1250
+ "sections": {
1251
+ "AGENTS.md": {
1252
+ "(intro)": "214aa46eb3a895d5",
1253
+ "## Layout": "8baf49e4291e7e88",
1254
+ "## Commands (all through the pinned CLI in node_modules)": "d80ec56e4b4fc4c6",
1255
+ "## The site": "ab922bb6bde23293",
1256
+ "## Making games": "abf2f0d59341512d",
1257
+ "## The Game Codex and progress": "0738ef719cef3a4c",
1258
+ "## Art direction and models": "aaea65236db52126",
1259
+ "## Running live games (the back office)": "4aa5f513a7cd2c3b",
1260
+ "## Servers and AI seats": "98d08705087a33ec",
1261
+ "## Selling things (the shop)": "b04b0a2b25f524f2",
1262
+ "## Continuing a build from the Claude app": "0d68081e924ca419",
1263
+ "## Rules": "7e8458adc1b81ece",
1264
+ "## Beta": "9d02638207a26e73"
1265
+ }
1266
+ },
1267
+ "named": {
1268
+ "Homie Arcade": {
1269
+ "sections": {
1270
+ "AGENTS.md": {
1271
+ "## Commands (all through the pinned CLI in node_modules)": "b35df2c19cd4b534"
1272
+ }
1273
+ }
1274
+ }
1275
+ }
1237
1276
  }
1238
1277
  }
1239
1278
  }
@@ -583,13 +583,16 @@ Measured, with Gem Rush's bump (owner movement, 30–50 ms one way):
583
583
  overrides). `?screen=1` makes a spectator that shows a QR code to the game URL.
584
584
  `/<game>/watch?room=` makes a watcher, with a strip of the players (section 16).
585
585
  8. **Show room facts** from the watch feed or the frame's messages: the lobby line,
586
- results, and with `?debug=1` the debug strip.
586
+ results, and with `?debug=1` the debug strip. Until the game is playable, show them on
587
+ the arrival card, in the game's own look (section 21), never a blank screen.
587
588
  9. **One matcher:** every Play door for a game lands in the same rooms (the `Lobby`).
588
589
 
589
590
  ### The game (a vendored `netplay.ts`)
590
591
 
591
592
  1. **Call `createNetplay({ game, maxPlayers, movement, checkpoint })` once** and render
592
- from the first frame. Never wait for `ready`.
593
+ from the first frame. Never wait for `ready`. A game that loads for seconds after its
594
+ first state passes `arrival: 'game'` and calls `net.playable()` once its world and the
595
+ player's own body are drawn (section 21): the play page's arrival card covers the wait.
593
596
  2. **Handle `role` at any time.**
594
597
  - Promoted: restore (section 9).
595
598
  - Host but not promoted: start a fresh round.
@@ -1422,3 +1425,36 @@ account or typed text.
1422
1425
  the host asks how the slimes hunt (chase, surround, gang up on the most hurt, regroup at the King),
1423
1426
  whether a wave comes, and how hard to push; every screen shows what the slimes are up to when a model
1424
1427
  chose it. The floor is the vale as it always played.
1428
+
1429
+ ## 21. The arrival (0.26.0)
1430
+
1431
+ The play page and the big screen never open on a blank screen. From the page's first paint until
1432
+ the game says it is playable, an **arrival card** covers the game's frame in the game's own look:
1433
+ its title and pitch, its key art (the landing's hero still, a trailer's poster or its cover,
1434
+ drifting slowly), a progress line that says what is happening ("Finding a room…", "Room 4 · 3
1435
+ playing · 2 AI", "Loading the game…", "Joining Room 4…", the game's own "Loading the heroes…
1436
+ 60%") and the controls for this device (game.json `landing.controls`). Then it fades and the
1437
+ game has the screen. Its colours are the game's palette (style.json), else its landing's, else
1438
+ the studio's (`worker/arrival.mjs`). Nothing here crosses the relay: it is between the game's frame
1439
+ and its page, so the wire stays revision 8.
1440
+
1441
+ | From the helper to its page | When |
1442
+ |---|---|
1443
+ | `{ what: 'attached', …, arrival: 'auto' \| 'game' }` | the helper starts (a helper older than 0.26.0 sends no `arrival`) |
1444
+ | `{ what: 'loading', p, label? }` | the game's `net.loading(p, label)`: 0 to 1, at most ten a second, never after `playable` |
1445
+ | `{ what: 'playable', by: 'game' \| 'auto' }` | once: the game's `net.playable()`, or (`arrival: 'auto'`) the helper's own |
1446
+
1447
+ - **`arrival: 'auto'`** (the default, so every game gets the card with no code): the helper says
1448
+ `playable` once this browser has its role and the room's state (a host at once, anyone else at
1449
+ its first snapshot), two animation frames later, so the game has drawn with it.
1450
+ - **`arrival: 'game'`**: the game says when, with `net.playable()` once its world and the player's
1451
+ own body are drawn (Hero Rush 3D: its own hero with its real model, not the stand-in), and
1452
+ `net.loading(p, 'the heroes')` while it loads. A game whose own loading takes seconds after the
1453
+ first state (models, textures, a baked terrain) uses this, so the player never sees stand-ins.
1454
+ - **The page's fallbacks**, so a game is never hidden behind the card: a helper older than 0.26.0
1455
+ lifts it 0.6 s after the browser has a seat; a frame with no helper, 8 s after it loaded; any game,
1456
+ 15 s after its helper attached; and 30 s after the page opened, whatever happens. A kick or a
1457
+ closed room lifts it for its notice. `?arrive=0` leaves it out.
1458
+ - **Measured** by `homie-studio perf` (`load.look`: the card's first frame, the first meaningful
1459
+ frame; `load.playable`: seated, the body drawn and the card lifted), from the page's own marks
1460
+ `homie:look` and `homie:playable` and `window.__shell.arrival`.
@@ -50,6 +50,11 @@
50
50
  * them), messages taken down (`on('unchat')`), and the game's own chat UI
51
51
  * (`net.say(text)`, `net.sayLine(id)`, `net.react(kind)`); the room's rules
52
52
  * are `net.chatRules`. The play page's chat panel needs none of it.
53
+ * - The arrival (0.26.0, NETPLAY.md section 21): tells its page when the game
54
+ * is playable, so the play page's arrival card (the game's title, art and a
55
+ * progress line) gives it the screen: by itself once seated with the room's
56
+ * state, or the game's own `net.playable()` (`arrival: 'game'`), with
57
+ * `net.loading(p, what)` while it loads. Nothing crosses the relay.
53
58
  *
54
59
  * WHAT IT DOES NOT DO: rendering, physics, input devices, bots. `Roster` below
55
60
  * is the bot-yield bookkeeping a host needs; the bots themselves are the game's.
@@ -451,6 +456,14 @@ export interface NetplayOptions<C = unknown> {
451
456
  * moves an AI's body with its own bot code (a Roster that passes `p.agent`). Reading the dial declares 'skill' too.
452
457
  */
453
458
  caps?: ('skill' | 'agents')[];
459
+ /**
460
+ * THE ARRIVAL (NETPLAY.md section 21): who says when the play page's arrival card (the game's title, art and a
461
+ * progress line while the room connects and the game loads) gives the screen to the game. 'auto' (the default): this
462
+ * helper, once this browser has its role and the room's state (a host at once, anyone else at its first snapshot),
463
+ * two animation frames later. 'game': the game itself, with `net.playable()` once its world and the player's own body
464
+ * are drawn (the page lifts the card anyway 12 s after the helper attached, so a game that never says is never hidden).
465
+ */
466
+ arrival?: 'auto' | 'game';
454
467
  }
455
468
 
456
469
  type WebSocketCtor = new (url: string) => WebSocketLike;
@@ -609,6 +622,17 @@ export interface Netplay<S = unknown, A = unknown, C = unknown> {
609
622
  hushed: boolean;
610
623
  /** This browser plays as an AI (the frame of an agent pass). */
611
624
  readonly asAgent: boolean;
625
+ /**
626
+ * THE ARRIVAL (section 21): the world and the player's own body are drawn, so the play page's arrival card gives the
627
+ * screen to the game. Once; later calls do nothing. Needed only with `arrival: 'game'` (an 'auto' game may call it
628
+ * earlier than the helper would).
629
+ */
630
+ playable(): void;
631
+ /**
632
+ * While it loads: how far along the game is (0 to 1) and what it is loading ("the heroes"), for the arrival card's
633
+ * progress line. At most ten a second reach the page; nothing after `playable()`.
634
+ */
635
+ loading(fraction: number, what?: string): void;
612
636
  /** Resolves with the first role (welcome, or offline fallback). */
613
637
  readonly ready: Promise<RoleChange<S, C>>;
614
638
  on<K extends keyof NetHandlers<S, A, C>>(kind: K, fn: NetHandlers<S, A, C>[K]): () => void;
@@ -1110,6 +1134,26 @@ export function createNetplay<S = unknown, A = unknown, C = unknown>(opts: Netpl
1110
1134
  let idleSkipped = 0;
1111
1135
  /** Why this client stopped for good (a FINAL_ERRORS refusal), or null while it plays or reconnects. */
1112
1136
  let closedWhy: string | null = null;
1137
+ // The arrival (section 21): the play page's card lifts at `playable` (the game's word, or this helper's).
1138
+ const arrival: 'auto' | 'game' = opts.arrival === 'game' ? 'game' : 'auto';
1139
+ let playableSent = false;
1140
+ let autoArmed = false;
1141
+ let snapSeen = false;
1142
+ let loadingAt = 0;
1143
+ function sayPlayable(by: 'game' | 'auto'): void {
1144
+ if (playableSent) return;
1145
+ playableSent = true;
1146
+ post?.({ what: 'playable', by });
1147
+ }
1148
+ /** 'auto': a host at once, anyone else at its first snapshot, then two animation frames (the game drew with it). */
1149
+ function autoPlayable(): void {
1150
+ if (arrival !== 'auto' || playableSent || autoArmed || !roleKnown) return;
1151
+ if (role !== 'host' && !snapSeen) return;
1152
+ autoArmed = true;
1153
+ const raf = (globalThis as { requestAnimationFrame?: (cb: () => void) => number }).requestAnimationFrame;
1154
+ const later = (fn: () => void): void => { if (typeof raf === 'function') raf(fn); else setTimeout(fn, 16); };
1155
+ later(() => later(() => sayPlayable('auto')));
1156
+ }
1113
1157
  /** The studio's announcement now showing, and the seats the studio muted (until when, server ms). */
1114
1158
  let announcement: Announcement | null = null;
1115
1159
  const muted = new Map<number, number>();
@@ -1354,6 +1398,7 @@ export function createNetplay<S = unknown, A = unknown, C = unknown>(opts: Netpl
1354
1398
  emit('role', e);
1355
1399
  resolveView('seat');
1356
1400
  notifyStats(true);
1401
+ autoPlayable();
1357
1402
  }
1358
1403
 
1359
1404
  /** The newest snapshot this browser holds, if recent: a promoted host's restore when the relay has none. */
@@ -1584,7 +1629,8 @@ export function createNetplay<S = unknown, A = unknown, C = unknown>(opts: Netpl
1584
1629
  replaceState(m['state']);
1585
1630
  }
1586
1631
  rebase = true;
1587
- if (snap && next !== 'host') acceptSnap(snap, JSON.stringify(snap).length, false);
1632
+ // The relay's last snapshot comes with the welcome: the room's state is in (the arrival, section 21).
1633
+ if (snap && next !== 'host' && acceptSnap(snap, JSON.stringify(snap).length, false)) snapSeen = true;
1588
1634
  if (next === 'host' && !snap && !continuing) snap = localSnap();
1589
1635
  offline = false;
1590
1636
  connected = true;
@@ -1685,7 +1731,7 @@ export function createNetplay<S = unknown, A = unknown, C = unknown>(opts: Netpl
1685
1731
  if (role === 'host') return; // a stale frame from a previous host
1686
1732
  const s: Snapshot<S> = { k: Number(m['k']) || 0, st: Number(m['st']), d: m['d'] as S, from: typeof m['from'] === 'number' ? m['from'] : null };
1687
1733
  if (Array.isArray(m['c'])) s.c = m['c'] as ControlWire[];
1688
- if (acceptSnap(s, bytes, true)) emit('snapshot', s);
1734
+ if (acceptSnap(s, bytes, true)) { emit('snapshot', s); if (!snapSeen) { snapSeen = true; autoPlayable(); } }
1689
1735
  }
1690
1736
 
1691
1737
  function onInput(m: Record<string, unknown>): void {
@@ -1877,7 +1923,7 @@ export function createNetplay<S = unknown, A = unknown, C = unknown>(opts: Netpl
1877
1923
  // No shell (a plain file, a dev server): the game is its own host, offline.
1878
1924
  queueMicrotask(() => goOfflineHost('offline'));
1879
1925
  } else {
1880
- post?.({ what: 'attached', v: NETPLAY_VERSION, rev: NETPLAY_REVISION, mark: NETPLAY_MARK });
1926
+ post?.({ what: 'attached', v: NETPLAY_VERSION, rev: NETPLAY_REVISION, mark: NETPLAY_MARK, arrival });
1881
1927
  open();
1882
1928
  // A relay that never answers must not leave a game on a black screen. But the wait is counted only while
1883
1929
  // this page is able to listen: a phone compiling shaders under load is blocked for seconds at a time, and
@@ -2050,6 +2096,15 @@ export function createNetplay<S = unknown, A = unknown, C = unknown>(opts: Netpl
2050
2096
  get announcement() { return announcement && (announcement.until === undefined || announcement.until > now()) ? announcement : null; },
2051
2097
  isMuted(s: number | null): boolean { return s !== null && (muted.get(s) ?? 0) > now(); },
2052
2098
  pickPlayer(s: number | null): void { post?.({ what: 'pick', seat: typeof s === 'number' ? s : null }); },
2099
+ playable(): void { sayPlayable('game'); },
2100
+ loading(fraction: number, what?: string): void {
2101
+ if (playableSent) return;
2102
+ const t = wall();
2103
+ const p = Math.max(0, Math.min(1, Number(fraction) || 0));
2104
+ if (t - loadingAt < 100 && p < 1) return;
2105
+ loadingAt = t;
2106
+ post?.({ what: 'loading', p: Math.round(p * 100) / 100, ...(typeof what === 'string' && what ? { label: what.slice(0, 40) } : {}) });
2107
+ },
2053
2108
  get watching() { return watching; },
2054
2109
  get viewSeat() { markFollows(); return watching ? viewNow : (offline ? null : seat); },
2055
2110
  get following() { return watching ? following : seat; },
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@homie-rocks/studio",
3
- "version": "0.25.0",
3
+ "version": "0.26.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/site/SITE.md CHANGED
@@ -47,7 +47,13 @@ site's pages only); the owner's tools are `/_studio/api/chat`, `/_studio/api/cha
47
47
  Made from the game's own files, nothing invented:
48
48
 
49
49
  - **The hero**, full-bleed: the game's footage when it has some, else its cover (or key art) moving slowly, tinted
50
- with the studio's colour so the words read. A game whose picture is white or cream (a light arena) takes
50
+ with the studio's colour so the words read. **In the game's own palette (0.26.0):** a game with a style.json (its
51
+ art direction) and no `landing.scheme` or `landing.theme` of its own gets its whole landing in that palette, its
52
+ paper as the page, its ink as the words, its accents, light or dark as its paper is, when the ink reads on the paper
53
+ (4.5:1); its footage then keeps its colour (a third of the tint) and the words sit on a soft panel of its paper. A
54
+ bright game is no longer shown on the studio's dark page. `landing.scheme`, `landing.theme`, a `site/pages/<id>/`
55
+ page and `site/theme.css` are the owner's and always win; a palette whose ink does not read keeps its accents only,
56
+ on the studio's scheme, as before. A game whose picture is white or cream (a light arena) takes
51
57
  `"scheme": "light"` in its `landing` block: its landing is drawn light, the hero tinted and shaded with a light
52
58
  background and the words dark, where a dark studio's tint would turn it grey (`"dark"` is the other way round,
53
59
  for a dark game in a light studio). `landing.theme` colours still win, and the rest of the site keeps its look. Footage is looked for in this order: `landing.hero` in game.json;
@@ -143,6 +149,18 @@ A studio with its own landing in `site/` keeps it: the template never replaces w
143
149
 
144
150
  ## The play page
145
151
 
152
+ **The arrival (0.26.0).** The play page and the big screen never open blank. From the first paint, an arrival
153
+ card covers the game's frame in the game's own look (worker/arrival.mjs): its title, its pitch (`landing.pitch`, else
154
+ the blurb's first sentence), its key art (the landing's hero still, a trailer's poster or its cover, drifting slowly;
155
+ `landing.hero.tallImage` on an upright phone when there is one), a progress line that says what is happening
156
+ ("Finding a room…", "Room 4 · 3 playing · 2 AI", "Loading the game…", "Joining Room 4…", and the game's own
157
+ "Loading the heroes… 60%"), and the controls for this device (`landing.controls.phone`, `.computer`, `.tv`). Its
158
+ colours are the game's palette (style.json), else the landing's (`landing.theme`, `landing.scheme`), else the
159
+ studio's. It lifts when the game says it is playable (NETPLAY.md section 21): by itself once seated with the room's
160
+ state, or at the game's own `net.playable()`; a game built before 0.26.0 lifts it once it has a seat, and nothing
161
+ keeps a game behind it longer than 15 s after the game started (30 s after the page opened). `?arrive=0` leaves it
162
+ out. `homie-studio perf` reads its first frame as the time to the first meaningful frame (`load.look`).
163
+
146
164
  `/<id>/play` puts the visitor in a public room at once and writes the room into the address, so a reload comes
147
165
  back to it and a copied address brings a friend into it. A small room button at the edge (top right by default)
148
166
  opens: **Invite** (the phone's share sheet, or the link copied), **Big screen** (`/<id>/tv` of this room, on
@@ -5,5 +5,6 @@
5
5
  "players": { "min": 1, "max": 8 },
6
6
  "roundSeconds": 60,
7
7
  "entry": "src/main.ts",
8
- "netplay": { "v": 1, "public": true, "movement": "owner" }
8
+ "netplay": { "v": 1, "public": true, "movement": "owner" },
9
+ "landing": { "pitch": "Grab the gems. Bump your rivals away.", "controls": { "phone": "Drag to move; a second finger bumps.", "computer": "WASD or arrows to move, Space to bump." } }
9
10
  }
@@ -7,5 +7,6 @@
7
7
  "entry": "src/main.ts",
8
8
  "netplay": { "v": 1, "public": true, "movement": "owner" },
9
9
  "needs": { "three": "0.185.1" },
10
- "assets": "library"
10
+ "assets": "library",
11
+ "landing": { "pitch": "Grab the gems. Bump your rivals away.", "controls": { "phone": "Drag to move; a second finger bumps.", "computer": "WASD or arrows to move, Space to bump." } }
11
12
  }
@@ -7,5 +7,6 @@
7
7
  "entry": "src/main.ts",
8
8
  "netplay": { "v": 1, "public": true, "movement": "owner" },
9
9
  "needs": { "three": "0.185.1" },
10
- "assets": "library"
10
+ "assets": "library",
11
+ "landing": { "pitch": "Grab the coins. Jump their swings. Knock the loot loose.", "controls": { "phone": "Drag anywhere to run; Jump and Swing at the bottom right.", "computer": "WASD or arrows to run, Space to jump, F or a click to swing." } }
11
12
  }