Skip to content

feat(game)!: zones as celld objects, Mob Pit room grid - #287

Draft
patrickleet wants to merge 16 commits into
feat/v5-game-servicesfrom
feat/v5-celld-zones
Draft

patrickleet wants to merge 16 commits into
feat/v5-game-servicesfrom
feat/v5-celld-zones

Conversation

@patrickleet

Copy link
Copy Markdown
Collaborator

Stacked on #285. Runs live game zones as celld Durable Objects and turns Mob Pit into a grid of rooms joined by doorways (GitKB tasks gr-p43, gr-p41; spike gr-p42).

Framework: celld-zone feature in distributed_game

  • Any Zone runs as one celld object per zone id: implement CelldZone, declare the class with celld_zone_object!, route with forward_to_zone. celld decides which node runs it.
  • Join with a ticket, a single-use doorway transfer ticket, or a reconnect token. Fixed-rate tick only while players are present.
  • Empty room: snapshot, then dormant; after ZONE_HIBERNATE_AFTER_MS it hibernates (dropped from memory, restored on next join). Driven by an object alarm, so it survives eviction.
  • Sockets from an earlier activation close with 1012; the JS client rejoins with a fresh ticket.
  • Facts reach cells object-to-object exactly once: per-room storage outbox, resent on next activation or alarm, deduplicated by the cell command ledger.
  • world::zone_activity cell + zone_status gain a Dormant status.
  • GET /rooms/{room}/info debug JSON; node name is debug-only (CELLD_ZONE_NODES / CELLD_ZONE_NODE), nothing routes by it.

Mob Pit rooms

  • Rooms pit.<x>.<y>; seeded biome, danger and 6–12 mobs from game_sim::mobpit::pit_room (same Rust in the browser as WASM); a doorway in the middle of each wall.
  • New crate mobpit_rooms (wasm32, in the wasm32 check). Snapshot keeps mobs and the room kill count.
  • SvelteKit page: live minimap over GraphQL @live, sleep/restore toasts, debug overlay (O) showing node/isolate/activation. Vite page polls /dev/mobpit/rooms.
  • run.sh mobpit [--fleet [N]] starts single-node celld dev or a local multi-node fleet with RustFS.
  • game-demos mobpit is services only; the native Mob Pit zone, its fact sink and the spike crate are removed.

Breaking (v5 unreleased): CommandFactSink is over a new FactHost trait (replaces FactRouting); BotConfig gains path.

Verified

  • Before rebase: celld zone integration test on celld dev and a 3-node fleet, including SIGKILL of the room's node with 200 facts in flight → recovered on another node, exactly 200 hits. Playwright two-player SvelteKit test across nodes passed.
  • After rebase onto feat(game): game-services framework gaps and modules #285: run.sh smoke OK (single node); cargo test --workspace --all-features --all-targets 130 suites ok, one known flaky upstream SQLite bus test (passed 3/3 on rerun); JS 491 pass; wasm32 check ok.

Open

  • run.sh mobpit --fleet not yet run end to end via the bash path (local Docker stopped publishing ports); CI sets MOBPIT_FLEET_REQUIRED=1.
  • Not yet in the celld zone host: returning a failed handoff to its source room, per-socket input rate limits, NetSim.
  • Native multi-host machinery stays until Endless Grid, Boss Ripple and The Freeze move to celld zones.

One Mob Pit room runs as a celld Durable Object (wasm32 Rust): the
existing ZoneHost, wire protocol and HS256 tickets, a setTimeout tick
loop while occupied, a hibernation snapshot in object storage, and the
celld node / isolate / activation replicated as a global `host` entity.
run.sh starts a 3-node production-mode fleet on one RustFS bucket;
tools/ hold the headless test and the measurement scripts.

Framework changes the spike needed: a `ticket` feature (admission
tickets without the axum server) and wasm32-safe tick timing in
ZoneHost (no `Instant` on wasm32-unknown-unknown).

Spike only: not wired into the demos.

[[tasks/gr-p42-zones-as-celld-objects-spike]]
[[tasks/gr-p42-zones-as-celld-objects-spike]]
distributed_game's `celld-zone` feature hosts any Zone as a celld
Durable Object, one object per zone id: celld places, forwards, evicts,
rebalances idle objects and recovers crashed ones; game code never names
a server. The spike's PitRoom glue becomes framework code:

- ZoneObject<Z: CelldZone> + celld_zone_object!: hibernatable WebSocket
  joins (ticket, single-use transfer ticket, reconnect token), a
  self-correcting deadline tick loop while occupied, snapshots in object
  storage, dormant -> hibernated by object alarm, restore on wake, 1012
  for sockets of an earlier activation, GET .../info debug JSON.
