Skip to content

Architecture

DigitalHeaven.Engine is a standalone game engine written in C#. It shares the DigitalHeaven repository but is its own runtime — not a mod, not built on Unity, and independent of the pallet/asset toolchain documented elsewhere on this site.

Numbered distributables carry one increasing integer shared across their executable metadata and the main/pause menu’s Build N label. Ordinary compilation displays Development build; it does not allocate a release number. This identity is independent of EngineVersion.Current (0.1.0), Apple’s marketing version, network protocols and dependency versions. Reinstalling an existing artifact retains its number. Packaging, signed-device validation and distribution remain separate gates; a menu label is not proof of installation or TestFlight availability.

Every compile stamps the source that produced it: Directory.Build.props runs git describe --always --dirty and git rev-parse at build time and generates DigitalHeaven.Engine.Contracts.BuildStamp — a static class holding the short and full commit sha, the branch, whether the working tree was dirty, and the UTC compile time ("unknown" for any field git could not answer, such as a stripped source drop). Nothing hand-edits this file; it is regenerated on every build.

The desktop host, the dedicated server (the same --headless boot path) and the mobile shell each log one INF line at startup — build <short sha>[-dirty] <branch> <utc time> — and the console’s version command prints the same line on demand. The frame-stats card prints the short sha on its summary line and its build row, so a screenshot from a bug report names its own build without anyone typing a command.

Movement first. The engine chases Source-engine game feel. Player movement — walking, sprinting, crouching, air-strafing — is the first feature, not an afterthought, and the bar it is held to is a gray-box map that is fun to move around in.

Multiplayer first. Every session is a networked session. Singleplayer is an embedded server on loopback: the same code path with one player connected. There is no separate offline mode to keep working.

The current wire protocol is 66: it combines the node-addressed mesh-renderer state from protocol 65 with map-generation envelopes and reliable baseline completion. MeshRenderer keeps message ID 30 and carries the same generation envelope as other map-local state; MapBaselineReady uses 31. Renderer revisions reset on a new map generation, and stale-generation packets are rejected before body decoding. The server sends renderer state before the baseline-ready marker. Older peers must reconnect using a matching build; this does not add packet fragmentation.

Gameplay in its own assembly. Gameplay code lives in DigitalHeaven.Game, behind the Contracts schema, so the engine talks to it only through shared vocabulary. It is an ordinary referenced library: every host, desktop and phone alike, composes it through CompiledGameplay<T>. The separation is a day-one constraint that shapes the whole architecture.

Baked lighting as identity. The visual north star is baked global illumination: MonoSH directional lightmaps, light-light probe volumes for dynamic objects, and box-projected reflection probes. Not all of it is implemented; each page says what it has.

Core’s built-in asset, map-entity, action and component converters and compiled-pallet header reader use source-generated JSON metadata on modern targets. Their discriminator rules, options and file formats remain shared with the legacy net6.0 adapter. That adapter deliberately keeps the inbox JSON runtime required by BepInEx hosts and is not trim-safe; it is excluded from modern builds. This does not make all Core serialization trim-safe: the open generic ReadAsset<T> API still uses reflection, and full iOS linker/AOT validation is a separate gate. Checksum and content-hash APIs also offer explicit JsonTypeInfo<PalletManifest> overloads; byte verification accepts separate header-read and manifest-write type information. These preserve the hash formula while letting a closed host own its metadata policy. The iOS network core-equality check and shared NetClient download pipeline use that explicit path, including generated JsonElement support for unknown metadata. The iOS host supplies separate header-read and manifest-write metadata at client construction, so both downloaded dependencies and map pallets are verified before cache commit or delivery. The original client constructor retains its legacy policy for existing callers. Constructors select private verifier delegates, so shared chunk dispatch no longer references a legacy fallback branch. Only the original constructor references the reflection-based verifier and declares that requirement; an unrooted original constructor can be trimmed independently. Actual linker validation remains necessary, and this migration alone does not clear all strict linker diagnostics. Original arbitrary-runtime-metadata overloads remain reflection-based; other legacy callers are not silently changed to a closed policy.

