The ladder chooses realization, not membership or world truth. Capacity, latency, device, entitlement, comfort, and accessibility can change the tier; the member should remain in the same governed room job with honest fidelity disclosure.
V3 — the Lilith Metaverse — has to put a gaming PC, a Quest 3, an iPhone, a locked-down work laptop, and a Chromebook on hotel Wi‑Fi into the same yoga room or the same concert, and it has to do that without forking the world or quietly handing the weak device a second-class truth about who is present and what is safe. The premium experience — Lumen global illumination, Nanite geometry, MetaHuman avatars, Sequencer-driven concerts — can only run inside Unreal Engine 5, so the strategy is to build one UE5 project and reach every device through a tiered client ladder: native UE binaries at the top, the same binary server-rendered over Pixel Streaming for the browser, a locally-rendered three.js fallback for hardware that can do neither, and a static landing page as the floor. Each rung trades fidelity — fewer particles, baked light instead of Lumen, a procedural avatar instead of a MetaHuman — but never identity, presence, or safety: every tier speaks the same multiplayer wire protocol to the same authoritative Rust world server and authenticates through the same V1 BFF. This page is the feature-level tour of that ladder, the Tier-2 fidelity envelope (what a fallback visitor genuinely loses, labelled honestly against the code), and the stadium envelope — how a single concert seats up to 4,096 attendees by banding them across the render paths. It hands the enforcement mechanics — the typed launch contract, the two-layer router, the Pixel Streaming relay — to its architecture companion ../architecture/tier-routing-and-pixel-streaming.md, and the world/presence model it rides on to ./world-rooms-presence-multiplayer.md. The surface-by-surface orientation is its sibling ./product-surfaces.md. The feature map this page belongs to is ../V3_features.md.
What ships, honestly#
The routing decision is real, typed, and tested on both layers. A visitor
crosses an availability gate in the Oshun shell BFF
(resolveV3LilithLaunchDecision,
apps/oshun/bff/src/routes/v3-lilith-launch.ts) that answers "is this region
open and this tenant cleared here?", then a device-capability surface picker in
the Lilith BFF (resolveLilithLaunchDecision,
apps/lilith/bff/src/routes/v3-lilith-launch-route.ts) that answers "what can
this device actually render?" and emits one of four surfaces —
native-deep-link, pxstream-browser, fallback-browser, or static-landing
— as a discriminated-union target so the dishonest combinations (a streaming
target with no POP, a static landing that still claims a renderer) are
unrepresentable. That picker is regression-locked by a weighted synthetic
device-mix gate that asserts per-tier decision shares to within ±1 point.
The Pixel Streaming browser client is a real Epic binding — player.ts
(@oshun/lilith-web-pxstream) imports
@epicgames-ps/lib-pixelstreamingfrontend-ue5.5 and drives it through a
LilithPixelStreamingClient state machine. The Tier-2 fallback's renderer,
client-side physics, spatial audio, tenant parity model, and steady-FPS gate are
genuine (@oshun/lilith-engine-web-fallback), exercised by Vitest and a
pixel-reading Playwright suite.
Three honest qualifications keep this page from over-claiming, and each is verified below rather than asserted:
- Tier-1 is a buildable engineering scaffold, not a content-complete game. The 17 UE modules, 11-platform cook registry, Game Feature plugin pipeline, and wire codec are real and tested; the authored content (levels, ability blueprints, Niagara, MetaSound graphs, avatar meshes) is largely declared in manifests but not committed. See ../architecture/tier1-ue5-client.md.
- The Pixel Streaming worker fleet and Epic signaller are external infrastructure. The browser lib binds the real Epic frontend and the relay constructs Epic-protocol SDP, but the per-session headless UE process lives in the GPU fleet, not this repo.
- Tier-2 has three labelled fidelity gaps beyond "reduced": VRM avatar rasterization, live multiplayer packets, and real binary asset transcoding are all planned, not present — the running fallback uses procedural primitive avatars, a simulated gateway handshake, and a bake that emits byte-budgeted placeholders. These are detailed under The Tier-2 fidelity envelope.
The four-tier client ladder#
The ladder is a strict descent: each rung is reached only when the rung above is unavailable, and the surface picker walks it as a fixed sequence (override → native → pixel-streaming → fallback → static).
| Tier | Surface | Where it renders | Real code anchor | Fidelity |
|---|---|---|---|---|
| 1 | Native UE5 | Local GPU, installed binary | V3/ue/V3.uproject (17 modules, 11 cook profiles) |
Full UE5 (Lumen + Nanite) |
| 1 | Pixel Streaming | Server GPU worker → WebRTC frames | @oshun/lilith-web-pxstream (player.ts) |
Streamed UE5 (1080p H.264 / AV1) |
| 2 | WebGPU/WebGL2 fallback | Local GPU via three.js | @oshun/lilith-engine-web-fallback (renderer.ts) |
Reduced (baked light, no Nanite) |
| 0 | Static landing | No 3D at all | buildFallbackDecision → static-landing |
Refuses to fake a renderer |
Tier 1 — the canonical UE5 client, native and server-side#
Tier 1 is the single V3/ue/ project cooked eleven ways. A native install on a
capable device (the router's override: 'native' or an installed-client probe)
emits a native-deep-link target (oshun://lilith/launch?...) and carries no
backend GPU cost — the device renders locally at lowest latency and highest
fidelity. The same binary, compiled headless by
V3PixelStreamingWorker.Target.cs as a monolithic shipping-with-logging target,
is what a GPU worker runs to serve the browser: it renders frames server-side
and forwards multiplayer packets to the world server, so a browser attendee is —
from the world server's perspective — just another Tier-1 client. The module
split, cook-profile registry, and worker target are the subject of
../architecture/tier1-ue5-client.md.
The Pixel Streaming browser tier#
player.ts is the real Epic binding, and it is not a stub.
createLilithPixelStreamingClient builds a LilithPixelStreamingClient that
matches a worker (POSTing a launch request whose result is validated by
parseLilithPixelStreamingWorkerMatch, which rejects any codec that is not
H264 or AV1), constructs the Epic PixelStreaming client with a per-mode
codec/bitrate envelope, mounts the returned video, and arms an 8,000 ms
first-frame budget timer that fails the session loudly if no frame arrives in
time (defaultFirstFrameBudgetMs = 8000). The three streaming modes are the
bitrate envelope the relay's POP selection honours:
| Mode | Preferred codec | Max bitrate | Use |
|---|---|---|---|
adaptive |
auto (worker) | 12 Mbps | Default; auto-codec |
low-latency |
H.264 | 6 Mbps | Constrained uplink, latency-sensitive |
quality |
AV1 | 20 Mbps | Modern Chrome/Edge, max fidelity |
On videoInitialized the client records firstFrameMs and fires an input
round-trip probe (oshun.v3.inputProbe → inputProbeAck) over the Epic UI
interaction channel so the surface can prove the data channel is clean, not just
that pixels arrived. Beyond the player, the package descriptor (index.ts)
advertises twelve capabilities — session-match:8ms-budget,
webrtc-offer:16ms-budget, input-channel:24ms-budget, and nine accessibility
control channels (reduced motion, color-vision palettes, one-handed mobile,
photosensitive-safe strobe attenuation, flat-stereo, cognitive-load reduction,
keyboard/single-switch, launch localization, avatar-anchored captions). The
honest boundary: the worker process and Epic signaller the client negotiates
with are external fleet — what ships here is the client shell and its match
call.
Tier 2 — the no-one-locked-out local renderer#
When the device can neither install the native client nor sustain a Pixel
Streaming WebRTC session — old laptops, locked-down machines, cellular tablets,
Pixel-Streaming-restricted regions, or an explicit "Lite mode" — the picker
emits a fallback-browser target and the browser renders the world locally
through three.js. selectLilithFallbackRendererBackend (renderer.ts:176)
prefers WebGPU, bridges to a webgl2-webgpu-fallback budget when WebGPU init
fails, and throws when WebGL2 is absent rather than presenting a blank
canvas. Two frozen budget profiles drive everything downstream
(renderer.ts:142, mirrored in steadyFps.ts):
| Backend | Target FPS | Max render cost | Pixel-ratio cap |
|---|---|---|---|
| webgpu | 60 | 16.7 ms | 1.5 |
| webgl2 | 30 | 33.4 ms | 1.0 |
A WebGPU-requested device that quietly fell back is held to the webgl2 30
fps budget, not 60 — the budget follows the real backend, not the request. The
validateLilithTier2SteadyFpsSample gate checks a 60-minute sustained
sample against eight conditions (min and average FPS at target, p95 and p99
frame time within budget, dropped-frame rate ≤ 0.5 %, no thermal throttle), each
failure a named reason. The static-landing floor is the picker's last resort: if
the browser reports neither WebGPU nor WebGL2, it returns static-landing with
local-rendering-unsupported — it refuses to claim a runnable fallback it
cannot deliver.
The Tier-2 fidelity envelope (known gaps from canonical)#
The Tier-2 fallback is the no-one-locked-out path, not a feature-parity
mirror of Tier 1. The table below is the customer/operator-facing envelope;
the columns are grounded in parity.ts, which enumerates exactly four runtime
profiles selected by selectLilithTier2TenantParityProfile.
| Feature | Tier-1 (UE5) | Tier-2 (three.js) |
|---|---|---|
| Global illumination | Lumen real-time GI | Pre-baked lightmap atlases (3 for Commons, 4 for a Saraswati hall) |
| Geometry detail | Nanite virtualized geometry | LOD chains; ≤ 80 K triangles/avatar; ≤ 30 MB venue load budget |
| Niagara stage VFX | Full Sequencer-driven Niagara | Reduced particles (96 Commons) / cue sprites (≤ 384 hall) |
| Aja AR overlays | World-space UMG anchored to student | HUD text + 9-joint 2D pose guide; no world-space anchoring |
| Camera cinematics | Sequencer-driven concert cameras | Cue-event-driven cameras with simplified blending |
| Stadium interactivity | Front + mezzanine Pixel Streaming | Crowd-band Yemaya LL‑HLS preview + thin spatial seat audio |
What Tier-2 actually substitutes — the four parity profiles#
lilithTier2TenantParityProfiles (parity.ts:72) is real data, not prose.
Three of the four profiles are parityLevel: 'full' — the room is fully
reachable at reduced fidelity — and only the stadium tier is degraded-preview:
commons-venue(full): Lumen GI → 3 baked lightmap atlases at a 30 MB venue budget; full Niagara → a 96-particlecommons-soft-presenceshader.tara-live-class(full): world-space UMG Aja cues → HUD text plus a 9-joint screen-space pose guide (poseGuideOverlay.jointCount: 9,targetPose: 'warrior-two'), gated separately byvalidateAjaTier2CueUxReportfor instructor + editorial sign-off, 80 % cohort clarity, WCAG-AA contrast, and no world-space anchoring.saraswati-hall-concert(full): Sequencer Niagara → cue-event sprites capped at 384 particles across 4 baked stage atlases.saraswati-stadium-preview(degraded-preview): the interactive stage collapses to a Yemaya LL‑HLS crowd-band video proxy plus thin seat audio.
Each profile carries a telemetrySignature, and the four join into one
load-bearing parity string
(tara:aja-hud:pose9|commons:baked-lightmap3:niagara96|saraswati:hall:cue-particles384|saraswati:stadium:yemaya-llhls-crowd)
that the Playwright suite hard-asserts, so bake and runtime cannot drift apart
without the signature diverging. Tara live classes, Tara on-demand, all Lilith
Commons venues, and Saraswati class- and hall-tier concerts run end-to-end on
Tier 2 with these caveats; only stadium-scale concert interactivity is
genuinely reduced.
The honest gaps — what Tier-2 does not yet do#
Three monolith claims are not backed by this codebase, and a feature page that pretended otherwise would be lying to operators planning the envelope:
- VRM avatar rasterization at "≤ 80 K triangles" is planned, not present.
There is no
GLTFLoader, no VRM importer, and no.glb/.vrmload path in the fallback app or engine — a grep returns zero. Avatars in the running scene are procedural primitive clusters (createAvatarCluster,renderer.ts:506: a capsule body plus a sphere head). The 80 K cap exists only as a constant inside the bake planner; nothing at runtime loads a baked avatar mesh today. - The "same multiplayer Protobuf packets, full presence/voice parity" claim
does not hold for Tier-2 yet. The protocol library exists
(
libs/v3/multiplayer-protocol) but the fallback does not import it (grep returns zero).gateway.tsselects a transport (WebTransport over QUIC preferred, WebRTC data channel as fallback) and then returns a simulated handshake with a hard-coded RTT (24 ms / 42 ms) — a "linked" badge, not a live socket. The Rapier prop-pickup prediction inphysics.tsis a deterministic reconciliation scenario (two parallel Rapier worlds), not a live netcode loop. - The asset bake emits placeholders, not binaries. The nightly bake is a real, gated planning and manifest stage — it decides LODs, byte budgets, S3 layout, and render feasibility, and proves all of it with a passing validator — but it writes Draco/KTX2/Opus artifacts as byte-budgeted text placeholders rather than invoking real transcoders.
These are the same gaps the architecture companion documents at ../architecture/tier2-fallback-web-client.md; they are honest seams in a real renderer, not stubs masquerading as features.
The stadium envelope — banding 4,096 across the ladder#
A Saraswati Stadium-tier concert targets up to 4,096 attendees per instance, which no single render path can serve interactively. The product answer is a banded seating model that routes each attendee to the render path matching their ticket and device — and it is worth being precise that the banding is a product model layered over what the code actually enforces, not a code struct of its own.
| Band | Capacity | Render path | Ticket gate |
|---|---|---|---|
| Front 256 (named) | 256 | Native UE or dedicated Pixel Streaming worker | Front-row tier |
| Mezzanine (next 768) | 768 | Pixel Streaming worker, 1080p lower-fidelity preset | Mezzanine tier |
| Crowd band (remainder) | 3,072 | Yemaya LL‑HLS multicast + thin spatial seat + chat/emoji | Crowd tier (free/low-cost) / Tier-2 |
What the code actually enforces#
The banding rests on three real anchors, each at a different layer:
- The 256 front band is the world server's interest cap, not a turnstile.
The authoritative world server distinguishes only two capacity tiers (
Class,Stadium), and they are interest and bandwidth budgets, not head-count admission limits.STADIUM_TIER_VISIBLE_ENTITY_CAP = 256(apps/v3/lilith-world-server/src/lib.rs:507) is the number of nearest entities anrstarR-tree hands each viewer, pinned bystadium_interest_load_test_confirms_256_entity_cap(lib.rs:5783), which assertsvisible.len() == 256. So the front 256 get authoritative transforms; everyone beyond the cap is rendered as the crowd shader, which is exactly why a 4,096-attendee room stays bandwidth-bounded (the world-rooms page covers the interest model in depth). - The mezzanine/front Pixel Streaming cost is a real per-node budget. The
relay's fleet config carries
class_sessions_per_gpu_node: 6andstadium_sessions_per_gpu_node: 3(apps/v3/lilith-pxstream-relay/src/lib.rs:437), so a stadium concert's interactive bands are explicitly the most expensive sessions a GPU node hosts — three per 24-vCPU node — which is why the front/mezzanine bands are ticket-gated and capacity-reserved rather than free walk-ups. - The crowd band is the Tier-2 stadium-preview profile. The
saraswati-stadium-previewparity profile (parity.ts:260) is the crowd band's render path:mode: 'yemaya-ll-hls',crowdBand: true, and aproxyUrlthat is honestly a placeholder endpoint (https://stream.oshun.example/...m3u8), not a live stream. A Tier-2 attendee defaults here; a native attendee with a front/mezzanine ticket renders locally.
The contract vocabulary, for completeness, is a third naming: V3CapacityTier
Schema = ['class', 'salon', 'theater', 'stadium']
(libs/contracts/src/v3/primitives.ts:20). The "front 256 / mezzanine 768 /
crowd 3,072" split itself is a product target met by these three mechanisms — it
is not a single banding struct any one service owns.
Capacity, prewarm, and the tight-quota POPs#
Because a stadium concert lights up 4,096 attendees at a scheduled minute, the
relay treats it as a capacity event rather than organic load. A scheduled
concert (event_id: "concert:saraswati:full-moon-stadium" in the relay)
pre-warms a POP worker pool ahead of doors, and the abuse-admission layer routes
free-tier latecomers to the Tier-2 crowd band when the reserved interactive pool
is full (Fallback → HTTP 503 with a banner) rather than starving a booked
attendee. The hardest regions are codified: ap-south-1 and ap-northeast-1
are tight-quota POPs requiring a 90-day reservation lead for scheduled
events (versus 30 days elsewhere) and a 60-minute prewarm window for stadium
events. Capacity beyond 4,096 is handled by spinning up additional concert
instances with a shared persona and setlist but separate audiences. The full
POP-scoring, admission, prewarm, and multi-cloud-failover machinery — and the
honest caveat that those drills run on deterministic simulate_* fixtures, not
a live cloud cut-over — is the subject of
../architecture/tier-routing-and-pixel-streaming.md.
Where this connects#
- ./product-surfaces.md — the surface-by-surface orientation: the UE5 client, the two web tiers, Studio, and the operator console, each named to its real code.
- ./world-rooms-presence-multiplayer.md — the world/room/capacity model the tiers render, including the 256-entity interest cap the stadium front band rides on.
- ../architecture/tier-routing-and-pixel-streaming.md — the architecture companion: the typed launch contract, the two-layer router, the relay matchmaker, and the stadium capacity/prewarm model.
- ../architecture/tier1-ue5-client.md and ../architecture/tier2-fallback-web-client.md — the two render surfaces routing chooses between, in engineering depth.
- The feature hub: ../V3_features.md.