# World, Rooms, Presence & Multiplayer

V3 is Lilith's embodied tier: the place where a meditator, a concert performer,
an operator on shift, and a low-end browser visitor all stand in the _same_ room
and share one truth about who is where, who touched what, and who may hear whom.
This page is the feature-level tour of the spatial substrate that makes that
possible — the **world model** (the realm → venue → room → cell hierarchy that
names every space), the **rooms / sharding / capacity** model (how a single
venue fans out into session-scoped instances spread across server shards), the
**presence** layer (embodiment, activity, and the visibility bands that decide
who appears to whom), and the **real-time multiplayer** that keeps thousands of
avatars coherent without the server melting. It deliberately hands two adjacent
concerns to siblings: how an avatar is _rendered_, named, and heard lives in
[Avatars, Nameplates & Spatial Audio](./avatars-nameplates-spatial-audio.md),
and which client surface a given visitor lands on (native UE5, the Pixel
Streaming worker, the WebGPU fallback, and how a 4,096-seat stadium is banded
across them) lives in
[Client Tiers, Fidelity & the Stadium](./client-tiers-fidelity-stadium.md). The
deep engine treatment — the authoritative tick, the QUIC gateway, JWT rotation,
the voice SFU, durable persistence — is the architecture companion,
[../architecture/world-server-and-gateway.md](../architecture/world-server-and-gateway.md),
with prediction and physics in
[../architecture/netcode-protocol-and-physics.md](../architecture/netcode-protocol-and-physics.md).
For the full feature scope this slots into, start at the hub:
[../V3_features.md](../V3_features.md).

## What ships, honestly

The split is worth stating up front, because the description and the code do not
fully agree, and this page follows the code.

- **Real and test-covered.** The spatial _model_ is genuine Rust in
  `apps/v3/lilith-world-server/src/lib.rs` (~6,300 lines, 28 in-file
  `#[test]`/`#[tokio::test]` cases plus seven integration files under `tests/`).
  Rooms, the consistent-hash shard ring, `rstar` R-tree interest management, the
  per-tier visible-entity caps, the 60-second reconnect retention, the
  **presence visibility-band authority**, and the scheduled-class admission gate
  are all implemented, deterministic, and covered. The wire contract
  (`libs/v3/multiplayer-protocol/`) carries presence, transforms, and capacity
  as first-class fields and is asserted byte-identical across Rust, TypeScript,
  and hand-rolled UE C++.
- **Library-grade, not a running daemon.** The world server's `run_service()`
  (`lib.rs:4564`) binds an axum router that exposes only `/healthz`, `/readyz`,
  and `/metrics` (`build_router`, `lib.rs:4516`). There is **no accept loop
  feeding live client packets into a ticking `RoomRegistry`** — the simulation
  core is exercised by tests, not yet by a client-serving socket. Treat presence
  and multiplayer as a verified library, not a deployed cluster.
- **Aspirational in the monolith / corrected here.** The monolith's five-level
  hierarchy and four numbered capacity tiers are a _product model_; the code is
  thinner and the vocabulary has drifted across three layers (see below). The UE
  `V3World` module is a scene/launch _validator_, not a `UV3WorldSubsystem`
  owning a live mirror, and "cross-shard chat via the V1 event bus" is an
  in-process `Vec` (`CrossShardEventBus`, `lib.rs:1120`).

## The world model: realms, venues, rooms, cells

The monolith describes a five-level hierarchy: **Realm** (a top-level theme —
Tara, Saraswati, Commons), **District** (a region within a realm), **Venue** (a
named addressable space), **Room** (a session-scoped instance of a venue holding
live participants, props, and music), and **Cell** (a break-out sub-room — a
small yoga circle, an after-concert green room). Two of those five are
load-bearing in code; the rest are naming convention the authoring side carries.