LayerChoiceVersion
Runtime.NET, JIT only (no NativeAOT)net10.0, C# latest
Windowing & inputSilk.NET2.23.0
GraphicsVulkan via Silk.NET — dynamic rendering only, no legacy render passes; ordered passes per frame — sun shadow cascades, then the scene into a linear HDR offscreen target (the map’s backdrop first, then a depth clear, then the world), then one copy of that finished scene, then the refracting passes that read it — water and glass — then the tonemap resolve, then the UI; each world pass skips a draw whose bounds lie wholly outside the volume it rasterizes (the camera’s sides, or a cascade’s box), which changes no pixel (client.render.cullDraws, on); the camera’s own passes also skip a placed object a Source map’s visibility set hides from the camera’s cluster (client.render.pvsCulling, on); MoltenVK on macOS2.23.0
PhysicsBox3D — native, with our own interop layerpinned commit
ECSFriflo.Engine.ECS3.6.0
NetworkingLiteNetLib2.1.4
Audio (later)miniaudio + Steam Audio4.8.1 (Steam Audio)
Textures (later)BCn compressed formats—

The scene copy is taken once per frame per eye, by the renderer rather than by either pass, immediately after the opaque block resolves and before any refracting pass draws. Water and glass bind that same copy. It is what makes the pass order above a rule rather than a convention: a refracting surface can only read what was already finished, so water cannot refract water and glass cannot refract glass, and a pane standing behind a pane shows through the first one unshifted. Unity, Godot and Source all stop in the same place — taking the grab after the transparents would mean ordering every transparent surface against every other one.

Glass draws in two pipelines off one shader body. A clear pane reads the copy with a single tap; a pane whose material authored a transmission roughness gathers a disk over it whose radius grows with the depth gap. The two are separate entry points — the same included body compiled twice against a constant — rather than one shader branching per pixel, because the gather’s register and occupancy cost is paid by every pixel of a pipeline that contains it whether or not the branch is taken. Measured on the eval map at 1280x720 on an RTX 4080 SUPER: clear glass cost 0.0375 ms as one branching pipeline and 0.031 ms as its own, and the frosted pane costs about 0.16 ms. Which pipeline a pane lands in is read per frame off its table row, so retuning a material’s roughness moves it on the next frame.

A map’s backdrop draws first, inside the scene block. A backdrop (Source’s 3D skybox) is drawn before the world in the same dynamic-rendering block: its opaque draws, then the sky behind them, then its blended draws back to front, then a vkCmdClearAttachments of depth, after which the world draws exactly as it would over an empty sky (and the sky is not drawn a second time). Valve draws the skybox room from anchor + eye / scale with the main camera’s rotation; the engine instead carries every backdrop draw through the fixed matrix scale · (p − anchor) and draws it from the real eye, which puts every point in the same direction at scale times the distance — the same pixels, under the frame’s own view matrix and uniforms. The camera’s side planes cull its draws like the world’s (its own bounds in the visibility state), its opaque draws go nearest first so early depth rejection spares the shading of what a nearer backdrop surface hides, and they count in the backdrop pass group of the draw stats (--gpuTimings lists it beside opaque and blended). Its draws live in RenderScene.BackdropItems, which no other pass reads: the shadow cascades, occlusion, outlines, water and glass never see the backdrop, and after the depth clear the world’s depth is all that remains for them. Two more scene pipelines (BackdropOpaque, BackdropTransparent) specialize dev_opaque.frag’s Backdrop constant to apply the backdrop’s own linear fog from two frame-block lanes; the push block is full and the frame block is shared, so a specialization constant is the switch that reaches only these draws. A parallel (orthographic) view and a coverage capture skip the backdrop.

A pane can wear the same thin film water does. The five thinFilm* fields go through iridescence.glsl, the one include both surfaces call, with the pane’s own index underneath instead of the liquid’s; at the default weight of zero the shader takes the path it took before films existed, so no uncoated pane moves. The depth across a pane is static — a coating stays where it was laid — where water marbles its film along the current, and because a soap film’s index is nearly matched to glass, a pane that is meant to show bands is authored with a metal oxide’s index the way a real dichroic pane is coated.

