Lilith Metaverse · Architecture

Authoring, Tenants & Content Pipeline

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

5sections12 minread1diagram

On this page

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 *.uplugin descriptors under V3/ue/Plugins/GameFeatures/ (3 tenants + 18 modes), each with a JSON registration manifest. Two real UE commandlets in the V3Editor module build and smoke-test them: one materializes a GameFeatureData.uasset + a gameplay-tag .ini from each manifest, the other drives the live UGameFeaturesSubsystem through an Active→Registered load/unload transition per plugin. The 21 generated GameFeatureData.uasset files are committed (a deliberate .gitignore exception). 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.md and have no standalone committed job in this tree. And libs/v3/isis-world-asset is 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 tenantsV3Tenant_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:

json
"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:

json
{ "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).

flowchart LR manifest["GameFeatureData_*.v3asset.json<br/><sub>declarative manifest (git)</sub>"] gen["UV3GenerateGameFeatureDataCommandlet<br/><sub>V3Editor</sub>"] tags["&lt;Plugin&gt;Tags.ini<br/><sub>gameplay tags</sub>"] asset["GameFeatureData.uasset<br/><sub>committed (gitignore exception)</sub>"] verify["UV3VerifyGameFeaturesCommandlet<br/><sub>Active → Registered, live subsystem</sub>"] manifest --> gen gen --> tags gen -->|ConfigureFromManifest +<br/>HasCompleteRegistrationManifest| asset asset --> verify verify -->|"load/unload smoke test (10s)"| ok([green])

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.ts looks like a world-asset pipeline but is a typed capability descriptor — three keys (asset-brief, lod-budget, provenance) with operational-metric strings and a v3IsisWorldAssetReadinessScore that 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_id scoping, 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.