Lilith Metaverse · Features

World, Rooms, Presence & Multiplayer

A focused page within the Lilith Metaverse Features documentation. The full map and every sibling page live in the Features hub.

7sections12 minread3diagrams

On this page

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, 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. 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, with prediction and physics in ../architecture/netcode-protocol-and-physics.md. For the full feature scope this slots into, start at the hub: ../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:9lilith-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.

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 FV3AtriumSceneElements. 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; 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, 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.

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.

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).

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_sequences 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.

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. 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:

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#