The unit the world server actually keys on is the **room**. `RoomKey`
(`lib.rs:64`) is a `(venue_id, instance_id)` pair whose `room_id()` renders as
`venue:instance` — exactly the shape the UE side ships as its default,
`commons-atrium:morning` (`V3World.h:43`). A live room is a `RoomState`
(`lib.rs:316`): its key, the shard it lives on, a `capacity_tier`, an optional
`scene_revision`, a `RoomLifecycle` (`Active` / `Shutdown`), the participant
map, and a broadcast queue. So "Venue" and "Room" are real, distinct keys;
"District" and "Cell" do not appear as code structures — they are product-model
groupings that authoring and routing impose on top of the `(venue, instance)`
namespace.

"Realm" survives in three different forms, which is itself an honest finding. On
the UE client a scene carries a `RealmId` `FName` (`V3World.h:63`, default
`Lilith.Atrium`); in the V3 contracts `V3RealmSchema`
(`libs/contracts/src/v3/primitives.ts:17`) is an **environment** enum
(`production | preview | training | private`), not a theme; and the theme realms
(Tara / Saraswati / Commons) live as the tenant vocabulary `V3TenantSchema`
(`primitives.ts:9` — `lilith-commons`, `tara-studio`, `saraswati-stage`). The
monolith's tidy "Realm → District → Venue" stack is, in practice, a tenant-owned
theme plus a `(venue, instance)` room key plus an environment promotion lane.

```mermaid
graph TD
    R["Realm / theme<br/>(tenant: tara-studio, saraswati-stage, lilith-commons)"]
    D["District<br/>(product grouping — no code struct)"]
    V["Venue<br/>(venue_id in RoomKey)"]
    Room["Room = RoomState<br/>(venue_id:instance_id)"]
    Cell["Cell / break-out<br/>(product grouping)"]
    R --> D --> V --> Room --> Cell
    Room -. "promoted dev→preview→prod" .-> Env["V3RealmSchema environment"]
```

### Scenes and promotion

A room renders a **scene**: a published level plus its assets. The UE
`FV3AtriumPlaceholderSceneSpec` (`V3World.h:60`) is the concrete shape — a level
asset path, a `RealmId`, a `RoomId`, a one-line `VisualThesis`, and a list of
required `FV3AtriumSceneElement`s. `BuildDefaultSceneSpec` (`V3World.cpp:121`)
authors six of them for the Commons Atrium: the level, a walkable floor, a
`MetaSound` ambient bed, Niagara light shafts, the V3 HUD overlay, and a PIE
player start, each with a real asset path and a `bRequiredForPie` flag.
`Validate` refuses a spec missing any required element. On the server side,
scenes reach a live shard without a redeploy through the hot-reload poller
described in the
[world-server architecture companion](../architecture/world-server-and-gateway.md);
`RoomState` carries the `SceneRevision` it was spawned at so an instance restart
can pick up a re-published scene. Authoring, signing (Isis provenance), and the
dev → preview → prod promotion gates are covered by the content-pipeline pages.

## Rooms, sharding, and capacity

### Capacity: a four-name product model over two budget tiers

This is the sharpest description-vs-code gap, so it is worth being exact. The
monolith names four capacity tiers — **Intimate** (≤ 12), **Class** (≤ 64),
**Hall** (≤ 256), **Stadium** (≤ 4,096). The V3 contracts name a _different_
four — `class | salon | theater | stadium` (`V3CapacityTierSchema`,
`primitives.ts:20`). But the **authoritative** world server only distinguishes
**two**: `CapacityTier::Class` and `CapacityTier::Stadium` (the wire enum
`CapacityTier`, `multiplayer.proto:5`). Those two are not head-count admission
limits at all — they are _interest and bandwidth budgets_. `visible_entity_cap`
(`lib.rs:2523`) maps the tier to `CLASS_TIER_VISIBLE_ENTITY_CAP = 32` or
`STADIUM_TIER_VISIBLE_ENTITY_CAP = 256` (`lib.rs:506`), and the bandwidth
validator gates the same two tiers at ≤ 32 kbps and ≤ 256 kbps. So the
"4,096-seat" figure is a product target met by the
[stadium banding model](./client-tiers-fidelity-stadium.md), not a number any
admission check in the world server enforces.