- Facts object to object: CelldObjectHost under CommandFactSink, with a
  durable outbox in object storage (FactQueue journal) so facts reach
  cells exactly once across crashes.
- world::zone_activity: the zone reports active / dormant / hibernated
  as facts into its ZoneActivity cell; zone_status projects them into
  zone_activities (ZoneStatus::Dormant, of_activity).
- Debug-only node lookup via operator listeners (CELLD_ZONE_NODES), off
  by default.

Breaking: CommandFactSink is portable (feature `cell`) over the new
FactHost trait, which replaces FactRouting and the CommandHost bound.
The JS GameConnection rejoins with a fresh ticket on close 1012
(GAME_CLOSE_RESTARTED).

Tests: celld_zone on celld dev and on a 3-node RustFS fleet (owner
killed mid-flight: 1012, recovery elsewhere, 200 facts exactly once),
zone_status activity projection, plan unit tests, wasm32 domain check.

[[tasks/gr-p43-celld-zone-host]]
Bots append `{path}/{zone}` (default `/zones`), so they can join zones a
celld worker routes elsewhere (`/rooms/{room}`). A celld zone object's
`GET …/info` debug JSON allows any origin, so pages served elsewhere can
show which node and isolate run the zone.

[[tasks/gr-p43-celld-zone-host]]
Mob Pit becomes a near-infinite seeded grid of rooms (`pit.<x>.<y>`),
each one a celld Durable Object hosted by distributed_game's celld zone
host. celld decides where rooms run; nothing in the game names a node.

