@homie-rocks/studio 0.5.0 → 0.7.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.
@@ -1,8 +1,19 @@
1
- # Homie netplay contract, v1 (revision 2)
2
-
3
- Status: **v1, revision 2** (2026-09-26). The wire version is `v: 1`. Everything revision
4
- 2 added is either an optional field or a new message type, and both sides ignore types
5
- they do not know. A change to the contract bumps `v` and keeps v1 working.
1
+ # Homie netplay contract, v1 (revision 3)
2
+
3
+ Status: **v1, revision 3** (2026-09-30, `@homie-rocks/studio` 0.6.0). The wire version is
4
+ `v: 1`. Everything revision 2 and 3 added is either an optional field, a new message type,
5
+ or a change of pace inside the old caps, and both sides ignore types they do not know. A
6
+ change to the contract bumps `v` and keeps v1 working.
7
+
8
+ **What revision 3 added** (nothing on the wire changed; every v1 game plays on it):
9
+ - **Rooms of up to 32 seats**, sized by the game's netplay manifest (section 3).
10
+ - **One address may hold every seat plus four sockets** (section 6), so a party on one
11
+ Wi-Fi fills a 32-seat room with a TV beside it.
12
+ - **The checkpoint, round and roster caps double for 17–32 seats** (section 6): a
13
+ 32-seat courier game's checkpoint measured 56 KB against the 64 KB cap.
14
+ - **Idle input keepalive** in the helper: an unchanged input frame goes out at most four
15
+ times a second (section 11).
16
+ - Measured cost and free-plan headroom of a 32-seat room (section 11).
6
17
 
7
18
  **What revision 2 added:**
8
19
  - Interpolation measures transit time.
@@ -95,8 +106,12 @@ code.
95
106
  | `colour` | `seat % 12` | An index into a 12-colour palette. |
96
107
 
97
108
  - **Every visitor gets a seat at once**, phone or desktop. There is no gathering.
98
- - **Room size.** `maxPlayers` comes from the manifest. On the site the Table reads it.
99
- Locally, the first visitor of an empty room sends it in `hello.max`.
109
+ - **Room size.** `maxPlayers` comes from the game's netplay manifest: game.json's
110
+ `netplay.maxPlayers`, or a `netplay.json` beside game.json or in the game's build
111
+ (`maxPlayers` or `players.max`), else game.json's `players.max`, else 8. **A room holds
112
+ at most 32** (`SEAT_MAX`). `homie-studio build` writes the number into the catalogue and
113
+ the site's Table takes it from there; the first visitor of an empty room may lower it
114
+ (`hello.max`), never raise it.
100
115
  - **A dropped seat is held 60 s** for its token, which covers a reload or a network blip.
101
116
  **A visitor who is present beats one who left:** when no seat is free, the relay
102
117
  reclaims the seat that has been absent longest. Visitors who leave can never lock a
@@ -259,13 +274,15 @@ so `ev.origin` is `"null"`.
259
274
  | `in` | < 256 B | 2 KB | 20 Hz, plus an immediate flush (≤ 60 Hz) on a press edge | 60 |
260
275
  | `ev` | < 1 KB | 4 KB (512 B from a screen) | as needed | host 30, replica 10, screen 2 |
261
276
  | `state` | < 2 KB per key | 8 KB per key; **64 keys and 64 KB per room** → `state-full` | only on change | 64 |
262
- | `ckpt` | < 32 KB | 64 KB | 1 Hz | 4 |
263
- | `round` / `roster` | < 2 KB / < 1 KB | 8 KB / 4 KB | on change | 4 / 8 |
277
+ | `ckpt` | < 32 KB | 64 KB (128 KB for 17–32 seats) | 1 Hz | 4 |
278
+ | `round` / `roster` | < 2 KB / < 1 KB | 8 KB / 4 KB (16 / 8 KB for 17–32 seats) | on change | 4 / 8 |
264
279
  | `ping` | — | 256 B | every 2 s | 8 |
265
280
 
266
281
  **Sockets.**