In fact the generic join path enforces **no head-count cap at all**. `join_room`
(`lib.rs:3792`) rejects only a duplicate `session_id`
(`ParticipantAlreadyJoined`); it does not count the room against a tier limit.
The one place a hard cap is enforced is the _scheduled_ path:
`spawn_tara_live_class_at_start` (`lib.rs:3435`) requires the supplied roster to
**exactly** match `schedule.capacity_limit` (`RosterCapacityMismatch`,
`lib.rs:3453`), reject an empty roster, reject duplicate sessions, and require
the named instructor to be present (`MissingInstructor`). That is the real
capacity contract: a booked class admits its exact roster; an open Commons room
relies on the interest/bandwidth budget rather than a turnstile.

### Sharding by consistent hash

Rooms above the intimate scale are distributed across world-server nodes by a
**consistent-hash ring**. `ConsistentHashRing` (`lib.rs:1078`) builds a
`BTreeMap<u64, shard_id>` seeded with `virtual_nodes` replicas per `ShardNode`
(so load spreads evenly rather than clumping), and `route_room` hashes a
`RoomKey::room_id()` and walks to the next ring position (wrapping at the end).
`ShardedRoomDirectory` (`lib.rs:1160`) wraps the ring and a `spawn_routed_room`
helper that routes a key to its shard and spawns the room there. The even-spread
property is a real test:
`consistent_hash_routes_one_thousand_sessions_evenly_across_three_shards`
(`lib.rs:6265`) routes a thousand rooms across three shards and asserts the
distribution is balanced, and
`sharded_directory_spawns_routed_rooms_and_propagates_cross_shard_chat`
(`lib.rs:6292`) walks the spawn-and-chat path. The matching gateway-side ring
(`GatewayConsistentHashRing`) pins each _session_ to the same shard so a client
and its room agree on a home node — that edge logic is in the
[world-server-and-gateway companion](../architecture/world-server-and-gateway.md).

One honest correction: cross-shard chat and presence do **not** ride V1's event
bus as the monolith implies. `CrossShardEventBus` (`lib.rs:1120`) is an
in-process `Vec<CrossShardChatEvent>` filtered by target room — a correct local
model of the fan-out, not a distributed bus integration.

### Reconnect retention

A transient drop does not evict a participant. `retain_session_for_reconnect`
(`lib.rs:3819`) parks a departing participant's full `ParticipantState` for
`RECONNECT_SESSION_RETENTION_MS = 60_000`; `resume_retained_session`
(`lib.rs:3839`) rejoins them if they return inside the window and refuses (and
reaps) an expired one; `purge_expired_retained_sessions` sweeps the rest. The
test `world_server_retains_session_state_for_thirty_second_reconnect`
(`lib.rs:5545`) proves a 30-second round trip lands the participant back intact.
The gateway honours the _same_ 60-second window, so a reconnecting client is
re-pinned to the shard that still holds its retained state.

## Presence and visibility bands

### Embodiment and activity

Every connected user is a `ParticipantState` (`lib.rs:83`): a `session_id`, an
`avatar_id`, an `EntityTransform`, and a join timestamp. The transform
(`multiplayer.proto:85`) carries quantized position/rotation/velocity (integer
millimetres and milli-degrees), an embedded `AvatarExpression` (an expression id
plus intensity in basis points), a `lod` band, and a `last_input_sequence`. On
the wire, a `PresencePacket` (`multiplayer.proto:115`) adds an `activity_state`
and a `visibility_band`. The protocol's `ActivityState` enum is `IDLE`,
`WALKING`, `PRACTICING`, `PERFORMING`, `OPERATOR` (`multiplayer.proto:11`) —
narrower than the monolith's
`present | practicing | watching | speaking | away | do-not-disturb`, an honest
divergence to expect when reading the wire format. Expression and the
status-ring rendering of these states are the avatar page's concern; see
[Avatars, Nameplates & Spatial Audio](./avatars-nameplates-spatial-audio.md).

### The visibility-band authority