- game_sim::mobpit: `pit_room` (#[game_pure], biome from the Endless
  Grid's noise, danger, 6-12 mobs), doorway and arrival rules,
  `PIT_WORLD`; the page draws rooms with the same Rust as WASM.
- mobpit_rooms (new, wasm32-portable): the room zone (doorway handoffs
  with carried position, hibernation snapshot of the mobs and the room's
  kills, profile loaded from the Crawler cell after joining) and its
  CelldZone hooks (kill facts to crawler cells, lifecycle facts to
  presence cells, object to object).
- cells worker: PitRoom objects behind `/rooms/{room}`, ZoneActivity
  cells feeding the zone status read model through the outbox Queue.
- game-demos mobpit is the services side only (dev room tickets, relay,
  crawler directory, room status, boards fed from the directory with
  idempotent max updates). Removed: the native zone, its fact sink, the
  slow-storage knob and the in-memory presence mirror.
- Vite page: rooms through `/rooms` (proxied to celld), doorways, biome
  floor, live minimap of room status, restore toasts, debug overlay
  with the celld node/isolate per room. run.sh starts celld dev itself;
  `run.sh mobpit --fleet [N]` runs N production-mode nodes on one
  RustFS bucket (docker compose) behind a round-robin dev balancer.

Tests: mobpit_rooms reference-host tests; mobpit_smoke on celld (bots
in a room, dormant -> hibernated -> restored, celld kill -9 mid-fight,
presence cells); mobpit + leaderboard headless; pit_room WASM = native.

[[tasks/gr-p41-mobpit-rooms-zones]]
The SvelteKit Mob Pit joins the entrance room on celld and follows
doorways from room to room; tickets admit the entrance or a room being
rejoined (after a 1012 restart), never chosen by a node.

- mobpit-service projects the rooms' zone-activity events into
  `zone_activities` next to `player_stats`, readable over GraphQL @LiVe.
- The page: a live minimap (Rooms.graphql @load @LiVe: active, dormant,
  hibernated, never ran) with restore and sleep toasts, a debug overlay
  of the room object's node, isolate and activation, doorways drawn on
  the walls; the room entry points rotate per connection (celld
  forwards every socket to the room's node).
- Playwright: two players on a 3-node production-mode celld fleet
  (RustFS via docker compose, tests/helpers/fleet.mjs): kill, live read
  model, doorways into a room on another node, the left room asleep on
  the minimap, restored on return, another kill settled live.

[[tasks/gr-p41-mobpit-rooms-zones]]
The Mob Pit, Leaderboard and SvelteKit docs describe rooms as celld
objects (doorways, dormant/hibernated/restored, the minimap, the debug
overlay, `run.sh mobpit --fleet`) and the services that remain native.
The spike crate (examples/game/zone-do-spike) is gone: its glue is the
framework's celld zone host and Mob Pit's rooms; its measurements stay
in the spike task. CI's game-demos smoke requires the fleet for the
Playwright test.

[[tasks/gr-p41-mobpit-rooms-zones]] [[tasks/gr-p43-celld-zone-host]]
When Docker runs the RustFS container but its port never becomes
reachable, the Playwright fleet helper now stops the container and
falls back to `celld dev` (or fails with MOBPIT_FLEET_REQUIRED=1, as
CI sets), instead of failing after a minute.

[[tasks/gr-p41-mobpit-rooms-zones]]
@coderabbitai

coderabbitai Bot commented Oct 21, 2026

Copy link
Copy Markdown

Important

Draft PR not reviewed

Draft PRs are not automatically reviewed by default.

  • Trigger a manual review

To automatically review draft PRs, update your CodeRabbit configuration:

reviews:
  auto_review:
    drafts: true
  • Autofix · Keep fixing CodeRabbit findings and required CI, and resolving merge conflicts

Thanks for using CodeRabbit! It's free for OSS, and your support helps us grow. If you like it, consider giving us a shout-out.

❤️ Share

Comment @coderabbitai help to get the list of available commands.

GameClient used to hold the controlled entity still while a handoff
was in flight and drop the inputs sampled meanwhile, so a border
crossing showed a hold, then a hop. With prediction.handoffFrame (the
predicted fields re-expressed in the target zone's frame) the client
keeps predicting in the target's frame, sends the transfer's inputs to
the target once it welcomes the player, and eases from what was drawn
into the target's state.

Predictor.bind keeps (and replays) inputs recorded while no entity or
the same entity was bound, filtered by the delta's ack, instead of
dropping them; new step() and easeFrom().

[[tasks/gr-p41-mobpit-rooms-zones]]
Rooms sit at fixed world places (room (x, y) at (x, y) x 40 m) and a
doorway is a passage: pit_step walks through the gap past the wall
line, the room hands the player over 0.5 m past it (hysteresis) and
the next room admits it at the same world point with its velocity
(carried MoveState in the target frame) instead of 2 m inside the
opposite wall. Player x/y widen to +-22 m on the wire.

Both pages draw the world: the Vite page builds the 5x5 rooms around
yours from pitRoom (biome floor, walls with doorways, pillars) under a
floating origin; the SvelteKit canvas follows your world position over
the 3x3 rooms around. Both predict through the handoff (handoffFrame).

Tests: rooms test checks the arrival world point; headless and
Playwright walks assert the own world position never moves more than
one tick across a doorway.

[[tasks/gr-p41-mobpit-rooms-zones]]
A zone that refuses a Hello (expired reconnect token, bad ticket) sends
Error and should close the socket. celld room objects send the Error but
the close never reaches the client, so after a celld restart a connected
page tried its void token and then hung in 'reconnecting' forever.

The client now ends the attempt itself on a pre-Welcome Error: a resume
falls back to a fresh ticket, a refused first connect rejects.

Refs [[tasks/gr-p45-same-player-after-restarts]]
A page reload is a fresh ticket for a slot its principal still holds.
The default Zone::on_rejoin (leave, then join) respawned the player, so
every reload lost the body even inside the reconnect grace. Arena and
Endless Grid now keep it and re-greet the new page; the on_rejoin docs
say why a game wants that.

Refs [[tasks/gr-p45-same-player-after-restarts]]
run.sh gave Arena, Grid, Ripple, Gallery and Freeze a random ticket
secret per run, so a returning browser's guest token no longer verified
and it became a new player. Every demo now signs with a secret persisted
in .data/<demo> (SvelteKit also keeps its Auth.js secret), and the cells
worker is rebuilt when the room or sim crates change.

Refs [[tasks/gr-p45-same-player-after-restarts]]
A reload, a new tab, closing the browser, or a services or celld restart
put a returning Mob Pit player in a new body at the entrance spawn point:

- the rooms used the default rejoin (leave + join), discarding the body;
- nothing chose the player's last room (Vite joined ?zone= or pit.0.0,
  the SvelteKit ticket handler the entrance);
- a room forgot a player's place when its slot ended;
- the dev guest token lived in sessionStorage (one guest per tab).

Rooms now keep the body on rejoin and park the place and HP of a player
whose slot ends (and of connected players in every snapshot) by verified
principal, so a fresh join resumes there after the room slept or celld
restarted. Page loads start in the last room from the presence cell:
POST /dev/mobpit/start (guest token verified) for Vite, the ticket
handler's zone() for SvelteKit. The guest token moves to localStorage
(?player=<label> for several guests per browser).

Tests: room unit tests, a headless test per restart kind, and a reload
plus a fresh sign-in in the SvelteKit Playwright test.

Refs [[tasks/gr-p45-same-player-after-restarts]]

This branch has not been deployed

No deployments
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

1 participant