@homie-rocks/studio 0.44.1 → 0.45.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,6 +17,47 @@ 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.45.0] - 2026-10-10
21
+
22
+ **Plugin 0.45.0** · [#85](https://github.com/homie-rocks/homie/pull/85)
23
+
24
+ Choose room sizes in the hundreds and reduce the state each phone downloads.
25
+
26
+ ### Added
27
+
28
+ - Gate connections and batched concentrator links, with seat resumption after a link restart.
29
+ - Studio-selected `room.view.precisionM`, `nearM` and `farHz` for distant bodies. The controlled body keeps its exact movement state.
30
+
31
+ ### Changed
32
+
33
+ - For rules games, public room admission, rules compilation and deploy planning use the studio's requested `players.max` without the 32-player clamp.
34
+ - Ordered snapshot deltas and a shared spatial index reduce repeated state work. Rules rooms have no automatic guest ceiling for shared carrier addresses.
35
+
36
+ ### Upgrade notes
37
+
38
+ - Rebuilding generates the Gate and Concentrator exports, bindings and SQLite migration, including for studios whose Worker entry predates Gates. Custom Worker source and routes are preserved.
39
+ - Existing browser-hosted netplay games keep building, checking and running with the restored port host/HUD helpers and importer. Their 32-seat limit remains; a larger request prints the rewrite guide. New games and starters use rules plus view. A release gate packs this version and checks two browsers finishing a round in the unchanged released 0.32.1 starter. Retirement is a later step, after studio migrations and a shipped one-command assisted rewrite.
40
+
41
+ ## [0.44.3] - 2026-10-10
42
+
43
+ **Plugin 0.44.3**
44
+
45
+ Solid terrain and indexed map queries keep large arenas within the rules budget.
46
+
47
+ ### Added
48
+
49
+ - Solid triangulated height tiles: explicit bases and source diagonals preserve ramp sides, roofs and underpasses across movement, support, overlap and rays.
50
+ - Build-time spatial indexing for up to 100,000 static shapes, with query work charged by visited geometry on authority and prediction.
51
+
52
+ ### Fixed
53
+
54
+ - Terrain-heavy rules maps spawn and move within the default tick budget, including Stormbreak's losslessly prepared 4,058 shapes.
55
+ - Build and deployment retain the same committed Wrangler config while local rebuilds and deployment select complete immutable Worker generations.
56
+
57
+ ### Upgrade notes
58
+
59
+ - Height tiles remain open surfaces unless `base` is declared. Solid tiles collide against the whole body, so capsule clearance on a slope differs from the old foot-point surface.
60
+
20
61
  ## [0.44.1] - 2026-10-10
21
62
 
22
63
  **Plugin 0.44.1**
package/README.md CHANGED
@@ -202,30 +202,28 @@ game its studio chooses to share, each with its own licence ("Game parts", below
202
202
  RIGHTS.md has no "A remix" line. Whether a file may be handed on in a shared part is still its licence's.
203
203
  - `homie-studio game remix` answers with a sentence that points to parts; `office launch --remixable` is ignored.
204
204
 
205
- ## Rooms of up to 32
205
+ ## Room size and delivery
206
206
 
207
- Every room of a game has the seats its netplay manifest names: game.json's `netplay`
208
- block (`maxPlayers`), or a `netplay.json` beside game.json or in the game's build
209
- (`maxPlayers` or `players.max`), else game.json's `players.max`, else 8; at most 32.
210
- Bots and a late joiner taking a bot's body work the same at 32 as at 4.
211
- Server rules keep running when a browser leaves; older browser-hosted games elect a new host. One address may hold every seat plus four sockets, so a party
212
- on one Wi-Fi (or strangers behind one carrier's address) fills a room with a TV beside it.
207
+ `players.max` in game.json is the room's chosen positive integer capacity; omitted means 8.
208
+ There is no 32-player ceiling or default per-address admission limit. Public games use
209
+ rules plus view with `room.host: "server"`. Gates carry the browser connections and
210
+ concentrators combine Gate links for larger rooms. Server rules survive browser departures.
211
+ New browser/offline games use the same rules contract. Existing netplay games and their
212
+ port host helpers remain supported for upgrades, with their original 32-seat limit.
213
+ For larger rooms, follow `guides/game/REWRITE.md`.
213
214
 
214
- ## A game's netplay manifest
215
+ For example, a 300-player room can choose spatial delivery explicitly:
215
216
 
216
- For rules games the build derives the revision from the rules and view; do not bump
217
- `netplay.version` or tune browser host election. `room` sets hosting and tick rate.
218
- The version and stall settings below describe existing browser-hosted games.
219
-
220
- game.json `"netplay"` says more than the room's size. All of it is optional, and `homie-studio build`
221
- writes it to the catalogue for the site's rooms (`netplay/NETPLAY.md` sections 22 to 24):
217
+ ```json
218
+ {"players":{"min":1,"max":300},"entry":"src/view.ts","room":{"host":"server","view":{"radiusM":12,"precisionM":0.01,"nearM":4,"farHz":5}}}
219
+ ```
222
220
 
223
- | Field | What it does |
224
- |---|---|
225
- | `maxPlayers` | Seats in a room, up to 32. |
226
- | `version` | The game's revision (a word or a number). Bump it when a change makes an already-open tab unable to play with a new one: a room then runs one build at a time, strangers are matched within the live build, and an old tab is told to reload at its round's break. |
227
- | `stallMs` | How long a host may send no snapshot before the room is handed to another browser: 1500 (the default) to 10000. A heavy 3D game whose frames hitch on a slow phone raises it; the helper also sends a heartbeat for up to 4 s while a host's frames are stuck. |
228
- | `params` | Names of the play page's query parameters to hand the game (`/<id>/play?seed=42`), beside `debug` and `q`, which always are. The game reads them as `net.params`. |
221
+ The studio chooses the radius, precision and distant update rate for its game. The
222
+ controlled body stays precise. Whole-room state remains the default. The build runs a
223
+ local capacity trial; its numbers are not Cloudflare capacity guarantees. Test the actual
224
+ mechanic at the chosen size. Rules builds derive their revision from rules and view;
225
+ do not manually bump `netplay.version`. Optional `netplay.params` still selects play-page
226
+ query parameters passed to the view, beside `debug` and `q`.
229
227
 
230
228
  The game's frame is sandboxed without `allow-same-origin` (it must never read the site's storage or
231
229
  the owner's session), so `localStorage` throws inside it: a setting or a personal best goes in
@@ -1238,8 +1236,8 @@ npx homie-studio port check my-game --url http://127.0.0.1:8787 # the owner te
1238
1236
  | `template/` (repository root), `scripts/template.mjs` | The public "Deploy to Cloudflare" template, generated from `new --template`. |
1239
1237
  | `lib/port.mjs` | `port plan` (reads a game and grades the port) and `port import`. |
1240
1238
  | `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. |
1241
- | `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). |
1242
- | `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). |
1239
+ | `port/` | The port toolkit (`@homie-rocks/studio/port`, or `window.HomiePort` from `homie-port.js` in a static game): the touch kit, keys, camera rules, bots, 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). |
1240
+ | `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`: studio-chosen room sizes. |
1243
1241
  | `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. |
1244
1242
  | `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`. |
1245
1243
  | `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>`. |
@@ -1,8 +1,10 @@
1
1
  # Rewrite a browser-hosted game as rules plus view
2
2
 
3
3
  Use this path when the owner asks to move an old game's rules onto the server.
4
- There is no automatic converter. An ordinary edit is not permission to rewrite;
5
- old games continue to work as they are. Read RULES.md before starting.
4
+ There is no automatic converter yet. Version 0.45.0 keeps existing netplay games
5
+ and their port host helpers working, with their original 32-seat limit. Larger
6
+ rooms require this rewrite. Keep a baseline copy and preserve the game throughout.
7
+ Read RULES.md before starting.
6
8
 
7
9
  1. Read this game's codex, manifest, entry, host loop, input, packing/checkpoint
8
10
  code, bots, saves and tests. Record what players must keep: controls, timings,
@@ -76,3 +78,19 @@ for existing movement code. Keep build/damage/door logic in entity handlers.
76
78
  Never copy collision lists into player motion or implement a second replay loop.
77
79
  Test visible prediction at 300 ms with loss, rejoin and saved-room restore.
78
80
  The installed toolkit's `guides/game/` is the version-matched source for this guide.
81
+
82
+
83
+ ### Solid terrain and query cost
84
+
85
+ Preserve each source height triangle and its diagonal. Declare height tiles with
86
+ `base` for solid ramps, terraces, roofs and underpasses; omitting it deliberately
87
+ means an open top surface. Do not replace slopes with boxes, invisible walls or
88
+ coarser sampling. Test both sides, tall end, ascent/descent, jump, dash, underside,
89
+ step head clearance, overlap and weapon/camera/blast rays. A discrete reference
90
+ mover may stop one input increment before the continuous capsule contact; report
91
+ both numbers instead of changing its body radius to make them match.
92
+
93
+ Map compilation indexes up to 100,000 shapes. Measure the default-budget guarded
94
+ spawn, six initial bodies/eight seats and traversal/combat with live cover. Repeat
95
+ on seeded virtual time with 300 ms/loss, rejoin and saved-room restore, then smoke
96
+ test real browser sockets. Geometry passing alone does not complete a game rewrite.
@@ -36,7 +36,7 @@ export default defineRules({
36
36
 
37
37
  The map must contain a nonempty `spots.start` list for that example:
38
38
  `{"bounds":{"min":[-12,-7],"max":[12,7]},"boxes":[],"spots":{"start":[[-5,0],[5,0]]}}`.
39
- Declare `players.max` in game.json (1–32); use `entry: "src/view.ts"` and
39
+ Declare `players.max` in game.json (a positive integer, including hundreds); use `entry: "src/view.ts"` and
40
40
  `room: { host: "server" }`. Only public tunables are visible in move.ts.
41
41
 
42
42
  `index.html` is the browser entry document; include `<script type="module" src="./assets/main.js"></script>`. The manifest names the source, but HTML loads this relative built bundle. Never point HTML at /src/view.ts: that URL is not served. The build rewrites assets/main.js to its hashed bundle. A minimal manifest is:
@@ -71,7 +71,7 @@ With `space: { dims: 3 }`, z is up and pos is the body's feet. Three.js renders
71
71
  Use continuous sweeps, then slide the remainder along the contact tangent.
72
72
  `world.sweep` includes entity bodies. Movement sees the map plus declared live colliders through `ctx.world` (also exposed on `ctx.map`).
73
73
 
74
- | Map list (at most 4,096 solids in all) | JSON shape |
74
+ | Map list (at most 100,000 shapes in all) | JSON shape |
75
75
  | --- | --- |
76
76
  | boxes | `{ "min": [0,0,0], "max": [2,2,1] }` |
77
77
  | circles (vertical columns in 3D) | `{ "at": [4,0,0], "r": 1 }` |
@@ -79,8 +79,25 @@ Use continuous sweeps, then slide the remainder along the contact tangent.
79
79
  | capsules | `{ "at": [0,0,0], "r": 0.5, "height": 2 }` |
80
80
  | heightTiles | `{ "at": [0,0,0], "size": [2,2], "heights": [0,1,0,1] }` |
81
81
 
82
- Height tiles support the foot point, have open edges and no underside; use boxes
83
- for platforms and ceilings. A rendered mesh is not a collider. Draw solid geometry
82
+ Height tiles without `base` retain their open, foot-point top surface. Add `base`
83
+ (a height offset from `at.z`, at or below all four samples) for a closed solid:
84
+ vertical sides, the sampled top and a flat underside. For example the relay ramp
85
+ is `{ "at": [-2.5,5,0], "size": [5,12], "heights": [4,4,0,0], "base": 0 }`.
86
+ Raise `at.z` or `base` for roofs and underpasses. `diagonal` defaults to `"00-11"`;
87
+ `"10-01"` preserves meshes split the other way. Row order is 00,10,01,11.
88
+ Noncoplanar cells are two triangular prisms, never a convex hull or bilinear patch.
89
+ Solid tiles use the whole declared body for sweeps, support and overlap; rays hit
90
+ sides and undersides as well as tops. Capsules sit above slopes by their rounded
91
+ foot's clearance, rather than embedding their lower sphere in a foot-point surface.
92
+
93
+ `compileMap` builds an immutable spatial index, shared in meaning by authority and
94
+ prediction. Queries charge visited bounds and actual geometry tests, not every
95
+ shape in the map; live colliders remain a separate overlay. A 100,000-shape map is
96
+ supported without decimation or a larger tick budget. Dense local geometry still
97
+ costs work. Walk slopes by projecting the remaining sweep onto a walkable contact
98
+ normal. For a step, sweep upward for head clearance, sweep across, then support
99
+ and sweep down; never teleport upward through a ceiling. Keep jump and snap-down
100
+ limits in the shared move function. A rendered mesh is not a collider. Draw solid geometry
84
101
  from room.map, including spheres/capsules/heightTiles. Changing the map changes
85
102
  state compatibility; it is static build data, not a per-room saved copy.
86
103
 
@@ -117,7 +134,7 @@ with a real touchscreen tap in Chrome; calling `element.click()` does not test
117
134
  touch delivery.
118
135
  Use `rounds: { seconds: 0, breakSeconds: 0 }` for manual rounds, and no fill bots
119
136
  for an app. A wall/TV watches without taking a seat. Each interactive phone
120
- takes a seat (up to 32); shared state is replicated, public and transient. An app's
137
+ takes one of the room's declared seats; shared state is replicated, public and transient. An app's
121
138
  lasting, authorized business data belongs in `createAppRecords`, not in room fields.
122
139
 
123
140
  ## State and field types
@@ -663,8 +680,7 @@ check green. Never rename rules.ts away or fall back to old-style hosting.
663
680
  `room.probe` adds truthful frame/mechanic/render counters to the built-in rules probe.
664
681
  Use the normal build, local dev and two-browser check, then play the requested mechanic
665
682
  for a minute with delay. Movement should respond before the authoritative response;
666
- score and other shared outcomes still arrive with the network. Milestone 1 supports
667
- up to 32 seats; neither frame rate nor feel is guaranteed by a passing build.
683
+ score and other shared outcomes still arrive with the network. The studio chooses `players.max`; neither frame rate nor feel is guaranteed by a passing build.
668
684
 
669
685
  Room state is saved automatically (default movementSeconds: 1, plus round end,
670
686
  pause and finish). After 60 seconds with no person the room ends even if screens or
@@ -673,7 +689,7 @@ server-verified money. All replicated fields are public, including disguised rol
673
689
  Use a separate authorized records service for private or lasting app records.
674
690
 
675
691
 
676
- ## Spatial delivery (0.44.0; milestone 2 slice 1)
692
+ ## Spatial delivery (0.45.0; milestone 2 slice 2)
677
693
 
678
694
  For a server game with a larger map, the studio's AI can set
679
695
  `"room": { "view": { "radiusM": 32 } }` in game.json to send each player only
@@ -691,12 +707,33 @@ may be absent from room.each while its geometry still blocks movement.
691
707
  Shared state, effects, rosters and watcher state are still public: this is not a
692
708
  hidden-information feature. Browser hosting sends the whole room.
693
709
 
694
- A request for hundreds of players also needs the next Gates release: 0.44.0's
695
- public build/join path still has the milestone 1 restriction of 32 seats. Do not
696
- silently clamp a requested bigger game or claim this setting raises admission.
697
- When Gates ship, `players.max` will choose the room's layout automatically; the
698
- studio will not create or find infrastructure by hand. The slice plan and local
699
- 300-client measurements are in docs/rooms-milestone-2-notes.md in Homie's repo.
710
+ Set the room size in `players.max`. The same setting feeds rules compilation,
711
+ public admission and the automatic Gate layout; there is no 32-seat clamp.
712
+ For example, in game.json:
713
+
714
+ ```json
715
+ {
716
+ "players": { "min": 1, "max": 300 },
717
+ "room": {
718
+ "host": "server",
719
+ "view": { "radiusM": 12, "precisionM": 0.01, "nearM": 4, "farHz": 5 }
720
+ }
721
+ }
722
+ ```
723
+
724
+ These are example visual settings, not defaults. `precisionM` rounds remote
725
+ positions and velocities in the delivered view; controlled bodies stay exact.
726
+ Remote bodies outside `nearM` update at `farHz`; visibility exits are immediate.
727
+ Rules, collision, saves and shared state stay authoritative and unchanged.
728
+ Views must interpolate distant motion and dispose/recreate entities on view exit
729
+ and entry. Omit the settings for full precision and the room's normal tick rate.
730
+
731
+ A small room embeds delivery in its Table. Larger rooms open Gate connections;
732
+ more than eight Gates use concentrators. The studio template supplies their exports, bindings and
733
+ migration; upgrade the template before deploying an existing studio. No infrastructure naming or studio admission ceiling is required.
734
+ Read the local measurements and their limitations in
735
+ `docs/rooms-milestone-2-notes.md` before making a capacity claim.
736
+
700
737
  ## Live collision geometry (0.44.1)
701
738
 
702
739
  ```ts
@@ -8,7 +8,9 @@ metadata:
8
8
 
9
9
  Read the installed toolkit's `node_modules/@homie-rocks/studio/guides/game/{RULES,REWRITE}.md`
10
10
  before authoring: these match its runtime even when this plugin is older. Toolkit
11
- check/build report stale installed plugin copies and the update step. For live
11
+ check/build report stale installed plugin copies and the update step. For solid slopes and terrain, use heightTiles with `base` and the source
12
+ `diagonal`; test sides, underside, step clearance and rays. Maps are indexed at
13
+ compile time up to 100,000 shapes; measure default-budget spawn and traversal. For live
12
14
  cover, doors and platforms, use declared entity `collider` state and movement's
13
15
  `ctx.world.sweep/support/overlaps`; test prediction, loss, rejoin and restore.
14
16
 
@@ -28,9 +30,10 @@ other games and every existing test file, including its import paths. If an old
28
30
  do not redirect the tests to a new helper file. Run the original test command.
29
31
 
30
32
  For a request involving a larger room or map, read RULES.md's **Spatial delivery**
31
- section. 0.44.0 can reduce what a server player receives with `room.view.radiusM`;
32
- Gates and public admission above 32 seats are the next slice. Choose a radius
33
- only when it fits the game. No rules rewrite or new profile is needed.
33
+ section. `players.max` chooses the room size and Gate layout. Choose `room.view.radiusM`
34
+ for the playable neighbourhood, and visual precision and far update rates to fit
35
+ the game. Local measurements are in docs/rooms-milestone-2-notes.md; they are not
36
+ Cloudflare or physical-phone capacity guarantees.
34
37
 
35
38
  ## Start it
36
39
 
@@ -78,7 +81,7 @@ make every change it needs in ONE file_edit with an edits list. Build after each
78
81
  coherent change across rules, movement and view; use diagnostics to guide the next edit.
79
82
 
80
83
  New games use `game.json` `"entry": "src/view.ts"`, `"room": { "host": "server" }`,
81
- and `players.max` at most 32. HTML loads `<script type="module"
84
+ and a studio-chosen `players.max`. HTML loads `<script type="module"
82
85
  src="./assets/main.js"></script>`, not the source-file URL. Choose seats, tick rate (20 by default), dimensions and
83
86
  starter yourself from the request; never ask the person an engineering question.
84
87
  `coin-dash` is the smallest reference; `gem-rush` adds bumps; `ember-vale` adds
@@ -140,9 +143,10 @@ a plain browser bundle is not a multiplayer game.
140
143
  responsible half, preserve unrelated play, and rebuild. The build/state hashes
141
144
  manage compatibility automatically; do not hand-pack snapshots or bump netplay.version.
142
145
  Recheck the changed mechanic, a late join and a reload in two browsers.
143
- - **Changing an old-style game:** ordinary requested edits keep it working untouched
144
- in its existing architecture. When its owner asks to move rules to the server,
145
- follow [REWRITE.md](REWRITE.md). There is no automatic converter.
146
+ - **Changing an old-style game:** existing netplay games keep working on 0.45.0
147
+ with their original 32-seat limit. Preserve their behavior on ordinary edits.
148
+ For a server migration or larger room, follow [REWRITE.md](REWRITE.md).
149
+ New games use rules plus view; there is no automatic converter yet.
146
150
  - Phones get touch, computers get keys; typed names and chat are text only.
147
151
  Keep native DOM buttons outside `guardGestures`' touch-drag selector; otherwise bind
148
152
  touch pointers explicitly. Verify actual touchscreen taps, not only `.click()`.
package/lib/build.mjs CHANGED
@@ -62,7 +62,7 @@ import { PACKAGE_ROOT, isRulesGame, listExperiences as listGames, readStudio } f
62
62
  // module is: Homie for Claude Desktop starts the toolkit with no node_modules beside it (scripts/desktop.mjs).
63
63
  let rulesBuildModule = null;
64
64
  const rulesBuild = () => (rulesBuildModule ??= import('./rules-build.mjs'));
65
- import { SEAT_MAX, netplayRow } from '../worker/seats.mjs';
65
+ import { seatCount, SEAT_MAX, netplayRow } from '../worker/seats.mjs';
66
66
  import { STUDIO_VERSION } from './version.mjs';
67
67
  import { basedOnRow, licenseOf, retiredKeys } from '../worker/license.mjs';
68
68
  import { openStage, swapIn } from './stage.mjs';
@@ -266,10 +266,14 @@ export function netplayOf(g, out = null) {
266
266
  }
267
267
 
268
268
  /**
269
- * How many seats a room of this game has: the netplay manifest's `maxPlayers` (or `players.max`), else game.json's
270
- * `players.max`, else 8; at most SEAT_MAX (32). `asked` is what the game named, so a build can say it was capped.
269
+ * Room size comes from game.json's players.max, else 8 (32 for apps).
270
+ * Rules games have no admission ceiling; existing netplay games retain their manifest precedence and 32-seat limit.
271
271
  */
272
272
  export function seatsFor(g, net = netplayOf(g)) {
273
+ if (isRulesGame(g)) {
274
+ const max = seatCount(g.players?.max);
275
+ return { min: Math.min(max, seatCount(g.players?.min, 1)), max, asked: max };
276
+ }
273
277
  const named = [net.maxPlayers, net.players?.max, g.players?.max].map((n) => Math.floor(Number(n))).find((n) => Number.isFinite(n) && n >= 1);
274
278
  const asked = named ?? (g.kind === 'app' ? 32 : 8);
275
279
  const max = Math.min(SEAT_MAX, asked);
@@ -390,8 +394,7 @@ export async function buildGameFiles(esbuild, root, g, out, { maps = false, sour
390
394
  let chunks = [];
391
395
  const budgets = modelBudgetsOf(g, log);
392
396
  // RULES PLUS VIEW (rooms on the server). A game with "room" in game.json and a src/rules.ts has its rules checked, guarded and loaded first:
393
- // a rule the wall refuses stops the build here, with the line named. A game without one is hosted by a player's
394
- // browser exactly as before, and cannot ask for the server.
397
+ // a rule the wall refuses stops the build here, with the line named. Existing netplay games retain their browser host.
395
398
  const ruled = isRulesGame(g);
396
399
  if (!ruled && g.room?.host === 'server') {
397
400
  throw new Error(`${label}/${manifest} asks for "room": { "host": "server" }, but ${mode === 'bundle' ? 'this game has no src/rules.ts: its rules are inside its own code and run in a player\'s browser. Ask for it to be rewritten as rules plus view' : 'a ported game is someone else\'s browser code, which the server cannot run. It stays hosted by a player\'s browser'}.`);
@@ -517,8 +520,8 @@ export async function build(root, { only = null, log = () => {}, deploy = proces
517
520
  for (const g of games) if (g.kind === 'app') { const bad = appProblems(g); if (bad.length) throw new Error(`apps/${g.id}/app.json: ${bad.join('; ')}`); }
518
521
  // `--types`: the games' TypeScript is checked first (esbuild only strips types, it never reads them), and a type
519
522
  // error stops the build before anything is built.
520
- // Rules always use their generated capability types, including the view. The legacy
521
- // compiler sees broad source types and can falsely reject valid rules callbacks.
523
+ // Rules always use generated capability types, including the view. Existing netplay games and local apps
524
+ // use the source type checker.
522
525
  const legacy = games.filter((g) => !isRulesGame(g));
523
526
  const typed = types && legacy.length ? typecheck(root, legacy, { log }) : null;
524
527
  // What the build before this one made, to say which games changed.
@@ -561,7 +564,7 @@ async function buildInto(dist, { esbuild, studio, shop, live, games, before, typ
561
564
  const bytes = bundle ? statSync(join(out, bundle)).size : dirBytes(out);
562
565
  const later = chunks.reduce((n, c) => n + statSync(join(out, c)).size, 0);
563
566
  const seats = seatsFor(g, netplayOf(g, out));
564
- if (seats.asked > SEAT_MAX) log(`warning: games/${g.id} asks for ${seats.asked} players; a room holds at most ${SEAT_MAX}, so its rooms have ${SEAT_MAX} seats`);
567
+ if (seats.asked > SEAT_MAX && !rules) log(`games/${g.id} asks for ${seats.asked} players; browser-hosted netplay rooms retain their ${SEAT_MAX}-player limit, so this build uses ${SEAT_MAX} seats. For larger rooms, rewrite as rules plus view: node_modules/@homie-rocks/studio/guides/game/REWRITE.md`);
565
568
  built.push({
566
569
  id: g.id, name: g.name, mode, bytes, ms: Date.now() - started, warnings, seats: seats.max,
567
570
  ...(rules ? { capacityTrial: rules.check.capacityTrial } : {}),
@@ -1,3 +1,5 @@
1
+ import {workerEntry} from './worker-build.mjs';
2
+ import { seatCount } from '../worker/seats.mjs';
1
3
  import { refreshWorkerConfig } from './worker-config.mjs';
2
4
  import { paidReleases, uploadPaidParts } from './parts-upload.mjs';
3
5
  /**
@@ -183,7 +185,7 @@ export function deployPlan(root) {
183
185
  const studio = readStudio(root);
184
186
  // Games whose rules run on the server (a src/rules.ts, and not "room": { "host": "browser" }): one line each.
185
187
  const hosted = listGames(root).filter((g) => isRulesGame(g) && g.room?.host !== 'browser').map((g) => {
186
- const seats = Math.max(1, Math.min(32, Math.floor(Number(g.players?.max)) || 8));
188
+ const seats = seatCount(g.players?.max);
187
189
  const tickHz = Math.max(1, Math.min(60, Math.floor(Number(g.room?.tickHz)) || 20));
188
190
  const use = hostedUse({ seats, inputHz: Math.max(1, Math.min(tickHz, Math.floor(Number(g.room?.inputHz)) || tickHz)) });
189
191
  return { id: g.id, name: g.name ?? g.id, ...use };
@@ -381,14 +383,14 @@ async function deployLocked(root, { log = () => {}, homie, fetchFn = null, ownRo
381
383
  if(subscriptions.code!==0)return refuse('Could not register studio functions',subscriptions.out);
382
384
 
383
385
  const started = Date.now();
384
- let dep = w(['deploy', ...repoVar(root)]);
386
+ let dep = w(['deploy', ...(workerEntry(root) ? [workerEntry(root)] : []), ...repoVar(root)]);
385
387
  if (dep.code !== 0 && explainCloudflare(dep.out, accountId)?.needs === 'workers-dev-subdomain') {
386
388
  const headers = await cloudflareAuth({ env: { ...process.env, ...projectsCloudflareEnv(root) }, bin: wranglerBin(root), cwd: root,
387
389
  exec: async (_bin, args) => { const r = w(args, { cwd: root }); return { code: r.code, stdout: r.stdout }; } });
388
390
  const address = await registerWorkersAddress({ accountId, slug: studio.slug, headers, ...(fetchFn ? { fetchFn } : {}) });
389
391
  if (!address.ok) return { ok: false, command: 'deploy', needs: 'workers-dev-subdomain', why: address.next };
390
392
  step(address.created ? 'registered the account’s workers.dev address automatically' : 'kept the account’s existing workers.dev address');
391
- dep = w(['deploy', ...repoVar(root)]);
393
+ dep = w(['deploy', ...(workerEntry(root) ? [workerEntry(root)] : []), ...repoVar(root)]);
392
394
  }
393
395
  if (dep.code !== 0) return refuse(`wrangler deploy failed: ${dep.out.trim().split('\n').slice(-6).join(' ')}`, dep.out);
394
396
  if (paidReleases(root).length) {
@@ -607,7 +609,7 @@ export async function ciDeploy(root, { log = () => {} } = {}) {
607
609
  } catch { step('could not add the Workers AI binding to wrangler.jsonc: the guides answer from the game\'s script'); }
608
610
  }
609
611
  const started = Date.now();
610
- const dep = w(['deploy', ...repoVar(root)]);
612
+ const dep = w(['deploy', ...(workerEntry(root) ? [workerEntry(root)] : []), ...repoVar(root)]);
611
613
  if (dep.code !== 0) return refuse(`wrangler deploy failed: ${dep.out.trim().split('\n').slice(-6).join(' ')}`, dep.out);
612
614
  step(`deployed ${names.worker ?? 'the Worker'} in ${Math.round((Date.now() - started) / 1000)} s`);
613
615
  const repo = repoOf(root);
package/lib/dev.mjs CHANGED
@@ -1,3 +1,4 @@
1
+ import {workerEntry} from './worker-build.mjs';
1
2
  import { networkInterfaces } from 'node:os';
2
3
  /**
3
4
  * `homie-studio dev` and `dev --stop`: the whole site on this computer (pages, rooms in a local Durable Object, D1),
@@ -194,10 +195,11 @@ export function devConfig(root, remoteAi) {
194
195
  const stripped = routesOf(json).map((r) => r.pattern);
195
196
  const routed = ROUTE_KEYS.some((k) => k in json);
196
197
  const aiNote = remoteAi ? 'AI guides think with Workers AI (remote, billed to the signed-in account).' : null;
197
- if (Boolean(json.ai) === remoteAi && !routed) return { args: [], note: aiNote, stripped: [] };
198
+ const entry=workerEntry(root);
199
+ if (!entry && Boolean(json.ai) === remoteAi && !routed) return { args: [], note: aiNote, stripped: [] };
198
200
  const abs = (p) => (p && !p.startsWith('/') ? join(base, p) : p);
199
201
  delete json.$schema;
200
- json.main = abs(json.main);
202
+ json.main = entry ?? abs(json.main);
201
203
  if (json.assets?.directory) json.assets.directory = abs(json.assets.directory);
202
204
  json.d1_databases = (json.d1_databases ?? []).map((d) => ({ ...d, ...(d.migrations_dir ? { migrations_dir: abs(d.migrations_dir) } : {}) }));
203
205
  for (const k of ROUTE_KEYS) delete json[k];
package/lib/mcp.mjs CHANGED
@@ -35,7 +35,7 @@ export const INSTRUCTIONS = `Homie Studio, on this computer: make game studios a
35
35
 
36
36
  Carry the person's request through to a working result without a lesson or an interview. Use their name or business name, otherwise "My Studio". Setup, dependencies, a plan, building and checking are AI work, not approval gates. Choose engineering and design defaults from the request; record them in CODEX.md so the person can change them later by asking. Phone and computer controls, a small first version, local sound and CC0 art are the defaults. No paid media unless requested with a budget. A business or charity may need an app or a site, not a game: do not make them play a demo or choose a genre.
37
37
  Run setup_status (for a studio that is not made yet, pass its name as studio), studio_scaffold (NO game until one is requested), then build the requested thing and check it. A demo and a small-change lesson are optional only when requested. Look for existing engine mechanisms and parts_find before building systems. Use few, large edits: one file_edit carries every change to a file in its edits list. game_plan records your decisions; it does not require an interview. Install missing prerequisites yourself with setup_prepare; follow studio_job without asking the person to retry. Optional providers never block.
38
- New games and apps needing transient shared live state use rules plus view with room.host server. Read studio_guide topic game and its file RULES.md before writing rules: declared entity/shared state and events, guarded deterministic move for immediate local prediction, and a browser view for input and rendering. Read file REWRITE.md when an owner asks to move an old game's rules to the server; no automatic converter exists and ordinary old games keep working. Repair the full build diagnostics and prove the requested behavior in two browsers. Current rooms have at most 32 seats; larger rooms and private per-seat state are not delivered. Apps use authorized app records for lasting/private business data; room hosting is not staff permission.
38
+ New games and apps needing transient shared live state use rules plus view with room.host server. Read studio_guide topic game and its file RULES.md before writing rules: declared entity/shared state and events, guarded deterministic move for immediate local prediction, and a browser view for input and rendering. Read file REWRITE.md when an owner asks to move an old game's rules to the server; no automatic converter exists; existing netplay games keep building and running with their original 32-seat limit; larger rooms require a rewrite. Repair the full build diagnostics and prove the requested behavior in two browsers. Set players.max to the chosen room size, including hundreds; public connections use Gates. Choose room.view radiusM, precisionM, nearM and farHz explicitly and measure at the requested size. Private per-seat state is not delivered. Apps use authorized app records for lasting/private business data; room hosting is not staff permission.
39
39
  Show progress briefly and give the preview link as soon as it works. Only when going live was requested, studio_deploy with plan: true says what it creates and costs; cloudflare_login asks for browser approval once; then studio_deploy. Publish to the directory only when requested (offer once after going live). Honour an existing request to deploy or list; do not ask again except for a host's mandatory approval.
40
40
  For a shop, stripe_login (shop connect) syncs Products, Prices, Payment Links and the webhook through one Stripe browser approval. No human-handled key. Follow studio_job and the result's single next step; rerun after shop.json changes. Test purchase, webhook, grant and refund with stand-ins before going live. Live payments only when requested.
41
41
  For a custom domain already on Cloudflare, studio_domain prepares the exact hostname route using the existing sign-in; studio_deploy applies it and Cloudflare creates DNS and TLS. Never ask for zone or account ids. Keep workers.dev until a domain is requested.
@@ -1,3 +1,4 @@
1
+ import { seatsOf } from '../worker/seats.mjs';
1
2
  /**
2
3
  * What `homie-studio build` does for a game written as rules plus view (rooms-milestone-1-design.md, section 8).
3
4
  *
@@ -145,7 +146,7 @@ export async function prepareRules(esbuild, root, g, { log = () => {}, longCheck
145
146
  if (!guarded.ok) throw new Error(`${g.kind === 'app' ? 'apps' : 'games'}/${g.id}: its rules were refused.\n${guarded.problems.slice(0, 20).map((p) => ` ${problemLine(p)}`).join('\n')}${guarded.problems.length > 20 ? `\n … and ${guarded.problems.length - 20} more` : ''}`);
146
147
  const tune = readJson(join(g.dir, 'tunables.json')) ?? {};
147
148
  const map = readMap(g);
148
- const seats = Math.max(1, Math.min(32, Math.floor(Number(g.players?.max)) || 8));
149
+ const seats = seatsOf(g);
149
150
  const bundle = await loadRules(esbuild, root, g.id, guarded.code, { bundleOnly: true });
150
151
  const data = { id: g.id, tune, map, room: g.room, seats, vocab: readJson(join(g.dir, 'agents.json')), sites: guarded.sites };
151
152
  // The declarations first, for the type check: a wrong program is named by the compiler before anything is played.
package/lib/scaffold.mjs CHANGED
@@ -71,7 +71,7 @@ export function slugify(name) {
71
71
  export function wranglerConfig({ worker, name, d1, d1Id = null, r2 = null, layout = 'root', main = null, ai = false, routes = null, paidParts = false, partsRateLimit = null, triggers = null }) {
72
72
  const at = layout === 'site' ? { schema: '../node_modules', main: 'src/runtime/worker.js', dist: './dist', migrations: 'migrations' }
73
73
  : { schema: 'node_modules', main: 'site/src/runtime/worker.js', dist: './site/dist', migrations: 'site/migrations' };
74
- const rooms = [{ name: 'TABLE', class_name: 'Table' }, { name: 'LOBBY', class_name: 'Lobby' }];
74
+ const rooms = [{ name: 'TABLE', class_name: 'Table' }, { name: 'LOBBY', class_name: 'Lobby' }, { name: 'GATE', class_name: 'Gate' }, { name: 'CONCENTRATOR', class_name: 'Concentrator' }];
75
75
  const config = {
76
76
  $schema: `${at.schema}/wrangler/config-schema.json`,
77
77
  name: worker,
@@ -94,7 +94,7 @@ export function wranglerConfig({ worker, name, d1, d1Id = null, r2 = null, layou
94
94
  ...(triggers ? { triggers: paidParts ? { ...triggers, crons: [...new Set([...(triggers.crons ?? []), '*/5 * * * *'])] } : triggers } : {}),
95
95
  assets: { directory: at.dist, binding: 'ASSETS', run_worker_first: true },
96
96
  durable_objects: { bindings: rooms },
97
- migrations: [{ tag: 'v1', new_sqlite_classes: ['Table', 'Lobby'] }],
97
+ migrations: [{ tag: 'v1', new_sqlite_classes: ['Table', 'Lobby'] }, { tag: 'v2-gates', new_sqlite_classes: ['Gate', 'Concentrator'] }],
98
98
  d1_databases: [{ binding: 'DB', database_name: d1, ...(d1Id ? { database_id: d1Id } : {}), migrations_dir: at.migrations }],
99
99
  ...(r2 ? { r2_buckets: [{ binding: 'MEDIA', bucket_name: r2 }, ...(paidParts ? [{ binding: 'PURCHASE_MEDIA', bucket_name: `${r2.slice(0, 52)}-purchases` }] : [])] } : {}),
100
100
  // Workers AI, only when a server's AI guides think with it (agents_brain workers-ai; deploy adds it, 0.17.0).
@@ -291,7 +291,7 @@ studio's pinned copy, never a registry lookup of the bare name.
291
291
  Start with coin-dash, gem-rush, ember-vale, gem-rush-3d or hero-rush-3d; all five follow this contract.
292
292
  In games/<id>/src/rules.ts, defineRules declares entities, fields, handlers, events, rounds and companions.
293
293
  The view calls openRoom and sends input/commands; it draws, plays sound and handles controls. It never
294
- decides a hit, score, spawn, turn or result. Keep game.json room.host set to server, up to 32 seats.
294
+ decides a hit, score, spawn, turn or result. Keep game.json room.host set to server and players.max to the chosen room size.
295
295
  - **Movement feels immediate through prediction.** Put the shared defineMove callback in src/move.ts;
296
296
  it runs authoritatively on the server and immediately for your own view, then reconciles. Other bodies
297
297
  interpolate. Use the starter's sweep/slide and room.me/room.each poses; no second browser physics loop,
@@ -312,10 +312,10 @@ studio's pinned copy, never a registry lookup of the bare name.
312
312
  then check with two browsers and play the requested mechanic with network delay. Info is not a failure.
313
313
  Never remove rules, weaken checks, forge a probe or switch hosting to get past a message.
314
314
  Rules hashes and room saves are automatic; character saves are player-owned, untrusted input. Room state
315
- is replicated, not secret. Bigger rooms and private state are not delivered by milestone 1.
315
+ is replicated, not secret. Rooms may have hundreds of seats; choose players.max and spatial delivery explicitly and measure. Private state is not delivered.
316
316
  - **Existing games:** change rules truth in rules and presentation in view. Browser hosting is for offline,
317
- local development and private friends games. Untouched older createNetplay/createRoom games still work;
318
- when asked to rewrite one, read the game skill's REWRITE.md and preserve its art, view and feel. There is
317
+ local development and private friends games. Existing netplay games and port host helpers remain supported at their original 32-seat limit;
318
+ to rewrite one, read the game skill's REWRITE.md and preserve its art, view and feel. There is
319
319
  no automatic converter. NETPLAY.md section 29 is the rules contract; earlier sections describe older APIs.
320
320
  - Phones and computers: touch controls on phones only, keys on computers; keep the
321
321
  centre of the screen clear during play.
@@ -473,7 +473,7 @@ studio's pinned copy, never a registry lookup of the bare name.
473
473
  aimNoise, aggression, positioning }\`. The party sets it by voting on a card in the play page (the middle vote
474
474
  wins, capped by the server's ceiling). Make a game's bots honour it: \`net.skillOf(slot)\` in their step (the
475
475
  snippet is in \`NETPLAY.md\` section 17; \`BotBrain\` from the port kit reads it with a rebuild), and declare
476
- \`caps: ['skill', 'agents']\` in \`createNetplay\` (a game on \`createRoom\` has \`agents\` already). A build
476
+ \`caps: ['skill', 'agents']\` in \`createNetplay\` (rules rooms provide the agent runtime). A build
477
477
  from before 0.16.0 still plays on every server; the office says it predates servers until it is rebuilt.
478
478
  - **Agent passes:** \`npx --no-install homie-studio agents pass <id> --label Claude [--server <server>]\` gives an
479
479
  AI its way into a seat (shown once; \`agents revoke <pass>\` ends it). It sits with \`POST /<id>/api/agent\`
@@ -545,7 +545,7 @@ chat's card follows the work from then on) and, for a studio still being set up,
545
545
  - The site's workers.dev address names the Cloudflare account (often after its owner): \`deploy\` keeps it in
546
546
  \`.studio/local.json\`, which git ignores. Never copy it into a committed file. A custom domain goes in
547
547
  studio.json as \`cloudflare.domain\`.
548
- - A game's room size is its netplay manifest's \`maxPlayers\` (game.json \`netplay\`, or netplay.json), up to 32.
548
+ - A game's room size is game.json \`players.max\`, a positive integer including hundreds; measure the chosen size.
549
549
  - Nothing in this studio needs \`~/.homie\` or a Homie box.
550
550
  - This studio is this folder. A studio in a folder beside it (another folder with its own \`studio.json\`) is
551
551
  somebody else's work: never read it or copy from it unless the person asks.
@@ -783,7 +783,7 @@ import rules from './rules/index.mjs';
783
783
 
784
784
  hostRules(rules);
785
785
  useTools(async () => (await import('./tools/index.mjs')).default);
786
- export { default, Table, Lobby } from '@homie-rocks/studio/worker';
786
+ export { default, Table, Lobby, Gate, Concentrator } from '@homie-rocks/studio/worker';
787
787
  `,
788
788
  'site/src/rules/index.mjs': rulesIndex([]),
789
789
  'site/migrations/0001_studio.sql': MIGRATION,