All versions are pinned exactly. Conventions throughout: units are meters, coordinates are Y-up right-handed with -Z forward, and the simulation runs at a 60 Hz fixed tick. The large-world coordinate architecture fixes coordinate scale as an immutable per-World creation contract: M2 remains Compact, and a future Large profile can be added without widening every Compact entity.

Arrows are project references. DigitalHeaven.Content and DigitalHeaven.Content.Decoders are not engine projects: they live in Content/, below the engine, the toolchain and Studio alike. The engine reaches the portable layer through Engine.Assets; that binding selects the standard decoder adapter at its composition boundary.

graph BT
Contracts["DigitalHeaven.Engine.Contracts"]
Engine["DigitalHeaven.Engine"]
Physics["DigitalHeaven.Engine.Physics"]
Content["DigitalHeaven.Content"]
Decoders["DigitalHeaven.Content.Decoders"]
Assets["DigitalHeaven.Engine.Assets"]
Net["DigitalHeaven.Engine.Net"]
Client["DigitalHeaven.Engine.Client"]
Host["DigitalHeaven.Engine.Host"]
Game["DigitalHeaven.Game"]
Tests["DigitalHeaven.Engine.Tests"]
Engine --> Contracts
Physics --> Contracts
Decoders --> Content
Assets --> Content
Assets --> Decoders
Assets --> Contracts
Client --> Assets
Game --> Assets
Net --> Engine
Net --> Contracts
Client --> Engine
Client --> Contracts
Host --> Engine
Host --> Contracts
Host --> Physics
Host --> Net
Host --> Client
Game --> Engine
Game --> Contracts
Tests --> Engine
Tests --> Contracts
Host --> Game
  • Contracts — schema and shared vocabulary: component structs, IDs, interfaces. The engine/gameplay boundary.
  • Engine — the headless simulation core: ECS worlds, explicit lifecycle, and fixed ticks.
  • Content (Content/DigitalHeaven.Content, outside Engine/) — the portable interpretation layer: reads compiled pallets into DH-owned meshes, decoded images, materials, resolved objects and maps. It targets netstandard2.1 and net10.0, references Core only, and receives an immutable ContentDecoders composition from its host. See Runtime Assets.
  • Content.Decoders (Content/DigitalHeaven.Content.Decoders, outside Engine/) — the standard concrete adapter. It implements IImageDecoder with StbImageSharp and IModelDecoder with SharpGLTF; neither dependency nor either library’s types cross the portable Content API.
  • Assets — the engine binding of Content onto Contracts: the water field (IWaterField) and the per-slot surface table. It also composes DefaultContentDecoders.Instance for engine-side content loads.
  • Physics — the only assembly that knows Box3D exists. The rest of the engine speaks the IPhysicsWorld / trace vocabulary. IPhysicsWorld also exposes ApplyForce (a force in newtons at a world-space point, with a wake flag), linear and angular damping get/set, GetMassData/SetMassData (mass in kilograms, center of mass, and rotational inertia), GetWorldPointVelocity (the linear velocity of a material point on a body, including its spin), and sensor shapes: CreateStaticBox’s isSensor flag plus IsBodySensor, SetSensorEventsEnabled/IsSensorEventsEnabled, and DescribeSensorEvents to drain begin/end overlap events for shapes that report contact but apply no collision response. Sensor events are opt-in on both the sensor and the visitor shape — call SetSensorEventsEnabled(body, true) on each side before events for that pair appear.
  • Net — versioned session protocol, replication codecs, loopback and LiteNetLib transports, and the composite transport that fans one session host across both for a listen server.
  • Client — window, input, network client, and the Vulkan renderer. The window and input layer is SDL3 (ppy.SDL3-CS) on every host: SdlWindow is the one window seam (window, Vulkan surface, event pump) the desktop WindowHost and the mobile shell both compose, and Input.Key is the one physical key enum, valued as SDL scancodes. Its IPresentationSurface capability separates the initialized Vulkan surface and drawable pixel size from desktop window policy. An external C# host can reuse the scene loader and real swapchain through HeadlessRenderer(IPresentationSurface, ...) and PresentFrame; the historical name also retains the unchanged offscreen capture constructor. The caller owns the event loop, forwards resize notifications, and keeps the native surface alive until the renderer is disposed.
  • Client.Frame — the one client frame every host drives. ClientFrame.Run walks the session ramp, the shared render block, the speed signals, the world clock, the eye and the listener, the interaction target, the session reconcile and the world dissolve in one fixed order, calling back through IClientFrameHost at fixed points for the host-only work (present, widgets, overlay, transitions). It reads no host state mid-frame: the session facts arrive frozen on ClientFrameInput, which is positional so a host that forgets one does not compile, and every answer comes back on ClientFrameResult. The movement feel (MovementFeel, MovementAudio, FallWind, SurfaceScrape) is one part of it. See CONTRIBUTING.md § “One frame body, one hook order”.
  • Host — the executable. Boots the engine and composes gameplay into it. Composes the shared frame and owns the desktop hooks plus editor/audio policy. IClientPresentation is the renderer seam the frame’s render block pushes through, implemented by the desktop ClientRuntime and by HeadlessRenderer for iOS and offscreen capture; window-shaped work (display mode, the dark title bar) is deliberately NOT on it. The shared ClientPresentation adapter in Client owns replica visual dispatch, its mutable catalog reference, light resolution and remote-avatar projection through IClientPresentationSink. ClientPresentationSettings.Read resolves world/owner/local look precedence without a device. ClientEntityPoses owns replicated poses, desktop bone-edit predictions and spring simulation with injected real asset providers; external clients use the same solver and event owner. It must subscribe before network polling and be disposed with the session; reset clears poses and springs. Missing geometry keeps the existing transient placeholder until real content loads, never a synthetic skeleton. These device-free regression tests are not evidence of an iOS GPU session. Prediction and reconciliation (ClientPredictor), fixed-tick scheduling (ClientTickPump/ClientClock), mover contact logging, and the minimal physics-prop/catalog/sink/model helpers live in Client, with Net and Physics references. The implementation types remain internal with friend access for Host and tests. The public ClientSimulation facade owns shared frame/reset ordering and staged map loading for desktop and external hosts. BeginMapLoad returns the shared MapLoadJob; hosts implement IClientMapInstallation for worker parsing, budgeted upload and final installation, then advance or cancel the job from their frame loop. Host’s post-tick order remains movement audio → client props → editor follow → input-latch reset: installed client props step the whole prediction physics world once per real fixed tick, never during reconciliation replay. Replacement physics, collision metadata and contact labels stay staged through Upload; the final installation boundary commits them. Cancellation and upload failure dispose the staged replacement without replacing the previous physics world or props. Synchronous and editor scene rebuilds explicitly commit at completion. This is not a promise of graphics rollback or rollback after a throwing final installation callback. Hosts call the facade’s once-only SignalMapReady(job) gate after complete installation. Failed joins retain the visible failure and remain unready; no retry/disconnect policy is added.
  • Game — gameplay code, behind the Contracts schema.
  • Tests — xUnit tests for Engine and Contracts.

Box3D. Descends from the Rubikon lineage (Source 2’s physics) and exposes a CastMover-style API that maps directly onto Source-style character controllers. s&box ships it natively, so it is proven on exactly the movement-focused workload this engine cares about. It is native code, so it stays quarantined behind the Physics module and our own interop.

Friflo.Engine.ECS (over Arch and Flecs.NET). Benchmarks at or near the top among C# ECS libraries, is pure managed code with no native binary, and is small enough to vendor if upstream stalls. Decisively, it is pure managed state: tearing down a world leaves no native state behind to corrupt.

LiteNetLib (over GameNetworkingSockets and QUIC). Pure C# with a long track record in shipped games. .NET 10’s built-in QUIC still lacks unreliable datagram support, which real-time game traffic needs, and GameNetworkingSockets would add a native dependency for capabilities LiteNetLib already covers in managed code.

JIT on desktop. The desktop host keeps reflection-based component discovery. AOT hosts (iOS) run the same composition with an explicit schema registration instead. Nothing is loaded at run time any more: both the gameplay and editor assemblies are ordinary project references.