This is the strongest presence piece in code, and it matches the monolith
exactly. `VisibilityBand` (`lib.rs:91`) is
`Public | Tenant | Cohort | Invited | Invisible`, and
`PresenceVisibilityAuthority` (`lib.rs:221`) decides, for every presence
broadcast, _who is allowed to receive it_. `subject_visible_to_viewer`
(`lib.rs:277`) encodes the rules precisely:

- you always see yourself;
- **operators always see operator peers**
  (`subject.is_operator && viewer.is_operator`), so a moderation console is
  never blinded;
- `Public` → everyone; `Tenant` → same tenant only; `Cohort` → same tenant _and_
  same non-empty cohort; `Invited` → only sessions on the subject's invite list;
  `Invisible` → no one.

`recipients_for_presence` (`lib.rs:240`) walks every viewer in the room through
that predicate and returns a sorted recipient set, failing loud if the subject
isn't present, a profile is missing, or the band would empty the recipient set
entirely (`EmptyRecipientSet`). `broadcast_presence_with_visibility`
(`lib.rs:3909`) then enqueues a `presence.visibility` broadcast addressed to
only those recipients and reports how many were `filtered_session_count`. The
integration test `tests/visibility_band.rs` stands up a twelve-session room
spanning every band — public, tenant, cohort peer, other-cohort, invited,
uninvited, invisible, two operators, and a foreign tenant — and asserts each
band resolves to the correct audience. This is the `presence-visibility-band`
capability advertised in `SERVICE_DESCRIPTOR` (`lib.rs:39`).

```mermaid
flowchart TD
    S["subject presence update"] --> Self{"viewer == subject?"}
    Self -->|yes| Show["receive"]
    Self -->|no| Ops{"both operators?"}
    Ops -->|yes| Show
    Ops -->|no| Band{"subject band"}
    Band -->|Public| Show
    Band -->|Tenant| T{"same tenant?"}
    Band -->|Cohort| C{"same tenant + cohort?"}
    Band -->|Invited| I{"viewer on invite list?"}
    Band -->|Invisible| Hide["filtered out"]
    T -->|yes| Show
    T -->|no| Hide
    C -->|yes| Show
    C -->|no| Hide
    I -->|yes| Show
    I -->|no| Hide
```

Presence cadence is a budgeted contract. The protocol's capability descriptor
(`libs/v3/multiplayer-protocol/src/index.ts`) publishes
`presence-packet:8ms-budget`, `snapshot-delta:16ms-budget`, and
`gateway-pin:24ms-budget`, and a session carries a `LilithGatewayPin`
(`libs/contracts/src/v3/lilith.ts:33`) — `gatewayId`, `shardId`, `expiresAt` —
once the edge has bound it to a shard. The monolith's "≤ 20 Hz transforms, ≤ 60
Hz expression, event-driven activity" maps onto the tick config below.

## Real-time multiplayer

### Server-authoritative, no rollback

Lilith multiplayer is client-server with **server-authoritative state and no
rollback** — a deliberate choice, because V3 is a contemplative/performance
space, not a twitch fighter. The simulation clock is `TickLoopConfig`
(`lib.rs:336`), a single generic struct defaulting to **50 Hz internal tick / 20
Hz transform broadcast / 60 Hz expression interpolation** (`lib.rs:343`). The
per-mode rate table in the monolith (20 Hz yoga, 10/20 Hz concert audience, …)
is the _intended configuration_ for each room type; the code is the
parameterized engine that consumes it, not a set of hard-coded presets.
Snapshots go out as deltas — only entities that changed since a named
`base_sequence` are sent — and a client chains `base_sequence`s to detect a
dropped or reordered delta rather than silently corrupting its mirror.
Prediction is light dead reckoning clamped to a 0–250 ms extrapolation window;
reconciliation accepts a single sub-perceptual frame of divergence before
snapping to server truth. The prediction/physics detail is the
[netcode companion](../architecture/netcode-protocol-and-physics.md).

### Interest management keeps a stadium bounded