267
- - At most `maxPlayers + 16` per room, and **12 per client address per room**. The Worker
268
- passes `CF-Connecting-IP` as `conn.ip`.
282
+ - At most `maxPlayers + 16` per room, and per client address per room **12, or every seat
283
+ plus four when that is more** (`perAddress`: 12 for 8 seats, 20 for 16, 36 for 32). The
284
+ Worker passes `CF-Connecting-IP` as `conn.ip`. Measured before revision 3: 32 visitors on
285
+ one address got 12 seats and 20 `too-many` refusals.
269
286
  - A client dropping more than 100 messages over the caps within 5 s is closed (`flood`).
270
287
 
271
288
  **Keep it small.**
@@ -494,6 +511,31 @@ Measured, with Gem Rush's bump (owner movement, 30–50 ms one way):
494
511
  writes/h ≈ $0.07/h, 9× everything else), or rewriting a whole log per event.
495
512
  - Re-check Cloudflare's published prices before quoting.
496
513
 
514
+ **A 32-seat room (measured 2026-09-30, `@homie-rocks/studio` 0.6.0).** Every incoming
515
+ socket message is a Durable Object request (billed 20:1; outgoing messages are free), and
516
+ in a full room the replicas' inputs are nearly all of them:
517
+
518
+ | 32 seats, one host | Incoming msgs/s at the relay | Requests/hour (20:1) | Free plan (100,000 a day) |
519
+ |---|---|---|---|
520
+ | every replica moving, input 20 Hz | ≈ 630 | ≈ 114 k | ≈ 53 room-minutes a day |
521
+ | a game's own vendored helper, input 30 Hz | ≈ 860 | ≈ 155 k | ≈ 39 room-minutes a day |
522
+ | half the replicas standing still, 0.6.0 helper | ≈ 415 | ≈ 75 k | ≈ 80 room-minutes a day |
523
+
524
+ - Put another way, the free plan carries roughly **28 player-hours a day** at 20 Hz input
525
+ (each seated player costs about one request a second). A studio that plays more than
526
+ that a day needs Workers Paid ($5 a month), where a full 32-seat room-hour costs about
527
+ $0.02 in requests plus $0.006 in duration.
528
+ - **Idle keepalive (helper, 0.6.0):** an input frame identical to the last one sent (same
529
+ avatar or intent, same held keys, no press, same reset epoch) is re-sent at most every
530
+ 250 ms. The host already holds that frame, so nothing it reads changes. A player standing
531
+ still costs 4 messages a second instead of 20. A game that vendors its own copy of the
532
+ helper gets this by updating that copy.
533
+ - Keep `inputHz` at 20 unless the game needs more: 30 Hz costs 1.5× the requests.
534
+ - Snapshots of a real 32-seat courier game (32 bodies plus the control table) were 1.1–1.7 KB at
535
+ 20 Hz: about 32 KB/s down per replica, 2 KB/s up. No snapshot delta or compression was
536
+ needed; the 2 KB target and 16 KB cap hold at 32 seats. Duration (13,000 GB-s a day free)
537
+ covers one busy room all day.
538
+
497
539
  ## 12. Trust and safety
498
540
 
499
541
  - **The host is a visitor's browser.** A modified host could fake scores. That is
@@ -569,7 +611,8 @@ npx --no-install homie-studio check my-game --url http://127.0.0.1:8787
569
611
  candidate for v2, not in v1.
570
612
  - **No lag compensation for hits.** A game can rewind a victim by the attacker's
571
613
  `rtt/2 + delay` itself.
