@homie-rocks/studio 0.44.3 → 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.
@@ -1,3 +1,10 @@
1
+ > 0.45.0: this document describes the internal transport used by rules rooms.
2
+ > Games have one authoring path: rules plus view with `openRoom`. Browser/offline
3
+ > hosting uses that same contract. Existing netplay builds and port host helpers
4
+ > remain supported with their original 32-seat limit. Historical protocol revisions below explain wire fields, not an
5
+ > alternative way to author new games. `players.max` chooses the room size without
6
+ > a 32-seat ceiling for rules games. Revision 12 adds roster patches and ordered chained deltas.
7
+
1
8
  # Homie netplay contract, v1 (revision 11)
2
9
 
3
10
  **Authoring now:** New games use rules plus view and the server as host. Read the
@@ -55,11 +62,9 @@ revision-8 relay ignore every new field and frame):
55
62
  because the frame has no storage), `net.params` (the address's switches), `net.shell` (where the
56
63
  page's own controls sit over the game), `guardGestures()` for a touch game.
57
64
  - **Whose body** (section 25): `peer.occ` (which stay in a seat), `Roster.claim(…, occ)`,
58
- the `admit` callback, and createRoom's takeover and adopt callbacks for a room revived from its
59
- checkpoint and for a host that reconnects as host.
65
+ and restoring a room's holders from its checkpoint.
60
66
  - **Places** (section 26): a tie policy for results. **The relay's log** (section 27).
61
- - **Seat or solo** (section 28, added after 0.31.0 with no change to the wire): `createRoom({ fallback })`,
62
- `room.standing`, `net.full`, `net.line()`, a bound on `reconnecting` (`reconnectMaxMs`), and typed
67
+ - **Seat or solo** (section 28, added after 0.31.0 with no change to the wire): `net.full`, `net.line()`, a bound on `reconnecting` (`reconnectMaxMs`), and typed
63
68
  readers for `net.prefs` (section 24).
64
69
  - New optional fields `hello.ver`, `hello.feat`, `welcome.ver`, `welcome.stale`, `welcome.stall`,
65
70
  `peer.occ`, `peer.ver`, `peer.feat`, `snap.hb`; a relay → client `stale` frame; the final refusal
@@ -255,7 +260,7 @@ code.
255
260
  - **Room size.** `maxPlayers` comes from the game's netplay manifest: game.json's
256
261
  `netplay.maxPlayers`, or a `netplay.json` beside game.json or in the game's build
257
262
  (`maxPlayers` or `players.max`), else game.json's `players.max`, else 8. **A room holds
258
- at most 32** (`SEAT_MAX`). `homie-studio build` writes the number into the catalogue and
263
+ chosen by the studio** (`players.max`). `homie-studio build` writes the number into the catalogue and
259
264
  the site's Table takes it from there; the first visitor of an empty room may lower it
260
265
  (`hello.max`), never raise it.
261
266
  - **A dropped seat is held 60 s** for its token, which covers a reload or a network blip.
@@ -1099,12 +1104,8 @@ function stepBots(dt: number): void {
1099
1104
  | `aiName(label)`, `stripAi(name)`, `AI_MARK`, `SKILLS`, `skillPreset(n, kids?)` | Names and the dial. |
1100
1105
  | `Roster({ …, policy })` | Keeps `aiSeats + guides` slots marked `agent` (a person never takes one); `claim(seat, name, agent?)` (hands `host`: the slot stays a bot, `agent.seat` set; hands `self`: claimed like a person's); `release(seat)` gives an agent's slot back as a seat kept for AI; `bots: 'off'` adds no filler. |
1101
1106
 
1102
- `createRoom` (the port kit) does all of it: its roster keeps the AI seats, its join passes
1103
- `p.agent`, it declares `caps: ['agents']`, and `room.skillOf(body)` is the dial. `BotBrain`
1104
- takes `skill: () => room.skillOf(body)` and maps it: `reactionMs`, an aim error of
1105
- `aimNoise × 0.8` rad, and a commitment of `2500 × (1.3 − 0.6 × aggression)` ms; `port/skill.ts`
1106
- adds `jitter(s, maxPx)`, `engages(s, dt)` and `standoff(s, near, far)`. A ported game built on
1107
- `BotBrain` gets the dial with a rebuild.
1107
+ Rules rooms provide the agent roster and skill dial. Declare deterministic bot inputs
1108
+ in `think`; presentation and controls remain in the view.
1108
1109
 
1109
1110
  **Old games and old relays.**
1110
1111
  - A game built with a helper before revision 6 plays on every server: doors, the human seat
@@ -1572,7 +1573,7 @@ and its page, so the wire did not change for it.
1572
1573
  that was, null when it was not late); on the play page `window.__shell.arrival` has `mode`,
1573
1574
  `by`, `explicitMs` (the page's clock) and `lateMs`, and `window.__shell.ready` the message
1574
1575
  itself. The fix is one option: `createNetplay({ arrival: 'game' })`, or in a port
1575
- `createRoom({ netplay: { arrival: 'game' } })`.
1576
+ the rules view's transport options.
1576
1577
 
1577
1578
  ## 22. The link, said out loud (revision 9)
1578
1579
 
@@ -1608,7 +1609,7 @@ A page stands in one of six places with its room, and the helper says which:
1608
1609
  | `alone`, a room still on the old build (`room-stale`) | `alone` | Playing on your own · this room opens when its players have the new version |
1609
1610
  | `alone`, the room did not come back | `alone` | Playing on your own · the room dropped, still trying… |
1610
1611
  | `online`, every seat taken (`net.full`) | `full` | This room is full · watching until a seat is free |
1611
- | `online`, the game's own sentence (`net.line(text)`; `createRoom`'s `fallback` uses it) | `seat` | for example: Playing on your own until the next round |
1612
+ | `online`, the game's own sentence (`net.line(text)`) | `seat` | for example: Playing on your own until the next round |
1612
1613
  | `closed`: `room-full`, `too-many` | `closed` | This room is full. (with "· playing on your own" once it does) |
1613
1614
  | `closed`: `replaced`, `kicked`, `room-closed`, `version` | `closed` | This game is open in another tab. / This room is closed. / This game needs a reload to play online. |
1614
1615
  | `closed`: `stale`, or a newer build is live | `stale` | This game was updated. Tap to reload. |
@@ -1625,9 +1626,7 @@ A page stands in one of six places with its room, and the helper says which:
1625
1626
  `reconnectMaxMs`, in `alone` (before this bound a replica whose room never came back showed a
1626
1627
  frozen round and "Reconnecting…" for good). Going `alone` from a room is a `role` event (`role:
1627
1628
  'host'`, `why: 'reconnect-timeout'`), also for a page that was the room's host: its seat went
1628
- with the room. `createRoom` keeps the round such a host was running (its own body and score,
1629
- everybody else's body a bot's); a game on raw `createNetplay` starts a round of its own, as it
1630
- does for `relay-timeout`. `closed` is said in words and the play page offers the way out.
1629
+ with the room. Rules views start their offline rules runtime when going alone. `closed` is said in words and the play page offers the way out.
1631
1630
  - **`net.full`** is true when this browser asked to play and every seat is taken: it is in the
1632
1631
  room with no seat (`seat === null`, role `screen`) and the relay seats it when one frees up (a
1633
1632
  `role` event with `why: 'seated'`). Section 28 says what a game does meanwhile.
@@ -1875,114 +1874,11 @@ const off = guardGestures({ touch: 'canvas, [data-action]' }); // once, early;
1875
1874
  listeners and the stylesheet are tested, and emulated touch does not raise the callout, so try
1876
1875
  a real long press on a real iPhone and Android phone before calling it done.
1877
1876
 
1878
- ## 25. Whose body: seats that change hands (revision 9)
1879
-
1880
- A seat number is not a person. The relay reuses a number when its player has been gone a minute
1881
- (or sooner, in a full room), a host can be cut off while people come and go, and a round can
1882
- outlive everyone in it. Three cases went wrong in real games; all three now run the same code as
1883
- an ordinary join.
1884
-
1885
- **`peer.occ`: which stay in the seat.** The relay numbers every stay: a new number whenever a seat
1886
- is given to a new token, the same number across that browser's reloads and reconnects, kept
1887
- through a deploy. `Roster.claim(seat, name, agent, occ)` and `Roster.reconcile(peers)` (pass
1888
- `occ`) use it: the same stay keeps its body; a seat that changed hands gives the old body back to
1889
- a bot and admits the newcomer like any arrival. `roster.occupants()` belongs in the checkpoint
1890
- beside `roster.toJSON()`, and `Roster.from(slots, opts, occupants)` reads it back. With a relay
1891
- that says no `occ`, a seat is taken to be the same player's, as before, and the helper still knows
1892
- its own: `net.resumed` is true when its welcome gave back the seat its token named.
1893
-
1894
- **The takeover callbacks cover every way a body changes hands** (`createRoom`):
1895
-
1896
- | How | `onTakeover(body, info)` | `adopt(body)` |
1897
- |---|---|---|
1898
- | A person joins a running round | `{ why: 'join', own: false }` | on the joiner, when the host resets its body |
1899
- | A visitor revives a room from its checkpoint | `{ why: 'restore', own: true }` for its own body, and for anyone claimed with it | on the new host, with the body it took |
1900
- | A replica is promoted and someone arrived since the last checkpoint | `{ why: 'migrate' }` | on that player, by reset |
1901
- | A host reconnects as host and someone arrived while it was away | `{ why: 'join' }` (the missed `join` is replayed) | on that player, by reset |
1902
- | The same player comes back to the body they held | not called when the slot never left them; called with `back: true` when they left and returned (a reload) | as before |
1903
-
1904
- `info.back` is true when the player held that body before. The default (no `onTakeover`) is still
1905
- "score 0, where it stands".
1906
-
1907
- **The restored room.** Everyone leaves; the relay keeps the round for 60 s; a new visitor arrives
1908
- before that. It is welcomed as host with the checkpoint (`why: 'resumed'`), and the round goes on
1909
- with the clock where it was. Before revision 9 the newcomer inherited a departed player's body
1910
- without `onTakeover` (their score, with eleven seconds left) and without `adopt` (its local pose
1911
- stayed at a default, so it could not move until the next round). Now `createRoom` hands the
1912
- departed players' bodies back to bots, claims one for the newcomer through `admit`, calls
1913
- `onTakeover` and then `adopt`. A player who comes back to their own room (their token) keeps
1914
- their body and score, and is adopted onto it. A game on raw `createNetplay` does the same with
1915
- `roster.reconcile(peers)` in its `role` handler: treat every slot in `claimed` as a takeover.
1916
-
1917
- **The host that reconnects as host.** A host whose socket drops and comes back while nobody else
1918
- could host gets a welcome, not a `role` (its role did not change), so its `role` handler never
1919
- ran, and the relay never told it who came or went meanwhile. A player who arrived in that window
1920
- had no body for the rest of the visit, and one who left kept a frozen one. The helper now says
1921
- the difference as the events it would have been: `leave` (with `why: 'gone'`) and `join`, by seat,
1922
- with a seat whose `occ` changed as both. Nothing to write: the handlers a game already has do it.
1923
- The same change stopped `createRoom` calling `onTakeover` (and zeroing the score) for a player
1924
- whose body never left them.
1925
-
1926
- **`admit`: which body an arrival takes.** By default a newcomer takes the lowest bot's body. In an
1927
- elimination round that can be a dead one while living bots stand by.
1928
-
1929
- ```ts
1930
- createRoom({
1931
- admit: (candidates, who) => candidates.find((b) => b.alive) ?? null, // null: none will do
1932
- });
1933
- new Roster({ min, max, admit: (slots, who) => pickSlotId(slots) }); // raw: slots in, a slot id out
1934
- ```
1935
-
1936
- - Asked for every NEW arrival: a fresh join, a restored room's claims, a promoted host's
1937
- reconcile, a missed join replayed. Return the body (the slot id, on `Roster`), `undefined` for the
1938
- default, or `null` for "none of these": a new body is spawned while the room has space, and when
1939
- it has none the default applies (a seated person always gets a body).
1940
- - Never asked for a player returning to the body they held: an eliminated player who reloads gets
1941
- their own dead body back, not a way into the round. `occ` is how the roster knows.
1942
- - Never offered a seat kept for AI when a person arrives; an agent is offered the kept seats first
1943
- (the reservation of section 17 holds).
1944
- - Decide from the bodies' own state so the next host makes the same choice. The host still resets
1945
- the body (`net.reset`), announces the roster and checkpoints, as for any claim.
1946
-
1947
- **The late-join checklist.** A game is not done with joining until each of these is true in two
1948
- real browsers:
1949
-
1950
- 1. A joiner mid-round takes a bot's body, the right one (`admit`), is reset onto it and can move.
1951
- 2. The host leaves; the other browser continues the same round; a joiner after that still works.
1952
- 3. A joiner who reloads comes back to the same body (and the same score, if the game keeps it:
1953
- `info.back`).
1954
- 4. **Everyone leaves, and a new visitor enters before the room is forgotten (60 s).** The round is
1955
- the same one; the visitor has a body it did not inherit silently (`onTakeover` ran, `adopt` ran),
1956
- can move at once, and is ranked as a newcomer.
1957
- 5. The same, but the visitor is one of the players who left (same tab, reloaded): their own body
1958
- and score.
1959
- 6. The host's network drops for a second while a third browser joins: after it reconnects, three
1960
- people, three bodies, and nobody's score was reset.
1961
- 7. A deploy with tabs open: section 23.
1962
- 8. **A slow browser joins mid-round** (throttle one to "4x slowdown" and open the room's link
1963
- while a round runs, then again during the results). Within a few seconds it is in one of the
1964
- places section 28 names, and the screen says which: playing in the room on its own body,
1965
- playing on its own with bots, or watching with a line that says until when. Never a body-less
1966
- camera with nothing said, never "Reconnecting…" on a page that was never connected, and never
1967
- a line left up after the link came back. `room.standing` (or `net.link`, `net.full` and your
1968
- own roster on raw `createNetplay`) is what a test asserts.
1969
-
1970
- ## 26. Places and ties (revision 9)
1971
-
1972
- `createRoom` ranks a round's results by score. The order of the rows was always: higher score
1973
- first, then **people before bots**, then **the lower slot**. Two bodies on 61 points were shown
1974
- first and second with nothing saying why. The order is the tiebreaker and is unchanged; what
1975
- place a tie gets is now the game's to say:
1976
-
1977
- | `createRoom({ ties })` | 61, 61, 40, 12 | |
1978
- |---|---|---|
1979
- | `'order'` (default) | 1, 2, 3, 4 | What results always were: the order above is the place. Say so to players if you keep it ("ties go to the player, then the earlier seat"). |
1980
- | `'shared'` | 1, 1, 3, 4 | Standard competition places: equal scores share one and the next is skipped. |
1981
- | `'dense'` | 1, 1, 2, 3 | Equal scores share one and none is skipped. |
1877
+ ## 25–26. Player bodies and places
1982
1878
 
1983
- `placesOf(scores, ties)` is the same rule for a game that ranks its own rows (pass the scores in
1984
- the order you show them). A game with a real tiebreaker (time, kills, who got there first) sorts
1985
- by it before ranking and keeps `'order'`.
1879
+ Rules own player bodies, arrival, departure and round results. Use the rules
1880
+ contract and `openRoom` view. The port-owned `createRoom` host scaffold and its
1881
+ callbacks remain available for existing games in 0.45.0. New games use rules plus view.
1986
1882
 
1987
1883
  ## 27. The relay's log (revision 9)
1988
1884
 
@@ -2009,61 +1905,10 @@ and `failed`.
2009
1905
  host is closed, it comes from somewhere these boundaries do not cover; the `leave` and
2010
1906
  `socket-gone` lines beside it say what the room was doing at that second.
2011
1907
 
2012
- ## 28. Seat or solo: a page with no body (after 0.31.0)
1908
+ ## 28. A page waiting for its body
2013
1909
 
2014
- A page can be in its room and have nothing to play: `net.link === 'online'`, and no body. It
2015
- happened three ways, and none of them said anything:
2016
-
2017
- | How | What the page had | What happens now |
2018
- |---|---|---|
2019
- | The relay seated the player and the host had no body free (every body was somebody's: a server stopped keeping seats for AI mid-round, or the game has fewer bodies than the room has seats). The claim was tried once, at the join. | a seat, role `replica`, no body, for the rest of the visit | The host seats everybody who is waiting **whenever a body frees up and at every round start** (`createRoom`; the starters on raw `createNetplay` do the same in `seatWaiting()`). |
2020
- | Every seat is taken. The relay lets the page in with no seat. | `seat === null`, role `screen`, nothing said | `net.full` is true and the line says so; the relay seats it when a seat frees up, as before. |
2021
- | The page was reset by its host before a snapshot showed its body. | a body it never adopted (it could not move until the next round) | `createRoom` owes the adoption until a snapshot shows the body. |
2022
-
2023
- **The recipe is an option, not prose.** `createRoom` decides what a body-less page does, after
2024
- `seatWaitMs` (default 4000, 1000 to 30000) of time it could actually listen:
2025
-
2026
- ```ts
2027
- const room = createRoom({
2028
- // …
2029
- fallback: 'solo', // 'wait' (default) | 'solo' | 'spectate'
2030
- seatWaitMs: 4000,
2031
- onStanding: (s) => hud.say(s.line), // optional: the line over the game says it already
2032
- });
2033
- room.standing; // { state: 'joining' | 'playing' | 'solo' | 'watching' | 'waiting' | 'closed', why, line }
2034
- ```
2035
-
2036
- | `fallback` | What the page does while the room has no body for it | The line |
2037
- |---|---|---|
2038
- | `'wait'` (default) | What a game did before: it waits. Now it says so, and the host seats it as above. | Waiting for a place in this round… |
2039
- | `'solo'` | A private round with bots, here: `room.hosting` is true, `room.mine()` is its body, `room.solo` is true. Nothing of it is sent (no round, roster, snapshot or `room.send`). The moment the room has a body for it, the private round ends and `adopt` is called with the room's body. | Playing on your own until the next round (in a full room: This room is full · playing on your own until a seat is free) |
2040
- | `'spectate'` | No body; `room.viewBody()` follows a person in the room until it has one. | Watching until the next round |
2041
-
2042
- `room.standing` ties it to the link states of section 22, so a game (and a test) asks one thing:
2043
-
2044
- | `standing.state` | `why` | `net.link` | Can the player play? |
2045
- |---|---|---|---|
2046
- | `joining` | `connecting`, `no-body`, `full`, `reconnecting` | `connecting`, or `online` inside `seatWaitMs` | not yet; a line after 0.7 s ("Joining the round…") |
2047
- | `playing` | `seated`, `reconnecting` | `online` (or `reconnecting`, bounded) | yes, in the room |
2048
- | `solo` | `alone`, `offline` (the link); `no-body`, `full` (the fallback) | `alone`, `offline`, or `online` | yes, on their own, with bots |
2049
- | `watching` | `screen` (a watcher or a big screen, by choice); `no-body`, `full` (`spectate`) | `online` | no; the line says until when |
2050
- | `waiting` | `no-body`, `full` (`wait`) | `online` | no; the line says so |
2051
- | `closed` | `net.closedWhy` | `closed` | no; the line says why and the play page offers another room |
2052
-
2053
- - **Solo is the game's own host code, run privately.** `onRoundStart` runs for the private round
2054
- (with the room's round number, a clock of its own) and the game's rules move its bots, exactly
2055
- as offline. A score made there stays there: the room's body starts as the host made it. Events
2056
- from the real room still arrive on `room.on('event')` while solo; a game that plays sounds for
2057
- them checks `room.solo`.
2058
- - **The wait counts only time the page could listen**, like the wait for the welcome: a page
2059
- blocked for six seconds by its own boot runs its timers before the snapshots queued behind
2060
- them, and a body that is already on its way is not "missing".
2061
- - **The default changes nothing a working game did.** A page that gets its body with its first
2062
- snapshots never sees any of this; `'wait'` only adds the line and the host's second try.
2063
- - **On raw `createNetplay`** there is no option: do what the starters do. Host: try the claim
2064
- again on `leave` and at a round's rollover (`seatWaiting()`). Everyone: test
2065
- `net.link === 'reconnecting'` for your own "Reconnecting…", read `net.full`, and say where the
2066
- player stands with `net.line('…')`.
1910
+ The rules view owns connecting, playing, offline and closed states. The same
1911
+ rules run for offline/browser play; a separate port host is not supported.
2067
1912
 
2068
1913
  ## 29. The server as host (revision 10)
2069
1914
 
@@ -88,9 +88,9 @@
88
88
 
89
89
  export const NETPLAY_VERSION = 1;
90
90
  /** The contract revision this helper speaks (NETPLAY.md): its hello says so (`rev`), and so does every build of it. */
91
- export const NETPLAY_REVISION = 11;
91
+ export const NETPLAY_REVISION = 12;
92
92
  /** In every bundle that includes the helper: `homie-studio build` reads it to tell the office which revision a build speaks. */
93
- export const NETPLAY_MARK = 'homie-netplay-rev:11';
93
+ export const NETPLAY_MARK = 'homie-netplay-rev:12';
94
94
 
95
95
  export type Role = 'host' | 'replica' | 'screen';
96
96
  export type Device = 'phone' | 'desk' | 'tv';
@@ -802,13 +802,13 @@ export interface Netplay<S = unknown, A = unknown, C = unknown> {
802
802
  /**
803
803
  * This browser asked to play and every seat is taken: it is in the room with no seat (`seat === null`, role
804
804
  * `screen`) and the relay seats it when one frees up (a `role` event with `why: 'seated'`). The line over the game
805
- * says so; `createRoom({ fallback })` decides what the page does meanwhile.
805
+ * says so; the rules view decides what the page shows meanwhile.
806
806
  */
807
807
  readonly full: boolean;
808
808
  /**
809
809
  * The line over the game is the game's to use too: one short sentence about where this player stands ("Playing
810
810
  * on your own until the next round"), or null to take it away. Shown while the link is `online`; a link that is
811
- * down says its own line first. `createRoom` uses it for its `fallback`.
811
+ * down says its own line first.
812
812
  */
813
813
  line(text: string | null): void;
814
814
  /** `connectClock: 'game'`: the game has finished booting; the wait for the room's welcome starts now. Once. */
@@ -1046,7 +1046,7 @@ export interface RosterOptions {
1046
1046
  max: number;
1047
1047
  botName?: (slot: number) => string;
1048
1048
  /**
1049
- * Revision 6: the room's policy (createRoom passes `() => net.policy`). A hybrid or beginner server keeps
1049
+ * Revision 6: the room's policy (read from `net.policy`). A hybrid or beginner server keeps
1050
1050
  * `aiSeats + guides` slots for AI (marked `agent`, never given to a person); `bots: 'off'` adds no other filler.
1051
1051
  */
1052
1052
  policy?: () => Policy | null;
@@ -1371,7 +1371,7 @@ export function cleanFeatures(list: unknown): string[] {
1371
1371
 
1372
1372
  /**
1373
1373
  * PLACES (NETPLAY.md section 26): rows ranked by score, with a tie policy said out loud. `rows` must already be in the
1374
- * order to show them (the tiebreak is the caller's: createRoom puts people before bots, then the lower slot).
1374
+ * order to show them (the tiebreak is the caller's).
1375
1375
  * `'order'` (the default, what results always were): places 1, 2, 3… in that order, so two equal scores get different
1376
1376
  * places. `'shared'`: standard competition places, equal scores share one and the next is skipped (1, 1, 3).
1377
1377
  * `'dense'`: equal scores share one and none is skipped (1, 1, 2).
@@ -1799,7 +1799,7 @@ export function createNetplay<S = unknown, A = unknown, C = unknown>(opts: Netpl
1799
1799
  const late = playableSent && arrived.by === 'auto' && arrived.playableMs !== null ? arrived.explicitMs - arrived.playableMs : null;
1800
1800
  if (late !== null && late >= 250) {
1801
1801
  arrived.lateMs = late;
1802
- console.warn(`[netplay] net.playable() came ${late} ms after the arrival card had already lifted: this game uses the automatic arrival (seated, with the room's state), so its own word changed nothing and the card left before the game was ready. Pass createNetplay({ arrival: 'game' }) (createRoom: netplay: { arrival: 'game' }) so the card waits for net.playable().`);
1802
+ console.warn(`[netplay] net.playable() came ${late} ms after the arrival card had already lifted: this game uses the automatic arrival (seated, with the room's state), so its own word changed nothing and the card left before the game was ready. Pass createNetplay({ arrival: 'game' }) so the card waits for net.playable().`);
1803
1803
  }
1804
1804
  post?.({ what: 'ready', by: 'game', mode: arrival, ms: arrived.explicitMs, ...(arrived.lateMs !== null ? { lateMs: arrived.lateMs } : {}) });
1805
1805
  sayPlayable('game');
@@ -2437,7 +2437,12 @@ export function createNetplay<S = unknown, A = unknown, C = unknown>(opts: Netpl
2437
2437
  return;
2438
2438
  }
2439
2439
  case 'roster': {
2440
- slots = (m['slots'] as Slot[]) ?? null;
2440
+ if (m['patch'] === true) {
2441
+ const next = new Map((slots ?? []).map(row => [row.slot, row]));
2442
+ for (const slot of (m['removed'] as number[] ?? [])) next.delete(slot);
2443
+ for (const row of (m['slots'] as Slot[] ?? [])) next.set(row.slot, row);
2444
+ slots = [...next.values()].sort((a, b) => a.slot - b.slot);
2445
+ } else slots = (m['slots'] as Slot[]) ?? null;
2441
2446
  post?.({ what: 'roster', slots });
2442
2447
  if (slots) emit('roster', slots);
2443
2448
  return;
@@ -2965,7 +2970,7 @@ export function createNetplay<S = unknown, A = unknown, C = unknown>(opts: Netpl
2965
2970
  if (full) { full = false; post?.({ what: 'full', full: false }); }
2966
2971
  aloneWhy = why;
2967
2972
  // A page that gives up on a room it WAS in says so as a role event even when it was that room's host: its seat is
2968
- // gone with the room, and the game seats itself again for a round of its own (createRoom keeps the round it had).
2973
+ // gone with the room, and the game seats itself again for a round of its own.
2969
2974
  if (!roleKnown || role !== 'host' || (rulesGame && !rulesHost) || why === 'reconnect-timeout') setRole(canOffline ? 'host' : 'replica', why, rulesGame && kept ? { ckpt: { k: tick, st: now(), d: kept as C } } : {});
2970
2975
  // No shell at all is `offline` from the start; a room that never answered is `alone` (and still knocked at). A
2971
2976
  // page the relay refused for good stays `closed` (nothing is knocking): it plays by itself and its line says why.
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@homie-rocks/studio",
3
- "version": "0.44.3",
3
+ "version": "0.45.0",
4
4
  "description": "A studio for games, music and video on its own Cloudflare: scaffold, multiplayer starters on the netplay contract, a port toolkit that makes existing single-player web games multiplayer, the site Worker with public rooms (Table/Lobby Durable Objects and D1, all on the free Workers plan, no payment method), a site with the hub's sections (Home, Games, Music, Videos, Rooms, Posts with feeds) in the studio's own look, an epic landing page for every game, big songs and videos in the studio's own R2 once it has storage (uploaded, checked by SHA-256, served at the same addresses), deploy, and the homie.rocks directory.",
5
5
  "type": "module",
6
6
  "license": "Apache-2.0",
package/rules/host.ts CHANGED
@@ -100,6 +100,7 @@ export interface Host {
100
100
  readonly paused: boolean;
101
101
  readonly people: number;
102
102
  readonly viewRadiusM: number | null;
103
+ readonly viewSettings: Compiled['settings']['view'];
103
104
  frame(m: Record<string, unknown>): void;
104
105
  /** Start the tick loop (the first person has joined). */
105
106
  start(): void;
@@ -768,6 +769,7 @@ export function createHost(o: HostOptions): Host {
768
769
  }
769
770
 
770
771
  return {
772
+ get viewSettings() { return c.settings.view; },
771
773
  get viewRadiusM() { return c.settings.view?.radiusM ?? null; },
772
774
  get tick() { return core.tick; },
773
775
  get epoch() { return epoch; },
@@ -1,41 +1,77 @@
1
1
  /** Per-connection state delivery. Pure data, shared by the relay and the view.
2
- * Deltas name a keyframe, never an unacknowledged previous delta. Losing a delta
3
- * therefore loses no baseline. A new keyframe bounds recovery to one second.
2
+ * Deltas may name a periodic keyframe or the previous ordered frame. A missing
3
+ * chained predecessor is ignored until the next keyframe (at most one second).
4
4
  * No history grows with time: one keyframe per connection, no per-frame queue.
5
5
  */
6
+ const spatial = new WeakMap(), controls = new WeakMap();
7
+ function indexFor(snap, radius) {
8
+ let byRadius = spatial.get(snap.d[1]);
9
+ if (!byRadius) { byRadius = new Map(); spatial.set(snap.d[1], byRadius); }
10
+ if (byRadius.has(radius)) return byRadius.get(radius);
11
+ const own = new Map(), cells = new Map(), order = new Map();
12
+ const size = radius > 0 ? radius : 1;
13
+ for (const [i, e] of snap.d[1].entries()) {
14
+ order.set(e, i);
15
+ if (e[9] !== undefined && e[10] !== 1) own.set(e[9], e);
16
+ const p = e[3], key = `${Math.floor(p[0]/size)},${Math.floor(p[1]/size)},${Math.floor((p[2]??0)/size)}`;
17
+ const list = cells.get(key) ?? []; list.push(e); cells.set(key, list);
18
+ }
19
+ const index = { own, cells, size, order }; byRadius.set(radius,index); return index;
20
+ }
6
21
  export function interestSnapshot(snap, seat, radiusM) {
7
22
  const [round, entities] = snap.d;
8
- const own = seat === null ? null : entities.find(e => e[9] === seat && e[10] === 0);
9
- const visible = own && radiusM !== null ? entities.filter(e => {
10
- const p = e[3], q = own[3];
11
- return e === own || (p[0] - q[0]) ** 2 + (p[1] - q[1]) ** 2 + ((p[2] ?? 0) - (q[2] ?? 0)) ** 2 <= radiusM ** 2;
12
- }) : seat === null ? entities : own ? entities : [];
13
- // Collision revisions are prediction inputs, independent of visual interest.
14
- // Deliver all geometry: authored sweeps/dashes need not fit inside the view radius.
15
- return { ...snap, d: [round, visible, ...snap.d.slice(2)], c: (snap.c ?? []).filter(row => row[0] === seat) };
23
+ let visible = entities;
24
+ if (seat !== null) {
25
+ const index = indexFor(snap, radiusM), own = index.own.get(seat);
26
+ visible = [];
27
+ if (own && radiusM === null) visible = entities;
28
+ else if (own) {
29
+ const q=own[3], x=Math.floor(q[0]/index.size), y=Math.floor(q[1]/index.size), z=Math.floor((q[2]??0)/index.size);
30
+ for(let dx=-1;dx<=1;dx++)for(let dy=-1;dy<=1;dy++)for(let dz=-1;dz<=1;dz++)
31
+ for(const e of index.cells.get(`${x+dx},${y+dy},${z+dz}`)??[]) {
32
+ const p=e[3];
33
+ if(e===own||(p[0]-q[0])**2+(p[1]-q[1])**2+((p[2]??0)-(q[2]??0))**2<=radiusM**2)visible.push(e);
34
+ }
35
+ // Snapshot order is part of the view contract, even though cells are not.
36
+ visible.sort((a,b)=>index.order.get(a)-index.order.get(b));
37
+ }
38
+ }
39
+ let control = snap.c && controls.get(snap.c);
40
+ if (!control) { control = new Map((snap.c ?? []).map(row => [row[0], row])); if (snap.c) controls.set(snap.c, control); }
41
+ return { ...snap, d: [round, visible, ...snap.d.slice(2)], c: control.has(seat) ? [control.get(seat)] : [] };
16
42
  }
43
+ const encodedRows = new WeakMap();
44
+ const rowText = row => {
45
+ let text=encodedRows.get(row);
46
+ if(!text){text=row.map(v=>JSON.stringify(v));encodedRows.set(row,text);}
47
+ return text;
48
+ };
17
49
 
18
- export function snapshotEncoder(keyframeTicks) {
50
+ export function snapshotEncoder(keyframeTicks, chained = false) {
19
51
  let base = null;
20
- let rows = new Map();
52
+ let rows = new Map(), keyTick = -Infinity;
21
53
  return {
22
- reset() { base = null; rows.clear(); },
23
- encode(snap) {
24
- if (!base || snap.e !== base.e || snap.k <= base.k || snap.k - base.k >= keyframeTicks) {
25
- base = snap;
26
- rows = new Map(snap.d[1].map(e => [e[0], e.map(v => JSON.stringify(v))]));
54
+ reset() { base = null; rows.clear(); keyTick = -Infinity; },
55
+ encode(snap, seat = null) {
56
+ if (!base || snap.e !== base.e || snap.k <= base.k || snap.k - keyTick >= keyframeTicks) {
57
+ base = snap; keyTick = snap.k;
58
+ rows = new Map(snap.d[1].map(e => [e[0], rowText(e)]));
27
59
  return snap;
28
60
  }
29
61
  const present = new Set(), changed = [];
62
+ const own = chained && seat !== null ? snap.d[1].filter(e => e[9] === seat && e[10] !== 1) : null;
30
63
  for (const entity of snap.d[1]) {
31
64
  const id = entity[0]; present.add(id);
65
+ if (own?.includes(entity)) continue;
32
66
  const before = rows.get(id);
33
67
  if (!before) { changed.push(entity); continue; }
34
68
  let mask = 0; const values = [];
35
- for (let i = 1; i < entity.length; i++) if (JSON.stringify(entity[i]) !== before[i]) { mask |= 1 << i; values.push(entity[i]); }
69
+ for (let i = 1; i < entity.length; i++) if (rowText(entity)[i] !== before[i]) { mask |= 1 << i; values.push(entity[i]); }
36
70
  if (mask) changed.push([id, mask, values]);
37
71
  }
38
- return { ...snap, d: { base: base.k, round: snap.d[0], ...(snap.d.length > 2 ? { collision: snap.d[2] } : {}), changed, removed: [...rows.keys()].filter(id => !present.has(id)) } };
72
+ const out = { ...snap, d: { base: base.k, round: snap.d[0], ...(snap.d.length > 2 ? { collision: snap.d[2] } : {}), changed, removed: [...rows.keys()].filter(id => !present.has(id)), ...(chained ? { chain: true } : {}), ...(own ? { own } : {}) } };
73
+ if (chained) { base = snap; rows = new Map(snap.d[1].map(e => [e[0], rowText(e)])); }
74
+ return out;
39
75
  },
40
76
  };
41
77
  }
@@ -57,8 +93,18 @@ export function snapshotDecoder() {
57
93
  return snap;
58
94
  }
59
95
  const d = snap.d;
60
- if (!base || !d || d.base !== base.k || snap.e !== base.e || snap.k <= base.k) return null;
96
+ if (!base || !d || snap.e !== base.e || snap.k <= base.k) return null;
97
+ if (d.own !== undefined && (!Array.isArray(d.own) || !d.own.every(e => Array.isArray(e) && e.length >= 9 && typeof e[0] === 'string'))) return null;
61
98
  if (!Array.isArray(d.round) || !Array.isArray(d.removed) || !Array.isArray(d.changed)) return null;
99
+ if (d.base !== base.k) {
100
+ // A skipped remote delta must not stall local prediction or collider edits.
101
+ // Keep the remote baseline unchanged until a keyframe repairs the chain.
102
+ if (!d.chain || !d.own?.length || !Array.isArray(d.round)) return null;
103
+ const visible = new Map(rows);
104
+ for (const id of d.removed) visible.delete(id);
105
+ for (const e of d.own) visible.set(e[0], e);
106
+ return { ...snap, d: [d.round, [...visible.values()], ...(d.collision === undefined ? [] : [d.collision])] };
107
+ }
62
108
  const next = new Map(rows);
63
109
  for (const id of d.removed) next.delete(id);
64
110
  for (const change of d.changed) {
@@ -75,7 +121,46 @@ export function snapshotDecoder() {
75
121
  if (j !== change[2].length) return null;
76
122
  next.set(entity[0], entity);
77
123
  }
78
- return { ...snap, d: [d.round, [...next.values()], ...(d.collision === undefined ? [] : [d.collision])] };
124
+ for (const e of d.own ?? []) next.set(e[0], e);
125
+ const decoded = { ...snap, d: [d.round, [...next.values()], ...(d.collision === undefined ? [] : [d.collision])] };
126
+ if (d.chain) { base = decoded; rows = next; }
127
+ return decoded;
79
128
  },
80
129
  };
81
130
  }
131
+
132
+ /** Studio-chosen visual precision and rates. The controlled body stays exact.
133
+ * Exits are immediate even between far updates; new arrivals never wait.
134
+ */
135
+ const quantizedRows = new WeakMap();
136
+ export function scheduledView({ radiusM = null, precisionM = 0, nearM = null, farHz = null } = {}, tickHz = 20) {
137
+ let previous = new Map(), epoch = null;
138
+ return (snap, seat) => {
139
+ if (snap.e !== epoch) { previous.clear(); epoch = snap.e; }
140
+ const selected = interestSnapshot(snap, seat, radiusM);
141
+ const own = selected.d[1].find(e => e[9] === seat && e[10] !== 1);
142
+ const next = new Map();
143
+ const entities = selected.d[1].map(entity => {
144
+ let row = entity;
145
+ if (entity !== own) {
146
+ const distance = own ? Math.hypot(...entity[3].map((n, i) => n - (own[3][i] ?? 0))) : 0;
147
+ const old = previous.get(entity[0]);
148
+ const interval = farHz && nearM !== null && distance > nearM ? Math.max(1, Math.round(tickHz / farHz)) : 1;
149
+ if (old && snap.k % interval !== 0) row = old;
150
+ else if (precisionM > 0) {
151
+ let versions = quantizedRows.get(entity);
152
+ if (!versions) { versions = new Map(); quantizedRows.set(entity, versions); }
153
+ row = versions.get(precisionM);
154
+ if (!row) {
155
+ row = entity.slice();
156
+ for (const i of [3, 4]) row[i] = entity[i].map(n => Number((Math.round(n / precisionM) * precisionM).toPrecision(12)));
157
+ versions.set(precisionM, row);
158
+ }
159
+ }
160
+ }
161
+ next.set(entity[0], row); return row;
162
+ });
163
+ previous = next;
164
+ return { ...selected, d: [selected.d[0], entities, ...selected.d.slice(2)] };
165
+ };
166
+ }
package/rules/rules.ts CHANGED
@@ -181,7 +181,7 @@ export interface RoomSettings {
181
181
  tickHz: number;
182
182
  inputHz: number;
183
183
  /** Omit the radius to send the whole room. No implicit visibility or bandwidth cap. */
184
- view: { radiusM: number | null };
184
+ view: { radiusM: number | null; precisionM?: number; nearM?: number | null; farHz?: number | null };
185
185
  durability: { movementSeconds: number };
186
186
  budget: { tick: number };
187
187
  predict: { catchM: number | null; catchUp: number; snapM: number | null; blendMs: number; interpMs: number | null };
@@ -229,13 +229,13 @@ export function roomSettings(raw: unknown): { settings: RoomSettings; problems:
229
229
  if (host === 'browser' && r.view?.radiusM != null) problems.push('"room.view.radiusM" needs room.host "server"; browser hosting sends the whole room');
230
230
  if (r.view !== undefined) {
231
231
  if (!r.view || typeof r.view !== 'object' || Array.isArray(r.view)) problems.push('"room.view" is an object with an optional radiusM');
232
- else for (const key of Object.keys(r.view)) if (key !== 'radiusM') problems.push(`"room.view.${key}" is not a setting; use radiusM`);
232
+ else for (const key of Object.keys(r.view)) if (!['radiusM', 'precisionM', 'nearM', 'farHz'].includes(key)) problems.push(`"room.view.${key}" is not a setting; use radiusM, precisionM, nearM or farHz`);
233
233
  }
234
234
  const p = r.predict && typeof r.predict === 'object' ? r.predict : {};
235
235
  return {
236
236
  settings: {
237
237
  host, offline, tickHz, inputHz,
238
- view: { radiusM: r.view?.radiusM === null ? null : num('view.radiusM', r.view?.radiusM, null) },
238
+ view: { radiusM: r.view?.radiusM === null ? null : num('view.radiusM', r.view?.radiusM, null), ...(r.view?.precisionM !== undefined ? { precisionM: num('view.precisionM', r.view.precisionM, 0) as number } : {}), ...(r.view?.nearM !== undefined ? { nearM: num('view.nearM', r.view.nearM, null) } : {}), ...(r.view?.farHz !== undefined ? { farHz: whole('view.farHz', r.view.farHz, 1, tickHz, tickHz) } : {}) },
239
239
  durability: { movementSeconds: whole('durability.movementSeconds', r.durability?.movementSeconds, 1, 60, 1) },
240
240
  budget: { tick: Math.max(1, Math.floor(num('budget.tick', r.budget?.tick, budgetFor(tickHz)) as number)) },
241
241
  predict: { catchM: num('predict.catchM', p.catchM, null), catchUp: num('predict.catchUp', p.catchUp, 1.25) as number, snapM: num('predict.snapM', p.snapM, null), blendMs: num('predict.blendMs', p.blendMs, 100) as number, interpMs: num('predict.interpMs', p.interpMs, null) },
@@ -2,10 +2,20 @@
2
2
  "id": "coin-dash",
3
3
  "name": "Coin Dash",
4
4
  "blurb": "Run for the coins before the others do. Sixty-second rounds, and the score is kept on the server, where nobody can change it.",
5
- "players": { "min": 1, "max": 8 },
5
+ "players": {
6
+ "min": 1,
7
+ "max": 8
8
+ },
6
9
  "entry": "src/view.ts",
7
10
  "example": true,
8
- "netplay": { "v": 1, "public": true },
9
- "room": { "host": "server" },
10
- "landing": { "pitch": "Run for the coins. First one there takes it.", "controls": { "phone": "Drag to run.", "computer": "WASD or arrows to run." } }
11
+ "room": {
12
+ "host": "server"
13
+ },
14
+ "landing": {
15
+ "pitch": "Run for the coins. First one there takes it.",
16
+ "controls": {
17
+ "phone": "Drag to run.",
18
+ "computer": "WASD or arrows to run."
19
+ }
20
+ }
11
21
  }
@@ -8,10 +8,6 @@
8
8
  },
9
9
  "roundSeconds": 90,
10
10
  "entry": "src/view.ts",
11
- "netplay": {
12
- "v": 1,
13
- "public": true
14
- },
15
11
  "saves": true,
16
12
  "decide": false,
17
13
  "landing": {
@@ -1,7 +1,7 @@
1
1
  // Presentation only: openRoom gives predicted own movement and interpolated other bodies.
2
2
  // Send intent; rules decide outcomes. Keep rendered obstacles aligned with room.map.
3
3
  /** Ember Vale: server-run hunts, with the hero save and memorials owned by its player. */
4
- import { AI_MARK, BUBBLE_FONT, createBubbles, createControls, createLabels, paintBubbles, type BubbleIn, type BubbleOut, createSaves, easeView, exposePort, fitView, stripAi, type BodyBase, type Fit, type LabelIn, type LabelOut, type NetEvent } from '@homie-rocks/studio/port';
4
+ import { AI_MARK, BUBBLE_FONT, createBubbles, createControls, createLabels, paintBubbles, type BubbleIn, type BubbleOut, createSaves, easeView, exposePort, fitView, stripAi, type Slot, type Fit, type LabelIn, type LabelOut, type NetEvent } from '@homie-rocks/studio/port';
5
5
  // The Game Lab: tunables, phases, tracks and overlays (no-ops outside the lab).
6
6
  import { lab } from '@homie-rocks/studio/lab';
7
7
  import { openRoom, type Entity } from '@homie-rocks/studio/rules/view';
@@ -28,7 +28,7 @@ const clamp = (v: number, a: number, b: number): number => Math.max(a, Math.min(
28
28
 
29
29
  /** The hero: the one thing that lasts. Kept in the save key 'hero'. */
30
30
  interface Hero { v: 1; name: string; level: number; xp: number; gold: number; hardcore: boolean; kills: number; deaths: number; born: number }
31
- interface Body extends BodyBase { x: number; y: number; hp: number; maxHp: number; level: number; face: number; flags: number; downUntil: number; atkAt: number; strikeAt: number; tx: number; ty: number; seenAt?: number; ax?: number; ay?: number }
31
+ interface Body { slot: number; seat: number | null; name: string; bot: boolean; score: number; agent?: Slot['agent']; x: number; y: number; hp: number; maxHp: number; level: number; face: number; flags: number; downUntil: number; atkAt: number; strikeAt: number; tx: number; ty: number; seenAt?: number; ax?: number; ay?: number }
32
32
  /** A slime; while it is struck: where its push starts (kx, ky), along (kdx, kdy), from when (kat, after the hit-stop). */
33
33
  interface Slime { id: number; x: number; y: number; hp: number; maxHp: number; size: number; hitAt: number; lastHit: number; kx?: number; ky?: number; kdx?: number; kdy?: number; kat?: number }
34
34
  type SlimeRow = [id: number, x: number, y: number, hp: number, size: number];
@@ -8,10 +8,6 @@
8
8
  },
9
9
  "roundSeconds": 60,
10
10
  "entry": "src/view.ts",
11
- "netplay": {
12
- "v": 1,
13
- "public": true
14
- },
15
11
  "landing": {
16
12
  "pitch": "Grab the gems. Bump your rivals away.",
17
13
  "controls": {