V3 is not one world — it is three (Tara Studio, Saraswati Stage, Lilith Commons) plus eighteen room modes on top of them, and every one of those has to be something a non-engineer can author, build into the client, ship to a region (or deliberately not ship to one), and hot-load at runtime without an engine rebuild. That is the job of this layer: the tenant packaging model that turns each world into a Game Feature Plugin, the authoring split that lets a yoga instructor publish an entire class from a web form while an environment artist builds the venue in UE5, and the content pipeline that reconciles those two very different source-control worlds (git for code and machine-generated assets, Perforce for binary art). It is the least cinematic and most load-bearing layer in the product — a tenant that won't register is a world nobody can enter, and a design token that drifts is a button that looks wrong in every room at once. The section hub is ../V3_ARCHITECTURE.md.
What ships, honestly#
The tenant packaging and the authoring tooling are real, committed, and machine-verified; the art those tenants point at, and the cross-region publish machinery, are partly spec. Three honest layers:
- Real and committed. All 21 Game Feature Plugins exist as genuine
*.uplugindescriptors underV3/ue/Plugins/GameFeatures/(3 tenants + 18 modes), each with a JSON registration manifest. Two real UE commandlets in theV3Editormodule build and smoke-test them: one materializes aGameFeatureData.uasset+ a gameplay-tag.inifrom each manifest, the other drives the liveUGameFeaturesSubsystemthrough an Active→Registered load/unload transition per plugin. The 21 generatedGameFeatureData.uassetfiles are committed (a deliberate.gitignoreexception). The web authoring surface is real Next.js (apps/oshun/web/src/app/lilith-studio/, 9 routes) backed by a substantial domain library (libs/v3/tara-studio, 19 domain modules incl. a 300-entry canonical asana library with a GA gate) and a Prisma schema. Design tokens cross the web↔UE boundary through a third commandlet with a drift-checking verify pass and ~107 committed token assets. - Manifest references real, art not committed. The registration manifests
name ability-set, world-template, Niagara, and MetaSound asset paths
(
/Game/Venues/Tara/...,/Game/VFX/Tara/NS_BreathCue). Those target assets are not in the tree — they are the UE-Editor art surface that, per the monolith, lives in Perforce, not git. There is no committed Perforce config (p4ignore/typemap) here either: Perforce is the documented posture, not a checked-in artifact. - Spec / described in the monolith. The nightly fallback bake (UE cooked
assets → glTF + KTX2 + lightmaps for Tier-2), the V1 environment-promotion
publish gates, and the world server's 30 s asset-manifest hot-reload poll
are described in
V3_ARCHITECTURE.mdand have no standalone committed job in this tree. Andlibs/v3/isis-world-assetis a capability descriptor, not a runtime world-asset generator — see the closing note.
Tenants are Game Feature Plugins#
V3 ships every world and every room mode as an Unreal Game Feature Plugin so
the build pipeline can include or exclude one per platform/region without an
engine rebuild, and so the client can hot-load it on demand. The 21 descriptors
live under V3/ue/Plugins/GameFeatures/:
- 3 tenants —
V3Tenant_TaraStudio,V3Tenant_SaraswatiStage,V3Tenant_LilithCommons. - 18 modes — four Tara room modes (
V3Mode_TaraLiveClass,…TaraOnDemand,…TaraPrivate,…TaraCohort), five Saraswati event modes (Concert / Club / Listening / Drop / Festival), and nine Commons venues (Atrium, Observatory, Debate Hall, Lecture Hall, Stacks, Ritual Room, Arête Atrium, Lantern Hall, Solitary Cell).
Every descriptor follows the same load contract — verified across all 21 — for
example V3Tenant_TaraStudio/V3Tenant_TaraStudio.uplugin:
"EnabledByDefault": false,
"ExplicitlyLoaded": true,
"BuiltInInitialFeatureState": "Registered",
"CanContainContent": true,
"Plugins": [ { "Name": "GameFeatures" }, { "Name": "ModularGameplay" } ]
EnabledByDefault: false + ExplicitlyLoaded: true is what makes a tenant
hot-loadable rather than always-resident;
BuiltInInitialFeatureState: "Registered" means it is known to the subsystem
but not Active until a client joins a room that needs it. The monolith's
tenant/mode table (V3_ARCHITECTURE.md §"Game Feature Plugins (Tenants)") maps
each plugin to its activation rule — tenants active globally at GA, modes loaded
on room join.
What a registration manifest carries#
Each plugin keeps a co-located JSON manifest at
Content/<Plugin>/GameFeatureData_<Plugin>.v3asset.json. It is the declarative
source of truth for what the plugin registers:
{ "assetType": "GameFeatureData", "plugin": "V3Tenant_TaraStudio",
"activation": "global-ga",
"gameplayTags": ["Tenant.TaraStudio", "Tara.Class.Live", ...],
"abilitySets": ["/Game/Tara/Abilities/AS_TaraInstructorControls", ...],
"worldTemplates":["/Game/Venues/Tara/WBP_TaraStudioTemplate", ...],
"niagaraSystems":["/Game/VFX/Tara/NS_BreathCue", ...],
"metaSounds": ["/Game/Audio/Tara/MS_TaraPracticeBed", ...] }
This is the honest seam noted above: the manifest is real and committed, but the asset paths it names point at art that is authored in UE and stored in Perforce — they are not in this repository. The manifest is the contract; the art is the fulfillment, and the two are versioned in different systems on purpose.
The commandlets that build and verify them#
Two real commandlets in V3/ue/Source/V3Editor/Private/ turn the manifests into
engine state. UV3GenerateGameFeatureDataCommandlet::Main
(V3GenerateGameFeatureDataCommandlet.cpp:251) walks every *.uplugin under
the GameFeatures folder, and for each one: reads + validates the manifest
(ReadManifest, rejecting empty arrays and a plugin-name mismatch against the
descriptor), writes a Config/Tags/<Plugin>Tags.ini gameplay-tag file
(SaveGameplayTagsFile), then materializes a GameFeatureData.uasset via
UV3GameFeatureData::ConfigureFromManifest followed by a
HasCompleteRegistrationManifest completeness gate before SavePackage
(SaveGameFeatureDataAsset). It is idempotent — AssetMatchesManifest short-
circuits a re-save when the existing asset already matches.
UV3GameFeatureData itself (V3/ue/Source/V3Core/Public/V3GameFeatureData.h)
is a real subclass of the engine's UGameFeatureData, carrying FeatureName,
ActivationPolicy, RegisteredGameplayTags, AbilitySetIds,
WorldTemplateIds, NiagaraSystemIds, and MetaSoundIds, and overriding
GetPrimaryAssetId() so the plugin participates in the asset-manager
primary-asset graph.
The verifier earns the most trust. UV3VerifyGameFeaturesCommandlet::Main
(V3VerifyGameFeaturesCommandlet.cpp:69) does not read files — it exercises the
live engine. For each plugin it calls
UGameFeaturesSubsystem::Get().ChangeGameFeatureTargetState(..., EGameFeatureTargetState::Active),
asserts IsGameFeaturePluginActive, then transitions back to Registered, with
a 10-second timeout (V3GameFeatureTransitionTimeoutSeconds) that ticks
GEditor while it waits. A plugin that can't actually load and unload fails the
commandlet. The 21 resulting GameFeatureData.uasset files are committed via an
explicit .gitignore exception (.gitignore:327 whitelists
V3/ue/Plugins/GameFeatures/**/Content/GameFeatureData.uasset against the
global *.uasset ignore).
Install-first vs. streamed: pak chunking#
Because tenants are plugins, the install can be partitioned along the same
boundary. The chunk plan lives at
V3/ue/Build/Chunking/tenant-mode-pak-chunking.json with a typed mirror at
libs/oshun/analytics/src/v3-tenant-mode-pak-chunking.ts, and is enforced by
scripts/v3/verify-v3-tenant-mode-chunking.mjs
(pnpm verify:v3 tenant-mode-chunking). The install-first set is the base shell
plus the Tara tenant and its four room modes (pakchunk0-base-shell,
pakchunk20-tenant-tara-studio, pakchunk21..24 for the Tara modes), while
V3Tenant_LilithCommons and V3Tenant_SaraswatiStage are streamed chunks
pulled on demand — so a mobile install (ios-forward-plus,
android-vulkan-forward-plus) ships small and grows only when a user actually
enters Commons or a concert. This is the build-side payoff of the plugin model:
the same boundary that hot-loads a world at runtime also slices the download.
The authoring split: web Studio vs. UE Editor#
Most V3 creators — Tara instructors, Saraswati editorial staff, partner studios — do not have UE5 installed and shouldn't need to. Lilith Studio therefore exposes authoring on two surfaces with a hard split, documented exhaustively in the monolith's "Lilith Studio Authoring Split" table. The rule of thumb: anything that is data (sequences, schedules, metadata, consent, taxonomy) is authored on the web; anything that is art or cinematics (venue levels, Niagara, camera cue tracks, MetaHumans) is authored in UE Editor.
What the web owns#
The web surface is a real Next.js app section at
apps/oshun/web/src/app/lilith-studio/, with nine committed page.tsx routes:
the Studio shell (page.tsx) plus asana/, tara/, concerts/, music/,
personas/, scenes/, avatar-costume/, and provenance/. A Tara instructor
authoring an AsanaSequence, their profile, schedule, and recording opt-in
never leaves these routes; the UE5 client renders the published sequence
manifest at runtime, so no UE install is required of the instructor.
The depth behind the web routes lives in libs/v3/tara-studio (19 domain
modules, not CRUD). The flagship is the canonical asana library
(src/asana-library.ts): 30 asana seeds crossed with 10 lineage variants
(Iyengar, Ashtanga, viniyoga, trauma-informed, chair-accessible, …) produce the
TARA_CANONICAL_ASANA_LIBRARY, and validateTaraCanonicalAsanaLibrary
(asana-library.ts:240) enforces a real GA gate — ≥ 300 entries, every entry
editorial-signed and design-approved, with ≥3 lineage variants, ≥4
modifications, ≥2 contraindications, and a ≥3-cue Aja alignment bundle — failing
loudly via assertTaraCanonicalAsanaLibraryGaGate. Alongside it sit
domain-specific guardrails: instructor TTS-voice consent/contract/scope-lock/
royalty (tts-voice-*.ts), Sophia-grounded lineage claims
(lineage-grounding.ts), an invitational-language linter, an eyes-open default,
and physical-adjustment explicit consent — each a published web-authorable
policy the UE client honors, not engine code. The capability ledger in
libs/v3/tara-studio/src/index.ts enumerates all 22 of these as a typed
V3PackageDescriptor.
Persistence is contract-first. libs/v3/tara-studio/prisma/schema.prisma
(machine- generated by
libs/oshun/persistence/scripts/generate-v3-prisma-schemas.ts) defines
V3AsanaSequence, V3Asana, V3LiveClassSession, V3OnDemandClassRecording,
V3InstructorProfile, V3InstructorCredential, V3AjaCueEvent, and
V3PracticePlan — each row carrying contractSchema, contractVersion, and a
payloadHash, mapped to a v3_* table. That hash column is the integrity
spine: a published manifest's bytes are provable against its row, so the JSON
the UE client pulls is the same artifact the web authored.
What the UE Editor owns#
The UE-side authoring tooling is the V3Editor module
(V3/ue/Source/V3Editor/). Its Build.cs declares the editor-only dependency
set that gives it teeth — AssetRegistry, Blutility, GameFeatures, Json,
UnrealEd, UMG, and the V3 runtime modules V3Core/V3UI. Beyond the two
Game-Feature commandlets above, it contributes the design-token toolchain and an
in-editor UV3DesignTokenReloaderWidget (an EditorUtilityWidget exposing
ReloadGeneratedTokens with LastReloadHash / LastAppliedWidgetCount
readback, so an artist can re-apply token changes live without restarting).
Venue levels, Niagara systems, MetaHumans, and Sequencer cue tracks are authored
here in the conventional UE workflows; as noted, their assets are
Perforce-resident and not in this tree.
Design tokens: the one bridge that crosses both#
Design tokens are where the two surfaces must agree, and the only place an
asset is generated from data on the UE side. The web emits a manifest to
V3/ue/Content/UI/DesignTokens/Generated/manifest.json — brushes, font faces,
and widget styles keyed by theme/role (e.g. dark.accent.primary → #20C9D8),
plus a top-level driftHash. UV3GenerateDesignTokenAssetsCommandlet
(V3GenerateDesignTokenAssetsCommandlet.cpp:817) parses it and materializes
real Slate assets — USlateBrushAsset (via FSlateRoundedBoxBrush),
UFontFace (loading the actual fallback font bytes off disk), and
USlateWidgetStyleAsset containers for
TextBlock/Button/EditableTextBox/ProgressBar styles — converting sRGB hex to
linear color and saving each package. The companion
UV3VerifyDesignTokensCommandlet (:839) runs the same pass with
bWriteAssets=false, so any asset that has drifted from the manifest fails
verification instead of being silently rewritten (RunDesignTokenAssetPass
returns the drift error per token). ~107 generated token .uassets are
committed under a second .gitignore exception (.gitignore:328), and the loop
is gated by pnpm verify:v3 ue-design-tokens. This is the inverse of the asana
flow — there, data renders at runtime; here, data is baked into engine assets
at build time — but both keep web and UE reading one canonical source.
Content pipeline and source control#
The pipeline reconciles two source-control worlds. Per the monolith's "Content
Pipeline and Source Control" section, UE binary assets (*.uasset, *.umap)
under V3/ue/Content/ are versioned in Perforce (binary support, per-file
locking — standard UE practice), while code modules (V3/ue/Source/) and the
Game Feature plugins (V3/ue/Plugins/) live in git alongside the monorepo
and are mirrored to Perforce for coexistence. The repository's .gitignore
encodes exactly this division: it ignores *.uasset/*.umap globally
(.gitignore:324-325) and then whitelists only the two machine-generated
classes — the 21 GameFeatureData.uasset registration assets and the design-
token assets. So git holds the deterministic, regenerable assets (and proves
them in CI); Perforce holds the hand-authored art. That is the honest shape of
"source control" here: the git side is complete and checked in; the Perforce
side is a documented posture with no committed config in this tree.
The downstream stages are described but not separately committed as jobs: publish flows from the UE Editor through V1's Studio editorial workflow (staging → preview → prod via environment-promotion gates); hot-reload has UE clients pick up Game Feature plugins on a release cadence while the world server polls the asset manifest every 30 s to invalidate fallback bakes; and the nightly fallback bake consumes UE cooked assets to emit glTF + KTX2 + lightmap variants for the Tier-2 fallback client. Treat those three as architecture intent backed by the monolith, not as code you can point at here.
Where authored content actually meets a client is the launch BFF. The routes
apps/lilith/bff/src/routes/v3-lilith-launch-route.ts and
v3-lilith-launch-distribution.ts (with openapi/v3-lilith-launch.openapi.yaml
and an integration test under src/__tests__/integration/) implement
POST /api/v3/lilith/launch, returning a launch decision — native, Pixel
Streaming, Tier-2 fallback, or static landing — with decisionId,
selectedSurface, reasonCodes, and a telemetry-ready LilithLaunchDecision
event (documented at V3/api/v3-bff-openapi.md). The router that decides which
surface gets the authored world is covered in
Tier 1 UE5 Client and the tier-routing page; this page
ends at the point the right tenant plugin is selected for delivery.
Edge cases & connections#
- Descriptor ≠ generator.
libs/v3/isis-world-asset/src/index.tslooks like a world-asset pipeline but is a typed capability descriptor — three keys (asset-brief,lod-budget,provenance) with operational-metric strings and av3IsisWorldAssetReadinessScorethat ratios matched capabilities. It is the readiness contract for Isis world-asset generation, not the generation runtime; do not cite it as proof that V3 procedurally builds venues. - Tenancy and isolation continue elsewhere. Tenants here are the UE
packaging units. Their RBAC, row-level
tenant_idscoping, room-boundary segregation, and residency rules are modeled in V1's tenancy graph and are detailed in the monolith's "Data Architecture and Tenancy" section and the planned sibling Data, Tenancy & Residency. A tenant plugin and a tenant realm are deliberately the same word for the same world, split across two layers. - Why the split is the source of truth. The authoring table exists precisely so a new surface can't be added ambiguously — every authoring task has exactly one primary surface and one persistence format, which is what keeps "who edits what where" from forking between web and UE teams during a cross-team handoff (e.g. Saraswati editorial proposing a setlist on the web, the concert-production team assembling Sequencer cue tracks in UE).
- Platform context. The domain libraries cited here
(
@oshun/tenant-tara-studio,@oshun/isis-world-asset, and the V1 substrate they compose over) are catalogued in Oshun Domain Libraries.
Together these three mechanisms — plugin packaging, the web/UE authoring split, and the git/Perforce pipeline — are what let V3 treat a "world" as a unit that can be authored by non-engineers, built into a chunked install, shipped or withheld per region, and hot-loaded into a live room, all without an engine rebuild.