Descent VTT - Enterprise Architecture Blueprints
Descent VTT - Enterprise Architecture Blueprints
This document provides visual Mermaid diagrams detailing the intricate systems designed in the Descent VTT Enterprise System Architecture Whitepaper.
1. High-Level Network Topology
This diagram illustrates the macro-level interaction between the Client, the .NET Backend, and the WebRTC SFU, highlighting the separation of authoritative state and high-frequency ephemeral data.
Updated 2026-08-01 (R2-F-44). Corrections:
Redis→ Garnet (§10.1(3) “completely replaces Redis”);Azure/AWS→ ACA primary + Cloud Run reduced-capacity DR (§10.1 explicitly rejects AWS ECS Fargate for lacking HTTP scale-to-zero); added the three always-on components the previous diagram omitted — theDescent.Vtt.Worker(ADR-043, and a line item in the §10.2 floor), the Asset Baking Jobs pool (§6.2), and the Edge Fetch Service (ADR-039, deliberately outside the VNet); and replaced the generic gateway fan-in with targeted per-silo forwarding (ADR-032 supersedes ADR-024’s sticky-ingress assumption). An IaC build from the previous diagram would have produced an environment with Redis and no projection worker — which is ADR-021’s “zero instances” failure mode, whose symptom is silently stale search and sheets.
graph TD subgraph Clients["Player Clients (Browser/PWA)"] C1[Client 1: Game Master] C2[Client 2: Player] C3[Client 3: Player] end
subgraph Backend["Azure Container Apps Environment (PRIMARY)"] API["Ingress<br/>room-affine when possible<br/>NOT load-bearing - ADR-032"] subgraph Orleans["Microsoft Orleans silos (same image, scaled out)"] RG1((RoomGrain A = T0)) RG2((RoomGrain B = T0)) Disp["Silo-local dispatcher<br/>targeted forwarding - ADR-032"] end Worker["Descent.Vtt.Worker<br/>minReplicas 2 · sharded leases<br/>ADR-043 · §10.2 floor"] Cache[("Microsoft Garnet<br/>minReplicas 1 · NOT load-bearing<br/>seq+shape-validated T1 copies - ADR-058")] end
subgraph Data["Neon PostgreSQL 18"] DBc[("clustering branch<br/>autosuspend DISABLED<br/>room-liveness signal - ADR-049")] DBe[("campaign branch<br/>hash room_id partitions - ADR-036<br/>awake only while rooms active")] end
subgraph Jobs["Event-driven ACA Jobs (KEDA)"] Bake["Asset Baking · §6.2<br/>+ quarantined decode - ADR-039<br/>+ campaign export - ADR-059"] end
Fetch["Edge Fetch Service<br/>NO identity · NO VNet route<br/>tier P4 - ADR-039"]
subgraph Edge["LiveKit SFU Edge Network"] SFU[LiveKit Server] end
Clients <==>|"SignalR / WebSockets (authoritative + CQRS)"| API API <--> Orleans Disp -.->|"one message per involved silo, per tick"| Disp Orleans <--> Data Orleans <--> Cache Worker <--> Data Jobs <--> Data
C1 <..>|"WebRTC DataChannel (adaptive ≤60Hz)"| SFU C2 <..>|"WebRTC DataChannel"| SFU C3 <..>|"WebRTC DataChannel"| SFU C1 <..>|WebRTC Audio/Video| SFU C1 -.->|"pasted URL → async import"| Fetch
style Backend fill:#1e1e2f,stroke:#4a4a6a style Data fill:#1a365d,stroke:#2c5282 style Jobs fill:#22543d,stroke:#38a169 style Edge fill:#2d3748,stroke:#4a5568 style Orleans fill:#2a1b4d,stroke:#553c9a style Fetch fill:#742a2a,stroke:#9b2c2cTwo things this diagram deliberately shows that the previous one hid: the always-on floor is not empty (Worker ×2, Garnet, the clustering branch, LiveKit baseline, observability, R2 storage, Edge Fetch — §10.2), and the Edge Fetch Service sits outside the VNet with no platform identity, because a user-driven server-side fetcher inside the VNet is an SSRF path to Garnet and the cloud metadata endpoint (ADR-039).
2. Frontend 5-Tier Worker Topology
Redrawn 2026-08-01 (R2-F-43). The previous diagram was titled “Strict 3-Tier” and placed networking on the Main Thread. §9.6.1 of the whitepaper describes a 4-tier topology and names the old arrangement as a fixed defect: “Networking was previously assigned to the Main Thread, which put a 20Hz snapshot ingest behind every DOM update — a 200ms UI stall would drain the interpolation buffer and freeze the 3D world while the UI looked healthy, misdirecting diagnosis at the GPU.” ADR-053 then adds the fifth edge. It also drew two mechanisms the whitepaper states do not exist:
Sandbox -.-> |Read-Only View| SAB— Guardrail 5: “The platform has no read-onlySharedArrayBufferprimitive … and QuickJS/WASM cannot address memory outside its own linear memory in any case.” The plugin worker receives a copy into the QuickJS heap.MainThread <=== |Reactive Signals| SAB— Guardrail 2: “a worker’s write into aSharedArrayBufferfires no notification and cannot trigger a signal.” Exactly onerequestAnimationFramelatch reads the published block.Further changes: the Streaming Worker (§9.6.1’s OPFS / crypto / transcode tier) was missing entirely; WebGPU compute is visual upsampling of the authoritative mask only (§9.2, ADR-017, ADR-034) and never a producer of FOW/LOS; the Logic Pool is now a single geometry worker with private linear memory and the shared arena is retired (ADR-052); and the caption’s “144 FPS / 0-Byte GC” claims are scoped per §9.1 rather than asserted.
The Network Worker is the single arrival authority and fans out to two consumers: DOM-facing authoritative state goes straight to the Main Thread, world state goes to the Render Worker’s ECS (ADR-053). This is what makes Guardrail 1’s Recovering state achievable — chat and sheets keep updating while the canvas is dark and the Render Worker rebuilds GPU state.
graph LR subgraph MainThread["Main Thread (UI & Input only)"] Latch[Single rAF Latch<br/>Guardrail 2 - two sources] Solid[SolidJS DOM Engine] Router[Render Router] Latch --> Solid end
subgraph WebWorkers["Background Web Workers"] NetWorker["Network Worker<br/>SignalR transport + protocol decode<br/>ARRIVAL AUTHORITY - ADR-053"]
subgraph RenderWorker["Render Worker (GPU, OffscreenCanvas)"] Babylon[Babylon.js + Frontend ECS] WebGPU["WebGPU Compute<br/>visual upsampling of the<br/>authoritative mask ONLY"] Thin[Thin Instances<br/>World UI Atlas] end
Streaming["Streaming Worker<br/>OPFS SyncAccessHandle · AES-GCM<br/>Basis transcode · 512KB chunks"]
Geometry["Geometry Worker (single)<br/>Descent.Geometry WASM<br/>PRIVATE linear memory - ADR-052<br/>own-move A* / collision preview"]
Plugin["Plugin Worker<br/>QuickJS WASM · one runtime per plugin<br/>ADR-040 budgets"] end
SABctl[("SharedArrayBuffer<br/>triple-buffer control + SPSC ring<br/>Render → Main ONLY")]
NetWorker ==>|"(a) DOM-facing authoritative state"| Latch NetWorker ==>|"(b) world state"| Babylon Streaming -->|GPU-ready payloads| Babylon Geometry -->|transferable ArrayBuffer| Babylon Babylon -->|"publish (never blocks)"| SABctl SABctl -->|latched once per frame| Latch
Plugin -->|"per-frame COPY into QuickJS heap"| Plugin NetWorker -.->|"immutable snapshot copy"| Plugin Plugin -- "JSON AST via RPC" --> Router Router -- "Screen-Space" --> Solid Router -- "World-Space" --> Thin
style MainThread fill:#1a202c,stroke:#2d3748 style WebWorkers fill:#2a4365,stroke:#2b6cb0 style RenderWorker fill:#285e61,stroke:#319795 style NetWorker fill:#553c9a,stroke:#805ad5 style Geometry fill:#742a2a,stroke:#9b2c2c style Streaming fill:#22543d,stroke:#38a169 style Plugin fill:#744210,stroke:#b7791f style SABctl fill:#4a5568,stroke:#a0aec0,color:#fffThree rules the diagram encodes, each of which was violated by the previous version:
- The Plugin Worker has no edge to the SAB. It receives a host-published immutable snapshot copied into its heap, which is why plugin functionality is independent of SAB availability (Guardrail 5, Guardrail 7).
- The Render Worker never blocks. No
Atomics.waiton the geometry path; it reads the newest completed mask via a sequence-numbered double buffer (ADR-052). A blocking wait would stall the frame loop while DOM stayed smooth — the same misattributed symptom §9.6.1 warns about for networking. - SAB appears on exactly one hop (Render → Main). Cross-origin isolation is therefore a frame-skew quality lever, not a capability dependency (ADR-052).
3. CQRS Command Queue & Server Reconciliation (Anti-Cheat)
This sequence diagram illustrates the “Optimistic UI with Rubber-banding” pipeline, detailing exactly what happens when a player moves a token, and how the server maintains absolute authority.
Redrawn 2026-08-01 (R2-F-45). The previous sequence omitted three steps that the whitepaper already mandates, and presented rubber-banding as a normal branch:
- No lease acquisition. §5.1.1: “lease acquisition is a Command, not a client assumption”; a client that fails to acquire one does not predict.
- No
DurableSeq. §4.4 gates the ack on the Marten commit; the previous diagram ACKed immediately afterAppend Event, which is the divergence the handshake exists to prevent.- Per-command rollback. Guardrail 4’s Causality Rule: rejecting intent N invalidates every dependent intent as one atomic set — “a rejected move followed by an attack from the vacated square … leaves the client having resolved consequences of a premise the server refused.”
Also: ADR-051 forbids offline spatial intents entirely (a lease cannot be acquired offline, and a long-lived offline lease would freeze the entity for the whole room via snapshot suppression), and ADR-050 replaces the “snaps back” guarantee with no gap, no rewind — the legal path has zero visual displacement, and rubber-banding is reserved for genuine rejection.
sequenceDiagram participant UI as SolidJS (Main Thread) participant Net as Network Worker participant Actor as Orleans RoomGrain (T0) participant DB as PostgreSQL (Marten, T1)
Note over UI, Actor: Online. Spatial actions require a lease (ADR-051 - not queueable offline) UI->>Actor: 1. AcquireLease(entityId) Actor-->>Net: 2. Lease granted - pushed immediately, tick-sequenced (ADR-050) Note over UI: Prediction starts. No lease → no prediction,<br/>server-driven movement instead (§5.1.1)
UI->>Actor: 3. FinalizeMove(leaseId, finalPosition) + causal premise Note over Actor: Validate against T0 + geometry
alt Valid Actor->>Actor: 4a. Update T0, reclaim ownership Actor-->>Net: 5a. Next tick carries authoritative transform (≤1 tick) Note over UI: Cross-fade from last peer value.<br/>Zero visual displacement (ADR-050) Actor->>DB: 6a. Event enters micro-batch DB-->>Actor: 7a. Commit confirmed Actor-->>Net: 8a. DurableSeq advances → action is now final (§4.4) else Rejected (illegal move / lease expired / world mutated) Actor-->>Net: 4b. Reject + dependent-set invalidation (Guardrail 4) Net-->>UI: 5b. ONE review surface:<br/>"these actions could not be applied" Note over UI: Rubber-band is the exception path,<br/>never the steady state (§9.6.4) else Timeline advanced or forked past the premise Actor-->>Net: 4c. Reject whole queue as a batch (Guardrail 4 Timeline Rule) Note over UI: User re-acts against current state.<br/>Rebasing onto an abandoned branch is undefined endNote on the offline case. The previous diagram was labelled “Normal Latency / Offline State” while showing a token move. Under ADR-051, offline eligibility is a fail-closed whitelist of non-spatial actions (sheet edits, inventory, notes, chat, macro authoring); offline movement becomes a local planning layer (TimelineIndependent annotations) presented for execution on reconnect, not a queued Intent.
4. Hybrid UI Rendering Boundary (World-Space vs Screen-Space)
This flowchart visualizes the decision tree of the “Render Router”, explaining how we solve the 500-goblin UI performance bottleneck.
Updated 2026-08-01. Added the registration-time validation gate (§12.2.2): the atlas target renders bitmap glyphs via hardware instancing and cannot host focus, IME text entry, scrolling, or hit-tested sub-regions, so a descriptor placing
ui.TextFieldor a scroll container on a Token target is rejected with a naming error at registration, not silently degraded at runtime. The previous decision tree routed unconditionally and would have quietly dropped interactive affordances a creator asked for. Also noted: Profile C’s DOM path is the one that renders CJK labels correctly (§9.1.1, §9.6.5) — the low-end tier is better at text than the high-end tier, which is worth stating explicitly because it is counter-intuitive.
flowchart TD Start["Plugin / Engine needs to render UI"] --> Check{"Attached to a map token?"}
Check -- "No (HUD, sheet, tracker)" --> RouteDOM["Route to Main Thread SolidJS"] RouteDOM --> SolidRender["Native HTML/Tailwind<br/>full interaction fidelity"]
Check -- "Yes (HP bar, name, status)" --> Validate{"Descriptor uses focus, IME,<br/>scroll, or hit-tested sub-regions?"} Validate -- Yes --> Reject["REJECT at registration - §12.2.2<br/>error names the offending node<br/>creator must re-target to screen-space"] Validate -- No --> RouteGPU["Route to WebGPU texture atlas"]
RouteGPU --> CheckDevice{"Device capability?"} CheckDevice -- "Profile A / B" --> ThinInstance["Hardware Thin Instances<br/>1 draw call · dirty-rect atlas upload<br/>coalesced to once per frame"] CheckDevice -- "Profile C" --> Culling["Distance LOD, cap 50 visible<br/>rendered via DOM<br/>→ correct CJK labels (§9.1.1)"]
ThinInstance --> Glyphs{"Label glyph set?"} Glyphs -- "Digits / Latin / status icons" --> MSDF["MSDF atlas (enumerable set)"] Glyphs -- "CJK / player-authored names" --> Tiered["Distance-tiered raster<br/>near: Canvas2D · mid: cached mip · far: icon+numeric<br/>subset font staged (R2-F-35)"]
style RouteGPU fill:#285e61,stroke:#319795 style RouteDOM fill:#1a202c,stroke:#2d3748 style Reject fill:#742a2a,stroke:#9b2c2c5. UGC Sandboxing (Figma Model / QuickJS)
This diagram details the Zero-Trust Architecture of the community plugin system, demonstrating how third-party code is safely executed without touching the DOM.
Updated 2026-08-01. Three changes. (1) One QuickJS runtime per plugin inside a single engine binary — ADR-040 restates the “Single WASM Engine Rule” as Single Engine, Runtime Per Plugin, because the previous single-runtime model gave plugins no isolation in the time dimension: one plugin’s O(n²) loop over 500 tokens starved every other plugin and the RPC bridge, freezing the GM’s combat tracker, with no way to identify the culprit. (2) Resource budgets were entirely absent from the P2 tier while P1 had a full regime (§4.3); ADR-040 mirrors it. (3) The per-frame state read is a copy (Guardrail 5’s Low-Latency Read Rule) — plugins cannot address memory outside their own linear memory, so there is no shared view to draw. Note also that networking is not on the Main Thread (§9.6.1, ADR-053) and that plugin mutations travel the SDK’s CQRS Command Queue, not a direct socket.
graph TD subgraph Untrusted["Untrusted Community Plugin (tier P2)"] Code["Modder Code (TS/JS)"] SDK["@descent-vtt/sdk"] Code -->|Calls semantic API| SDK end
subgraph Sandbox["Plugin Worker — single engine binary, isolated C-memory"] RT1["QuickJS runtime<br/>plugin A"] RT2["QuickJS runtime<br/>plugin B"] NoDOM["NO DOM · NO window · NO fetch"] Budget["Interrupt-driven budgets - ADR-040<br/>per-invocation soft/hard · per-runtime heap<br/>per-frame aggregate · token bucket<br/>→ plugin:deferred, attributable"] Untrusted --> RT1 RT1 --> NoDOM RT2 --> NoDOM Budget -.->|preempts| RT1 Budget -.->|preempts| RT2 end
Snap["Host-published immutable snapshot<br/>COPIED into each runtime's heap<br/>declared interest set, capped at registration"]
subgraph Bridge["RPC Message Boundary — prioritised above plugin execution"] JSON["JSON AST UI Descriptor"] State["CQRS Intent Command"] end
subgraph Host["Trusted Host"] Router["UI Render Router (Main Thread)<br/>registration-time target validation - §12.2.2"] Queue["SDK CQRS Command Queue<br/>causal premise per intent - Guardrail 4"] NetW["Network Worker<br/>ADR-053"] end
Snap --> RT1 Snap --> RT2 RT1 -->|Yields| Bridge RT2 -->|Yields| Bridge JSON --> Router State --> Queue Queue --> NetW
style Untrusted fill:#4a1c40,stroke:#702459 style Sandbox fill:#742a2a,stroke:#9b2c2c style Host fill:#1a202c,stroke:#2d3748 style Snap fill:#2a4365,stroke:#2b6cb0 style Budget fill:#744210,stroke:#b7791fWhy the copy is the right primitive, not a limitation. Guardrail 5 is explicit: “The platform has no read-only SharedArrayBuffer primitive — a typed-array view carries no access control.” A copy preserves the intended property (plugins read fast and cannot corrupt engine state) by construction rather than by an unenforceable promise, and it is why plugin functionality is independent of SAB availability in the Guardrail 7 matrix.