@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.
- package/README.md +54 -2
- package/bin/homie-studio.mjs +75 -11
- package/lib/build.mjs +72 -9
- package/lib/cloudflare.mjs +28 -11
- package/lib/directory.mjs +2 -2
- package/lib/look.mjs +101 -0
- package/lib/markdown.mjs +121 -0
- package/lib/media.mjs +2 -2
- package/lib/scaffold.mjs +115 -8
- package/lib/site.mjs +437 -0
- package/lib/stats.mjs +125 -0
- package/lib/studio.mjs +43 -1
- package/netplay/NETPLAY.md +55 -12
- package/netplay/netplay.ts +19 -2
- package/package.json +3 -2
- package/site/SITE.md +128 -0
- package/worker/index.mjs +416 -136
- package/worker/pages.mjs +151 -208
- package/worker/room.mjs +37 -3
- package/worker/seats.mjs +20 -0
- package/worker/site.mjs +1052 -0
- package/worker/stats-page.mjs +144 -0
- package/worker/stats.mjs +314 -0
- package/worker/version.mjs +2 -0
package/netplay/NETPLAY.md
CHANGED
|
@@ -1,8 +1,19 @@
|
|
|
1
|
-
# Homie netplay contract, v1 (revision
|
|
2
|
-
|
|
3
|
-
Status: **v1, revision
|
|
4
|
-
2 added is either an optional field
|
|
5
|
-
|
|
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
|
|
99
|
-
|
|
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
|
|
268
|
-
|
|
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).
|
package/netplay/netplay.ts
CHANGED
|
@@ -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.
|
|
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),
|
|
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://…"] }`.
|