A viewer never receives every avatar. `InterestManager::visible_entities`
(`lib.rs:1310`) loads all _other_ participants into an `rstar` R-tree and
returns the **nearest N**, where N is the tier cap (32 at class, 256 at
stadium), tie- broken by id for deterministic output. This is a genuine spatial
index, pinned by two load tests:
`interest_manager_caps_class_visibility_at_32_nearest_entities` (`lib.rs:5734`)
and `stadium_interest_load_test_confirms_256_entity_cap` (`lib.rs:5783`). It is
what lets a 4,096-attendee stadium room hand each viewer a bounded snapshot —
the front band gets authoritative transforms, everyone beyond the cap is the
crowd shader described on the
[client-tiers page](./client-tiers-fidelity-stadium.md). Bandwidth is then a
tested budget, not a hope: the class tier proves 256 moving avatars stay under
32 kbps on deltas alone, which is why no `zstd` was ever needed.

### Interaction primitives — validated, owned, consented

Multiplayer interaction is server-validated so a client can never assert state
the simulation rejects. Three Rust authorities carry it: pickup/place
(`InteractionAuthority` — cross-room pickup and double-grab are refused, covered
by `tests/pickup_place.rs`), the **asana-lock consent state machine**
(`Requested → Consented → Locked → Released`, where only the _student_ may
consent, covered by `tests/asana_lock.rs`), and a parallel physical-adjustment
consent gate (`tests/tara_physical_adjustment_consent.rs`). A `rapier3d`
anti-cheat rejects any transform implying more than 12 m/s of motion. These are
the contractual backbone behind Tara live classes; the consent flows are
detailed in the netcode and Tara companions.

## A join, end to end

Assembled from the real functions above (the serving daemon does not yet wire
them together), a join looks like this:

```mermaid
sequenceDiagram
    participant C as Client
    participant G as Realtime Gateway
    participant W as World Server (RoomRegistry)
    C->>G: handshake(JWT, room_id, transport)
    G->>G: route_session -> shard pin (TTL 60s)
    G->>W: spawn_routed_room / join_room (RoomKey venue:instance)
    W->>W: validate (no duplicate session; class roster if scheduled)
    W->>W: visible_entities (rstar nearest-N, cap 32/256)
    loop 20 Hz transform / 60 Hz expression
        W->>W: FixedTickLoop.tick -> SnapshotBroadcastPlan
        W-->>C: ServerEnvelope{ snapshot_delta }
    end
    W->>W: broadcast_presence_with_visibility (band-filtered recipients)
    Note over C,W: on transient drop, retain 60s; resume on the same shard
```

The UE side participates as a scene/launch validator, not a net subsystem.
`FV3AtriumPlaceholderSceneBuilder::BuildColdJoinValidationReport`
(`V3World.cpp:187`) runs 96 samples over six deterministic phase fixtures —
`tap_to_room_ticket` (500 ms budget) through
`avatar_spawn_to_fully_rendered_frame` (900 ms) — against a 5-second
join-to-rendered budget, and `ConnectLocalWorldServerStack` validates the
`lilith-world-server:43101` service config (`V3World.h:37`) rather than opening
a live socket. These are honest synthetic budget checks, covered by UE
automation, not measurements of real joins.

## Where this connects

- [Avatars, Nameplates & Spatial Audio](./avatars-nameplates-spatial-audio.md) —
  how the embodiment, activity-state ring, nameplate LOD, and voice that ride
  these presence packets are rendered and heard.
- [Client Tiers, Fidelity & the Stadium](./client-tiers-fidelity-stadium.md) —
  which surface a visitor lands on, the Tier-2 fidelity envelope, and the
  4,096-seat stadium banding the capacity tiers feed.
- [../architecture/world-server-and-gateway.md](../architecture/world-server-and-gateway.md)
  — the authoritative tick, QUIC gateway, JWT rotation, voice SFU, and durable
  persistence behind this model.
- [../architecture/netcode-protocol-and-physics.md](../architecture/netcode-protocol-and-physics.md)
  — the wire protocol, prediction/reconciliation, and the physics/consent
  authorities in depth.
- The feature hub: [../V3_features.md](../V3_features.md).