572
- - **No snapshot deltas.** They are not needed at the measured sizes (Gem Rush: 364–427 B).
614
+ - **No snapshot deltas.** They are not needed at the measured sizes (Gem Rush: 364–427 B;
615
+ a 32-body courier game's snapshot: 1.1–1.7 KB).
573
616
  - **A reload shows a bot in the player's body for about 1–2 s**, and then the player
574
617
  takes the same body back.
575
618
  - **The host is a visitor's browser** (section 12).
@@ -161,6 +161,8 @@ export interface NetStats {
161
161
  snapHzOut: number;
162
162
  inputHzIn: number;
163
163
  inputHzOut: number;
164
+ /** Idle input frames not sent (identical to the last one, inside the keepalive interval). */
165
+ idleInputsSkipped?: number;
164
166
  lastSnapBytes: number;
165
167
  maxSnapBytes: number;
166
168
  bytesInPerS: number;
@@ -507,6 +509,13 @@ const IN_SLOW_CAP_PER_S = 15;
507
509
  const IN_SLOW_MS = 8000;
508
510
  /** A socket with this much still unsent does not queue another input frame (the newest one waits instead). */
509
511
  const IN_BACKLOG_BYTES = 2048;
512
+ /**
513
+ * An input frame identical to the last one sent (same avatar or intent, same held keys, no press, same reset epoch)
514
+ * goes out at most this often: a player standing still says so four times a second, not twenty. The host already
515
+ * holds that frame, so nothing it reads changes; every incoming socket message is a Durable Object request
516
+ * (billed 20:1), and in a 32-seat room the replicas' inputs are nearly all of them (NETPLAY.md §11).
517
+ */
518
+ const IDLE_INPUT_MS = 250;
510
519
 
511
520
  function heldList(held: Iterable<string> | Record<string, boolean> | undefined): string[] {
512
521
  if (!held) return [];
@@ -635,9 +644,12 @@ export function createNetplay<S = unknown, A = unknown, C = unknown>(opts: Netpl
635
644
  let prevHeld = new Set<string>();
636
645
  let pendingPresses: Record<string, number> = {};
637
646
  let inputTimer: ReturnType<typeof setTimeout> | null = null;
647
+ /** The last input frame sent, as text (idle frames repeat it at most every IDLE_INPUT_MS). */
648
+ let lastInputSig = '';
638
649
  /** Wall times of the input frames sent in the last second, and until when the slow cap holds. */
639
650
  const inSent: number[] = [];
640
651
  let inSlowUntil = 0;
652
+ let idleSkipped = 0;
641
653
  /** Why this client stopped for good (a FINAL_ERRORS refusal), or null while it plays or reconnects. */
642
654
  let closedWhy: string | null = null;
643
655
  /** Wall time the socket went down (0 while connected): how long a reconnect has taken. */
@@ -1071,12 +1083,17 @@ export function createNetplay<S = unknown, A = unknown, C = unknown>(opts: Netpl
1071
1083
  const budget = inputBudgetWait();
1072
1084
  const backlog = (ws?.bufferedAmount ?? 0) > IN_BACKLOG_BYTES;
1073
1085
  if (budget > 0 || backlog) { inputTimer = setTimeout(flushInput, backlog ? Math.max(budget, 30) : budget); return; }
1086
+ // Standing still: the host has this exact frame already. Say it again only as a keepalive.
1087
+ const pressed = Object.keys(pendingPresses).length > 0;
1088
+ let sig = '';
1089
+ try { sig = JSON.stringify([pendingA, pendingHeld, mine.rs]); } catch { sig = ''; }
1090
+ if (!pressed && sig && sig === lastInputSig && wall() - lastInputSentAt < IDLE_INPUT_MS) { idleSkipped += 1; return; }
1074
1091
  inSent.push(wall());
1075
1092
  inSeq += 1;
1076
1093
  const msg: Record<string, unknown> = { t: 'in', q: inSeq, a: pendingA, h: pendingHeld, r: mine.rs };
1077
1094
  if (Object.keys(pendingPresses).length) msg['p'] = pendingPresses;
1078
1095
  if (raw(msg)) {
1079
- inputOut.hit(wall()); pendingPresses = {};
1096
+ inputOut.hit(wall()); pendingPresses = {}; lastInputSig = sig;
1080
1097
  sentHist.push({ q: inSeq, a: pendingA, h: pendingHeld, at: wall(), r: mine.rs });
1081
1098
  if (sentHist.length > 64) sentHist.shift();
1082
1099
  }
@@ -1095,7 +1112,7 @@ export function createNetplay<S = unknown, A = unknown, C = unknown>(opts: Netpl
1095
1112
  const t = wall();
1096
1113
  return {
1097
1114
  role, seat, host, connected, offline, rtt, offset: Math.round(offset),
1098
- snapHzIn: snapIn.hz(t), snapHzOut: snapOut.hz(t), inputHzIn: inputIn.hz(t), inputHzOut: inputOut.hz(t),
1115
+ snapHzIn: snapIn.hz(t), snapHzOut: snapOut.hz(t), inputHzIn: inputIn.hz(t), inputHzOut: inputOut.hz(t), idleInputsSkipped: idleSkipped,
1099
1116
  lastSnapBytes, maxSnapBytes, bytesInPerS: perSecond(bytesIn), bytesOutPerS: perSecond(bytesOut),
1100
1117
  interpDelay: Math.round(interpDelay()), snapAgeP90: Math.round(ageP90), starvedPct: +starvedPct.toFixed(3), rejectedSnaps,
1101
1118
  owned: api.owned, pending: sentHist.length, stateKeys: stateMap.size,
package/package.json CHANGED
@@ -1,7 +1,7 @@
1
1
  {
2
2
  "name": "@homie-rocks/studio",
3
- "version": "0.5.0",
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), song and video pages from the music/ and videos/ manifests, optional R2 storage for large media, deploy, and the homie.rocks directory.",
3
+ "version": "0.7.0",
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, optional R2 storage for large media, deploy, and the homie.rocks directory.",
5
5
  "type": "module",
6
6
  "license": "Apache-2.0",
7
7
  "homepage": "https://homie.rocks/studio/",
@@ -37,6 +37,7 @@
37
37
  "worker/",
38
38
  "starters/",
39
39
  "media/",
40
+ "site/",
40
41
  "README.md",
41
42
  "LICENSE",
42
43
  "NOTICE"
package/site/SITE.md ADDED
@@ -0,0 +1,128 @@
1
+ # The studio site
2
+
3
+ `npm run build` (`homie-studio build`) makes a studio's whole site from the studio's own folder, and the
4
+ studio's Worker serves it. Every studio site has the same sections as homie.rocks, in the studio's own look,
5
+ and every game gets a landing page. Anything the studio puts in `site/` wins.
6
+
7
+ ## Sections
8
+
9
+ | Address | What it is | Shown when |
10
+ | --- | --- | --- |
11
+ | `/` | Home: the featured game as a full-bleed hero with Play, the live rooms, the games, the latest posts, videos and music | always |
12
+ | `/games/` | Every game, with its cover, pitch, players playing now, Play and About | the studio has a game |
13
+ | `/music/`, `/videos/` | Songs and videos (`media/MEDIA.md`) | the studio has one published |
14
+ | `/rooms/` | Every public room playing now across the studio's games, each with Join (`/api/rooms` is the same as JSON) | the studio has a game |
15
+ | `/posts/` | The studio's posts, newest first, with `/posts/feed.xml` (Atom) and `/posts/feed.json` (JSON Feed) | the studio has a post |
16
+
17
+ A section with nothing in it has no tab, and its address answers 404. Every page ends with a
18
+ "Made with Homie" link to https://homie.rocks/studio/. studio.json may add `"tagline"` (one line, on Home and in
19
+ the feeds) and `"site": { "featured": "<game id>" }` (the game on Home; otherwise the first with its own footage).
20
+
21
+ ## A game's landing: `/<id>/`
22
+
23
+ Made from the game's own files, nothing invented:
24
+
25
+ - **The hero**, full-bleed: the game's footage when it has some, else its cover (or key art) moving slowly, tinted
26
+ with the studio's colour so the words read. Footage is looked for in this order: `landing.hero` in game.json;
27
+ `games/<id>/hero/` by the house brands' names (`wide.mp4` and `tall.mp4`, their AV1 cuts `wide.av1.mp4` and
28
+ `tall.av1.mp4`, stills `wide.jpg` and `tall.jpg`); a trailer in `videos/manifest.json` whose `for.game` is the
29
+ game (its 16:9 `video`, 9:16 `vertical` and `poster`). A loop of 8 to 15 s under 3 MB each, muted, plays best.
30
+ - **The pitch** (`landing.pitch`, else the blurb), a big **Play** button that drops into a public room at once,
31
+ and the live line ("3 players playing right now", refreshed every 15 s from `/<id>/live`).
32
+ - **Phone, computer, TV**: the controls for each (`landing.controls`, else `credits.json` `controls`), a code a
33
+ phone scans to play, and how to put the game on a TV (`/<id>/tv` shows the room with its own join code).
34
+ - **Live rooms**, each with Join. When studio.json shares its stats (`stats.share`), also "played this week".
35
+ - **How to play**: `landing.about`, `landing.howToPlay` (a few lines), the controls, the room facts.
36
+ - **Credits**: the studio and `landing.credits` (`[{ "role", "name", "url" }]`); for a port, the original, its
37
+ author and licence and every part inside it (`credits.json`, as the port skill writes it), with the full
38
+ licence texts on `/<id>/credits`; and Homie's open engine.
39
+ - **Make a game like this**: the two commands that install the Homie plugin and the words to say. When the game
40
+ shares its source (the default; `"share": { "source": false }` keeps it closed) the words remix it from
41
+ `/games/<id>/source.json`, and the button goes to `https://homie.rocks/studio/?remix=<that address>`.
42
+
43
+ game.json's `landing` block, every key optional:
44
+
45
+ ```json
46
+ "landing": {
47
+ "pitch": "Blast rocks, not friends.",
48
+ "kicker": "A line above the title",
49
+ "headline": "The How to play heading",
50
+ "about": "A paragraph about the game.",
51
+ "howToPlay": ["Steer with the stick", "Fire at the edge"],
52
+ "controls": { "phone": "Stick left, FIRE right", "computer": "Arrows and Space", "tv": "Phones are the pads" },
53
+ "players": { "one": "pilot", "many": "pilots" },
54
+ "hero": { "video": "hero/wide.mp4", "tall": "hero/tall.mp4", "image": "hero/wide.jpg", "tallImage": "hero/tall.jpg",
55
+ "alt": "What the footage shows", "focus": "50% 40%", "tint": 30 },
56
+ "credits": [{ "role": "Music", "name": "Low Tide", "url": "https://example.com" }],
57
+ "theme": { "accent": "#ff3bd4", "glow": "#00eaff" },
58
+ "tv": true
59
+ }
60
+ ```
61
+
62
+ ## Posts: `posts/*.md`
63
+
64
+ One markdown file each. The name is the address, `posts/2026-09-30-we-are-live.md` is `/posts/we-are-live/`, dated
65
+ by its prefix unless the frontmatter says `date:` (a day, or a day and a time, UTC).
66
+
67
+ ```markdown
68
+ ---
69
+ title: We are live
70
+ date: 2026-09-30
71
+ summary: One line for the cards and the feeds.
72
+ image: /games/crown-thief/cover.jpg
73
+ game: crown-thief
74
+ song: theme
75
+ video: trailer
76
+ author: The studio
77
+ draft: false
78
+ ---
79
+
80
+ The body.
81
+ ```
82
+
83
+ `game:`, `song:` and `video:` link one of the studio's own (a link to one the site does not have is left out,
84
+ and the build says so); the post shows each as a card with Play, Listen or Watch. `draft: true` keeps it off the
85
+ site. README.md and names starting with `_` or `.` are not posts.
86
+
87
+ The markdown is a safe subset, rendered once at build time: headings (`#` is the page's second level), paragraphs,
88
+ **bold**, *italic*, ~~struck~~, `code`, fenced code, links, images, lists, quotes and rules. Raw HTML is shown as
89
+ text, and a link or an image goes only to `https://…` or a path on the site.
90
+
91
+ Each post is also a record (`/posts/feed.json`, `_homie.record`): `{ "$type": "rocks.homie.studio.post", title,
92
+ text (the markdown), createdAt, summary, links: [{ kind, id }] }`, the shape it keeps when studios publish posts to
93
+ atproto later. `/.well-known/homie-studio.json` lists the latest posts for the homie.rocks directory.
94
+
95
+ ## What `site/` overrides
96
+
97
+ | Put it in | What it does |
98
+ | --- | --- |
99
+ | `site/theme.json` | The tokens every page uses: `bg`, `fg`, `accent`, `glow`, `panel`, `accentInk` (colours: `#hex`, `rgb()`, `hsl()`, `oklch()`), `display`, `text`, `mono` (font lists), `fonts` (`[{ "family", "src": "/fonts/x.woff2", "weight": "400 800" }]`, files in `site/public`), `radius` (0 to 40), `mark` (a logo), `icon`, `social` (the share picture), `wordmark`, `uppercase: false`, `scheme: "light"`. Or `palette`: neon, dock, gold, acid, ember, orchid, tide, candy. A value that is not what it should be is dropped, with a warning. |
100
+ | `site/theme.css` | CSS after the site's own, on every generated page: restyle anything. |
101
+ | `site/partials/<name>.html` | A piece of every generated page: `head` (in `<head>`), `header` (the top line and its tabs), `footer` (the "Made with Homie" footer), `home` (a band on Home), `game` (a band on every landing), `game-<id>` (a band on one), `post` (after a post). `{{studio.name}}`, `{{game.name}}`, `{{game.id}}`, `{{game.play}}`, `{{year}}` are filled in. A band partial sits in one of the page's bands, unless it is its own `<section class="band">`. |
102
+ | `site/pages/<path>/index.html` (or `site/pages/<path>.html`) | A whole page at `/<path>/`, served as it is, instead of the generated one (`site/pages/index.html` is Home, `site/pages/<id>/index.html` a game's landing) or beside them (`/about/`). It may borrow the site's parts: `<!-- homie:style -->`, `<!-- homie:header -->`, `<!-- homie:footer -->`, `<!-- homie:script -->`. The rooms, the game files, the API and the owner's pages are never a page's. |
103
+ | `site/public/` | Files served as they are at the same path: fonts, a logo, hero footage, a page's pictures. |
104
+ | `site/src/worker.mjs` | The Worker. A studio that keeps a whole brand site of its own (with its own routes) wraps the template's Worker here, as the house brands do; everything it does not answer goes to the template. |
105
+
106
+ A studio with its own landing in `site/` keeps it: the template never replaces what the studio made.
107
+
108
+ ## The play page
109
+
110
+ `/<id>/play` puts the visitor in a public room at once and writes the room into the address, so a reload comes
111
+ back to it and a copied address brings a friend into it. A small room button at the edge (game.json
112
+ `"screen": { "share": "top-left" }` moves it off the game's own HUD; top right by default) opens:
113
+ **Invite** (the phone's share sheet, or the link copied), **Big screen** (`/<id>/tv` of this room, on another
114
+ screen) and the room's code. A room code the relay cannot use (1 to 32 letters, digits, `-` or `_`) is refused
115
+ on the page, never swapped for a public room. A player who typed no name gets a two-word handle.
116
+
117
+ The game runs in a sandboxed frame with an opaque origin, which is why its allow list delegates with `*`
118
+ (`fullscreen *; autoplay *; gamepad *`): Safari refuses a bare `gamepad` there (`getGamepads()` throws a
119
+ SecurityError), so a controller would not work.
120
+
121
+ ## Headers
122
+
123
+ Every HTML answer is `no-transform` (a custom domain's edge injects nothing: Web Analytics' automatic beacon
124
+ posts to a `/cdn-cgi/rum` a Worker does not serve). Generated pages are never framed (`x-frame-options: DENY`,
125
+ `frame-ancestors 'none'`), load scripts only from the site, and send `referrer-policy:
126
+ strict-origin-when-cross-origin` (another studio's stats see which site sent a visitor, never the path). The
127
+ play page and the game may be framed by the site itself and by the https origins studio.json lists in
128
+ `"site": { "frameAncestors": ["https://…"] }`.