Skip to content
Draft
Show file tree
Hide file tree
Changes from all commits
Commits
Show all changes
23 commits
Select commit Hold shift + click to select a range
be03b91
feat(game)!: NetSim on celld zone objects
patrickleet Oct 21, 2026
6e3bb8d
feat(game)!: Mob Pit network panel, delete the Arena demo
patrickleet Oct 21, 2026
1cb7722
feat(game)!: passive watchers on celld zones, SDK watchZones
patrickleet Oct 21, 2026
7c3cd78
feat(game)!: Mob Pit sees next door, delete the Endless Grid
patrickleet Oct 21, 2026
8530d4d
feat(game): CelldZone::refresh keeps a zone in step with its world
patrickleet Oct 21, 2026
6eb5c28
feat(game)!: Mob Pit hoods, boroughs, city; delete Boss Ripple
patrickleet Oct 21, 2026
7896e89
feat(game)!: every Mob Pit room is laid out differently
patrickleet Oct 21, 2026
cd75571
feat(game)!: jumping in Mob Pit
patrickleet Oct 21, 2026
dbfeef6
feat(game): Mob Pit world events scheduled by boroughs
patrickleet Oct 21, 2026
a84dd27
fix(game): a celld zone wakes without waiting for dev settings
patrickleet Oct 21, 2026
0f38f4c
feat(game)!: Mob Pit bow and first person; delete Shooting Gallery
patrickleet Oct 21, 2026
d45f643
feat(distributed_game): live content rollout for celld zones
patrickleet Oct 21, 2026
13eac2a
feat(distributed_game): snap_above cuts teleports in corrections
patrickleet Oct 21, 2026
b493b10
feat(game)!: live content updates in Mob Pit; delete The Freeze
patrickleet Oct 21, 2026
306382a
fix(game): a respawn cuts instead of sliding the player
patrickleet Oct 21, 2026
c8c6d37
test(distributed_game_macros): correction error names snap_above
patrickleet Oct 21, 2026
8862aa5
fix(game): keep the player's surroundings visible in fog and storms
patrickleet Oct 21, 2026
9dc14ff
Merge branch 'feat/v5-celld-zones' into feat/v5-mobpit-world
patrickleet Oct 21, 2026
bb0da1f
test(game): settle the base RTT before the network panel check
patrickleet Oct 21, 2026
8346842
Merge branch 'feat/v5-celld-zones' into feat/v5-mobpit-world
patrickleet Oct 21, 2026
97b6ef8
fix(game): minimap follows the player with live dots
patrickleet Oct 21, 2026
585ee44
feat(game)!: near watchers wake and keep celld zones awake
patrickleet Oct 21, 2026
a96621b
feat(game): Mob Pit wakes the doorway rooms in reach
patrickleet Oct 21, 2026
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
16 changes: 13 additions & 3 deletions distributed_cli/src/game_client.rs
Original file line number Diff line number Diff line change
Expand Up @@ -152,6 +152,8 @@ enum Correction {
Ease {
#[serde(rename = "durationMs")]
duration_ms: u32,
#[serde(rename = "snapAbove", default)]
snap_above: Option<f32>,
},
Snap,
Replace,
Expand Down Expand Up @@ -1056,9 +1058,17 @@ impl<'a> Gen<'a> {
let _ = writeln!(out, "\t{}: {{", key(&kind.name));
for (field, correction) in corrected {
let policy = match correction {
Correction::Ease { duration_ms } => {
format!("{{ mode: 'ease', durationMs: {duration_ms} }}")
}
Correction::Ease {
duration_ms,
snap_above: None,
} => format!("{{ mode: 'ease', durationMs: {duration_ms} }}"),
Correction::Ease {
duration_ms,
snap_above: Some(above),
} => format!(
"{{ mode: 'ease', durationMs: {duration_ms}, snapAbove: {} }}",
f32_literal(*above)
),
Correction::Snap => "{ mode: 'snap' }".into(),
Correction::Replace => "{ mode: 'replace' }".into(),
};
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -142,7 +142,7 @@ export const FIELD_TYPES = {
export const CORRECTIONS: CorrectionPolicies = {
player: {
x: { mode: 'ease', durationMs: 150 },
y: { mode: 'ease', durationMs: 150 },
y: { mode: 'ease', durationMs: 150, snapAbove: 2.5 },
vx: { mode: 'snap' },
face: { mode: 'replace' },
name: { mode: 'replace' },
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -259,7 +259,8 @@
{
"correction": {
"durationMs": 150,
"mode": "ease"
"mode": "ease",
"snapAbove": 2.5
},
"name": "y",
"priority": 1.0,
Expand Down
5 changes: 5 additions & 0 deletions distributed_game/Cargo.toml
Original file line number Diff line number Diff line change
Expand Up @@ -97,6 +97,11 @@ name = "zone_lease"
path = "tests/zone_lease/main.rs"
required-features = ["cell"]

[[test]]
name = "content_rollout"
path = "tests/content_rollout/main.rs"
required-features = ["cell"]

[[test]]
name = "zone_state"
path = "tests/zone_state/main.rs"
Expand Down
48 changes: 44 additions & 4 deletions distributed_game/PROTOCOL.md
Original file line number Diff line number Diff line change
Expand Up @@ -194,6 +194,8 @@ replication priorities are not transmitted.
| `0x02` | C→S | Input |
| `0x03` | C→S | Ping |
| `0x04` | C→S | Leave |
| `0x05` | C→S | Content |
| `0x06` | C→S | Near |
| `0x80` | S→C | Welcome |
| `0x81` | S→C | Delta |
| `0x82` | S→C | Event |
Expand All @@ -212,10 +214,13 @@ decode errors.

```
Hello := 0x01, varuint protocol_version, string content_version,
varuint capabilities, string ticket, string reconnect_token
varuint capabilities, string ticket, string reconnect_token,
bool near
Input := 0x02, varuint seq, varuint client_tick, bytes payload
Ping := 0x03, f64 client_time
Leave := 0x04
Content := 0x05, string content_version
Near := 0x06, bool near
```

- **Hello** MUST be the first message, sent within the server's hello
Expand All @@ -225,6 +230,7 @@ Leave := 0x04
join `ticket` is a signed admission ticket (§8) and `reconnect_token` is
empty; for a reconnect `ticket` MAY be empty and `reconnect_token` is the
token from the last Welcome. A second Hello gets `Error(BAD_MESSAGE)`.
`near` is for observers (ignored for players): see **Near**.
- **Input**: `payload` is game-defined. Duplicate or already-processed
`seq` values are ignored. Inputs are rate limited per connection
(default 120/s, burst 240); excess inputs are dropped and the server
Expand All @@ -239,6 +245,22 @@ Leave := 0x04
- **Ping**: the server answers with Pong immediately.
- **Leave**: graceful exit; the player is removed at once (no reconnect
grace).
- **Content**: the client patched its content live (typically after a
`Resume` carrying a new version) and now runs `content_version`. The
server records it for the connection's player only when it equals the
zone's current content version (a client can catch up, never get
ahead) and ignores it otherwise; observers' are ignored. Without it a
live-patched client counts as stale until its next Hello.
- **Near** (and Hello `near`): an observer says it is near the zone (e.g.
its player is close to this room) or no longer is. On zones hosted as
celld objects a near observer whose ticket grants `wake` (§8) counts as
occupancy: its Hello wakes a sleeping zone (instead of `ASLEEP`) and the
zone keeps ticking while it stays near; `near = false` (or closing the
connection) lets the zone go dormant and hibernate as usual. Without the
grant the observer stays a passive watcher and is sent
`Error(WAKE_DENIED)` (the connection stays open).
Servers that keep observed zones running anyway (the native `net`
server) ignore it; players' are ignored.

Worked example — Hello (`version 1, content "c1", caps 5, ticket "t.k.n"`):

Expand All @@ -249,6 +271,7 @@ Worked example — Hello (`version 1, content "c1", caps 5, ticket "t.k.n"`):
05 capabilities 5
05 74 2e 6b 2e 6e ticket "t.k.n"
00 reconnect_token ""
00 near false
```

Input (`seq 300, client_tick 1000, payload 01 ff`): `02 ac02 e807 02 01ff`.
Expand Down Expand Up @@ -309,8 +332,13 @@ Redirect := 0x89, string zone_id, string url
- **Error**: codes `1` BAD_MESSAGE, `2` UNAUTHORIZED, `3` REJECTED,
`4` ZONE_FULL, `5` VERSION_MISMATCH, `6` RATE_LIMITED, `7` NOT_FOUND,
`8` RECONNECT_FAILED, `9` ZONE_UNAVAILABLE (the zone could not be created
right now; retry later). Errors during the handshake are followed by close;
`BAD_MESSAGE` and `RATE_LIMITED` after joining are not.
right now; retry later), `10` ASLEEP (an observer is not admitted to a
sleeping zone it would otherwise wake; zones hosted as celld objects treat
observers as passive watchers unless near with a `wake` grant; retry
later), `11` WAKE_DENIED (an observer said it is near but its ticket
does not grant `wake`; it stays a passive watcher — a fresh ticket may
grant it). Errors during the handshake are followed by close;
`BAD_MESSAGE`, `RATE_LIMITED` and `WAKE_DENIED` after joining are not.

Worked example — Welcome (session "s1", zone "z1", player, 30 Hz ticks,
20 Hz patches, tick 42, time 1000.25 ms, token "rt", content "c1",
Expand Down Expand Up @@ -456,6 +484,11 @@ unpadded base64url). Any HS256 JWT library can mint one.
inputs.
- `name` (string, optional) — display name vouched for by the issuer
(never identity). Carried into transfer tickets.
- `wake` (bool, optional, default false) — observers only: the issuer
lets this watcher wake the zone and keep it awake while it is near
(Hello `near` / `Near`, celld zones). The issuer decides who may (for
example only rooms next to the principal's own); the zone never
trusts the client for it.
- Failures produce `Error(UNAUTHORIZED)` and close.

**Custom ticket formats.** A server MAY admit join tickets in another
Expand Down Expand Up @@ -553,7 +586,7 @@ JSON conventions in `decoded`:
## 10. Versioning

`protocol_version` changes on any incompatible wire change. Message type
bytes `0x05..0x7f` and `0x8a..0xff`, and field type tags `10..255`, are
bytes `0x07..0x7f` and `0x8a..0xff`, and field type tags `10..255`, are
reserved for future versions.

Additive changes within version 1 (clients SHOULD ignore unknown kick and
Expand All @@ -562,6 +595,13 @@ error codes and treat them as "session over" / "request failed"):
- `0x89 Redirect` (server → client).
- Kick code `7` RELOAD.
- Error code `9` ZONE_UNAVAILABLE.
- Error code `10` ASLEEP.
- Error code `11` WAKE_DENIED.
- `0x06 Near` (client → server) and the ticket claim `wake`.

Not additive (made before the first release, so the version stays 1):
Hello's trailing `bool near` — clients built before it send one byte
less and are refused with `BAD_MESSAGE`; rebuild them.
- Transfer tickets (§8.1) and content-version policies (§8.2); `Handoff`
and `Freeze`/`Resume` encodings are unchanged.

Expand Down
20 changes: 12 additions & 8 deletions distributed_game/README.md
Original file line number Diff line number Diff line change
Expand Up @@ -95,18 +95,15 @@ The words used throughout:

## Try it

Eight runnable demos live in [`examples/game`](../examples/game/README.md),
each built to show one part of the engine:
The runnable demos live in [`examples/game`](../examples/game/README.md).
They are being folded into one game, Mob Pit (the others are deleted as
their mechanic moves in):

| Demo | Run | Shows |
|---|---|---|
| Arena | `examples/game/run.sh arena` | ticks, deltas, prediction on/off, interest radius vs. global entities, simulated latency |
| Mob Pit | `run.sh mobpit` (`--fleet` for several celld nodes) | rooms as celld objects joined by doorways, sleeping when empty and restored on return; combat whose rewards are saved exactly once without stalling |
| Mob Pit | `examples/game/run.sh mobpit` (`--fleet` for several celld nodes) | rooms as celld objects joined by doorways, sleeping when empty and restored on return; combat whose rewards are saved exactly once without stalling; a network panel (simulated latency / jitter / loss on every room, prediction on/off with a server ghost, the interest ring); the rooms next door seen through passive watchers (the doorway rooms in reach woken by near watchers); hoods › boroughs › city as tier cells (boss kills ripple up through a saga, weather lifts as bosses fall); world events scheduled by boroughs; jumping; a bow with lag-compensated hits and first person |
| Leaderboard | `run.sh leaderboard` | a live ranking fed from saved kills, rebuilt from history |
| Shooting Gallery | `run.sh gallery` | lag compensation on/off under latency |
| The Freeze | `run.sh freeze` | pausing every zone on two servers, updating content, resuming; safe-room reloads |
| Endless Grid | `run.sh grid` | a generated world split into zones across two servers, seamless border crossings, zones sleeping and waking |
| Boss Ripple | `run.sh ripple` | nested zones (realm › city › borough › neighbourhood) with events travelling up |

Then open `http://localhost:5173/<demo>/`. Each page explains what to try.

Expand Down Expand Up @@ -402,7 +399,8 @@ against its former hand-written loop.
bytes), `#[quant(min, max, bits)]` / `#[quant(centered = R, bits)]` (zero
exact) / `#[angle(bits)]` / `#[field_type(EXPR)]` for floats,
`#[priority(p)]`, and `#[correct(ease = ms | snap | replace)]` client
defaults. The interest position is the `x` / `y` fields (or
defaults (`ease = ms, snap_above = m`: an error larger than `m` cuts the whole
entity — a respawn or another server-side teleport is a cut, not a slide). The interest position is the `x` / `y` fields (or
`#[replicated(x = .., y = ..)]`); a kind without one must be `global`.
`out.add(..)` returns a handle for `.global()`, `.local()`, `.priority(p)`.
For each client the engine then sends only the difference from what that
Expand Down Expand Up @@ -1175,6 +1173,12 @@ principals commit concurrently; settlements arrive in completion order.
`DG_NET_LATENCY_MS`, `DG_NET_JITTER_MS`, `DG_NET_LOSS`,
`DG_NET_RETRANSMIT_MS`. `GameServer::set_net_sim(Some(sim) | None)`
changes it at runtime for every connection (order is preserved).
`distributed_game::netsim` is portable: zones hosted as celld objects
(`celld-zone`) apply the same simulation to each socket when the worker
sets `ZONE_DEV_SETTINGS=1`; the setting lives in the worker's dev
settings object (`celld_zone::ZoneDevSettings`, `PUT /zone-dev/netsim`
`{latencyMs, jitterMs, loss}` with the internal secret) and every zone
object reads it within a second.

## World structure

Expand Down
2 changes: 2 additions & 0 deletions distributed_game/src/bots.rs
Original file line number Diff line number Diff line change
Expand Up @@ -369,6 +369,7 @@ async fn drive(
role: Role::Player,
name: None,
extensions: Default::default(),
wake: false,
});
let url = format!(
"{}/{}/{}",
Expand All @@ -385,6 +386,7 @@ async fn drive(
capabilities: 0,
ticket,
reconnect_token: String::new(),
near: false,
});
ws.send(Message::Binary(hello.encode().into()))
.await
Expand Down
91 changes: 91 additions & 0 deletions distributed_game/src/celld_zone/cells.rs
Original file line number Diff line number Diff line change
Expand Up @@ -12,6 +12,7 @@ use serde_json::{json, Value};
use worker::{Env, Method, Request, Response, Result, State};

use crate::cell::fact_session_from_headers;
use crate::world::content_rollout::{mount_content_rollout, ContentRollout};
use crate::world::zone_activity::{mount_zone_activity, ZoneActivity};

/// Builds the handler session from trusted request headers (after the
Expand Down Expand Up @@ -187,3 +188,93 @@ async fn read_activity(cell: &AggregateCell<ZoneActivity>) -> std::result::Resul
let activity = cell.load().await.map_err(|error| error.to_string())?;
Ok(json!({ "activity": activity.map(|a| a.row()) }))
}

/// Dev-only settings shared by every zone object of a worker (kind
/// [`ZONE_DEV_KIND`](super::ZONE_DEV_KIND), binding `ZONE_DEV`), e.g. the
/// network simulation under shard [`NETSIM_SHARD`](super::NETSIM_SHARD).
/// `GET /zone-dev/{shard}` returns the stored JSON (`{}` when unset), `PUT`
/// (or `POST`) replaces it. Every request needs the internal secret. Zone
/// objects read it only when `ZONE_DEV_SETTINGS=1`.
pub struct ZoneDevSettings {
state: State,
env: Env,
}

impl ZoneDevSettings {
/// The settings object of `state`.
pub fn new(state: State, env: Env) -> Self {
Self { state, env }
}

/// Durable Object `fetch`.
pub async fn fetch(&self, mut req: Request) -> Result<Response> {
if let Err(error) = authenticate(&req, &self.env) {
return error_response(error);
}
const KEY: &str = "settings";
match req.method() {
Method::Get => {
let stored: Option<Value> = self.state.storage().get(KEY).await.ok().flatten();
json_status(stored.unwrap_or_else(|| json!({})), 200)
}
Method::Put | Method::Post => {
let body: Value = match req.json().await {
Ok(body @ Value::Object(_)) => body,
_ => {
return error_response(CellDispatchError::BadRequest(
"a JSON object is required".into(),
))
}
};
self.state.storage().put(KEY, &body).await?;
json_status(body, 200)
}
_ => json_status(json!({ "error": "not found" }), 404),
}
}
}

/// The content-rollout cell of a game worker (`ContentRollout:{world}`,
/// kind [`CONTENT_ROLLOUT_KIND`](crate::world::content_rollout::CONTENT_ROLLOUT_KIND),
/// binding `CONTENT_ROLLOUT`): the world's live content version and rollout
/// phase, read by every zone object ([`ObjectOptions::content_rollout`](super::ObjectOptions::content_rollout)).
/// `sessions` decides who may start a rollout: it must give the admin's
/// session the [`CONTENT_ADMIN_ROLE`](crate::world::content_rollout::CONTENT_ADMIN_ROLE)
/// (the cell's own schedules need no session). `GET` returns
/// `{"state": ContentState | null}`.
pub struct ContentRolloutCell {
cell: AggregateCell<ContentRollout>,
env: Env,
sessions: Sessions,
}

impl ContentRolloutCell {
/// Open the cell of `state`, publishing its events to `outbox`.
pub fn new(state: State, env: Env, outbox: CelldOutbox, sessions: Sessions) -> Self {
let cell = mount_content_rollout(
AggregateCell::<ContentRollout>::from_state_with_snapshots(state, 20)
.expect("content rollout cell identity"),
)
.with_celld_outbox(outbox);
Self {
cell,
env,
sessions,
}
}

/// Durable Object `fetch`.
pub async fn fetch(&self, req: Request) -> Result<Response> {
serve_cell(&self.env, &self.cell, req, read_rollout, self.sessions).await
}

/// Durable Object `alarm` (the rollout's own schedule).
pub async fn alarm(&self) -> Result<Response> {
cell_alarm(&self.env, &self.cell).await
}
}

async fn read_rollout(cell: &AggregateCell<ContentRollout>) -> std::result::Result<Value, String> {
let rollout = cell.load().await.map_err(|error| error.to_string())?;
Ok(json!({ "state": rollout.map(|r| r.state()) }))
}
Loading
Loading