Skip to content

dh.map

Extension: .dh-map
Type ID: dh.map

A map binds a visual GLB mesh to its material textures and describes the world’s collision and its entities as explicit, authoring-controlled primitives. It is consumed by a game engine at runtime: the client renders the textured geometry, and the server installs the colliders as static physics and the map’s spawnPoint entities as player spawn points.

Collision is deliberately not derived from the GLB. Colliders are an explicit list of primitives so map authors control exactly where the world is solid, independent of the visual mesh.

dh.map is consumed by DigitalHeaven.Engine. See Engine Assets & Audio for how the engine loads a compiled map pallet at runtime.

PropertyTypeRequiredDescription
$type"dh.map"noType identifier
namestringnoDisplay name
descriptionstringnoDescription
geometrystringnoPath (within the pallet) to the GLB visual mesh. Omit for a collision-only map.
materialsobjectnoMaps a glTF material name on the geometry to a dh.material reference (barcode)
collidersarraynoExplicit collision primitives (authored; see also autoCollision)
autoCollision"mesh" | "boxes" | "hulls" | falsenoHow the geometry mesh becomes collision. Default "mesh" — true-to-shape concave collision cooked from the visual triangles at map load. "boxes"/"hulls" bake per-node colliders at compile time instead; false disables collision. See Automatic collision.
hullOptionsobjectnoTuning for autoCollision: "hulls" convex decomposition (see Automatic collision)
entitiesarraynoThe map’s world entities — a $type-discriminated list (see Entities)
patchesarraynoNode-level edits applied to the imported geometry (see Patches). Only dh.removeObject is supported.
nodesarrayGenerated, not authored. The compiled map’s node manifest; the compiler bakes it from the geometry and overwrites anything written here.

The type is inferred from the file extension, so $type is not needed in source files. The compiler adds it automatically during builds.

debug_map.dh-map
{
"name": "Debug Map",
"description": "A 32x32 m arena floor enclosed by 3 m walls, with a raised platform and a low step.",
"geometry": "meshes/debug_map.glb",
"materials": {
"grid": "materials/devGrid.dh-mat",
"orange": "materials/devOrange.dh-mat"
},
"colliders": [
{ "type": "box", "center": [0, -0.5, 0], "halfExtents": [16.5, 0.5, 16.5] },
{ "type": "box", "center": [0, 1.5, -16.25], "halfExtents": [16.5, 1.5, 0.25] },
{ "type": "box", "center": [5, 0.5, 5], "halfExtents": [2, 0.5, 2] }
],
"entities": [
{ "$type": "spawnPoint", "position": [0, 0, 0], "yaw": 0 },
{ "$type": "spawnPoint", "position": [10, 0, 10], "yaw": 45 },
{ "$type": "boxBrush", "position": [8, 0.75, 0], "size": [1.5, 1.5, 1.5], "rotation": [0, 30, 0], "material": "orange" }
]
}

geometry is a pallet-relative path to the GLB meshed as the map’s visual surface. glTF materials on the mesh are not honored — DigitalHeaven pallets are the material system — but glTF material names are used as slot keys for the materials map below. Omit geometry for a collision-only map.

The materials object maps a mesh material slot name (a glTF material name on the geometry) to a reference to a dh.material asset — its path from the pallet root (its barcode), or a cross-pallet barcode:

"materials": {
"grid": "materials/devGrid.dh-mat", // same-pallet material
"sky": "materials/sky.dh-mat", // an unlit, tinted sky slot
"proto": "core:materials/devGridWhite.dh-mat" // a cross-pallet material (engine core)
}

The referenced material supplies the slot’s base-color texture, tint, shader (lit/unlit), uvScale and emissiveColor — see dh.material. References are same-pallet (materials/name.dh-mat) or cross-pallet (<palletId>:materials/name.dh-mat); the engine core pallet (core:…) needs no declared dependency.

The same slot keys are what solid entities (boxBrush and the solid logic kinds) reference by name in their material fields.

colliders is an explicit list of collision primitives the server installs as static bodies. Two kinds are supported, selected by type: a box (center, half-extents, optional rotation) and a hull — a convex hull built from an inline point cloud.

A collider the compiler bakes from the geometry (see Automatic collision) additionally carries a nodeId — the node manifest id of the mesh node it came from — recording its provenance. Authored colliders have no nodeId.

A box may be oriented via an optional rotation.

FieldTypeRequiredDescription
type"box"yesPrimitive kind.
center[x, y, z]yesBox center in meters
halfExtents[x, y, z]yesBox half-extents in meters
rotation[x, y, z]noOrientation as euler degrees, applied in Z·Y·X order (same convention as a boxBrush). Omitted or all-zero yields an axis-aligned box.
{ "type": "box", "center": [0, -0.5, 0], "halfExtents": [16.5, 0.5, 16.5] }
{ "type": "box", "center": [5, 1, 0], "halfExtents": [3, 1, 0.5], "rotation": [0, 45, 0] }

A convex hull built from an inline world-space point cloud. The points are absolute world positions; the physics backend builds the smallest convex hull that encloses them (a concave cloud yields its convex hull). A hull carries no center/halfExtents/rotation — the points are already in world space.

FieldTypeRequiredDescription
type"hull"yesPrimitive kind.
points[[x, y, z], …]yesWorld-space vertices. At least four finite points are required; a smaller or degenerate cloud is rejected at compile time.
{ "type": "hull", "points": [
[0, 0, 0], [2, 0, 0], [1, 0, 2], [1, 2, 1]
] }

Other type values are rejected at compile time.

autoCollision selects how a map’s geometry becomes collision. It takes one of four values:

ValueMeaning
"mesh"True-to-shape concave collision cooked from the visual triangles when the map loads — the map is solid exactly where its mesh is (the default, used when the field is absent).
"boxes"Bake one oriented bounding box per mesh node at compile time, appended to colliders. Cheaper collision for blockout geometry.
"hulls"Convex-decompose each mesh node into one or more convex hull colliders at compile time, appended to colliders.
falseNo automatic collision; rely solely on the authored colliders.
  • The skybox node is always excluded: a node carrying a material named like a sky (contains sky, case-insensitive) is never made solid, so the player is never caged by the sky dome. In "boxes"/"hulls" mode, degenerate (thinner than ~1 mm) and oversize (larger than 900 m) nodes are additionally skipped.
  • A map without geometry has no automatic collision regardless.
  • Authored colliders are always installed, in every mode — automatic collision is added on top, never a replacement.
{
"geometry": "meshes/city.glb",
// autoCollision omitted — defaults to "mesh": the whole city is solid to its own triangles
"colliders": [ // still installed, on top of the cooked mesh
{ "type": "box", "center": [0, -0.5, 0], "halfExtents": [500, 0.5, 500] }
]
}

"mesh" makes the map solid to its own triangles — a single static concave collision mesh, cooked from the geometry at map load (not baked into the pallet) and installed on both the server and the client-prediction physics world so movement matches. It is the most faithful mode: an archway keeps its opening, a stair keeps its steps, a bowl stays hollow, with no per-node approximation. The skybox triangles are dropped before cooking; everything else is kept as-is.

Because the collision is one merged mesh cooked from the whole map, mesh mode has two properties worth knowing:

  • No per-node colliders are baked. In "mesh" mode the compiler writes only the node manifest — no nodeId-stamped colliders appear in the compiled colliders list (contrast "boxes"/"hulls", which append one per node).
  • Node identity does not de-solidify collision. Since the whole map is one mesh body, hiding or removing a single visual node (e.g. a live scene edit) leaves that region still solid — the cooked mesh is unchanged. If you need a node’s collision to disappear with the node, use "boxes" or "hulls", where each node owns its collider.

The two compile-time baking modes merge per-node colliders into authored colliders; they differ in shape fidelity and cost:

  • "boxes" wraps each node in a single oriented box. It is cheap to bake and produces the smallest output, but a box fills any concavity — a doorway, an L-shaped wall or a ramp underside is solid where the mesh is hollow. Good for blockouts and mostly-boxy geometry.
  • "hulls" runs a convex decomposition (V-HACD): each node becomes a set of convex hulls whose union approximates the mesh, so concave shapes stay concave — an archway keeps its opening, an L-wall keeps its notch. This costs far more at build time (seconds per map, versus milliseconds for boxes) and produces a much larger collider list. Unlike "mesh", each hull is convex, so a single node’s deep concavity is still only approximated.

For reference, baking the prototype Night City map (86 collidable mesh nodes) yields 86 box colliders in "boxes" mode versus 893 hull colliders (8,223 total points) in "hulls" mode — roughly a 30× larger collision payload for a proportionate gain in shape accuracy. "mesh" bakes no colliders into the pallet at all — it cooks the triangles at load instead.

hullOptions tunes the convex decomposition used by autoCollision: "hulls" (it is ignored in "boxes" mode). Every field is optional; an omitted field takes the decomposition’s default. Values are validated at compile time.

FieldTypeDefaultDescription
maxConvexHullsint > 064Upper bound on the number of hulls produced per mesh node. Lower it to cap collider count at the cost of fidelity.
resolutionint > 0400000Voxelization budget (total voxels). Higher resolves finer concavities but costs more build time.
maxVerticesPerHull4–25564Maximum vertices in each output hull. At least 4 (a hull’s minimum), capped at the physics backend’s 255-vertex hull limit; an out-of-range value is a build error.
minVolumePercentErrornumber > 01.0Volume-error convergence threshold (percent). Smaller values chase a tighter fit for more hulls/time.

The voxel fill strategy is intentionally not exposed — hull baking always uses flood-fill.

{
"geometry": "meshes/city.glb",
"autoCollision": "hulls",
"hullOptions": {
"maxConvexHulls": 32, // fewer, coarser hulls per node
"maxVerticesPerHull": 48
}
}

The compiler bakes a nodes manifest into every compiled map that has geometry: one entry per mesh-bearing node of the GLB, giving each imported node a stable identity that colliders, patches and engine tooling can address. It is generated output, not something you author — anything written under nodes in a source file is discarded and replaced by the baked manifest. The manifest is baked whenever the map has geometry, including when autoCollision is false, because it describes node identity, not solidity.

FieldTypeDescription
idintStable index assigned in scene-traversal order over the mesh-bearing nodes (the same order the runtime mesh loader assigns node slots), independent of whether the node is skipped for collision.
namestringThe node’s own name from the GLB (empty for an unnamed node).
pathstringThe node’s hierarchical, slash-separated name path from the scene root (e.g. buildings/block03/wall_north). Mesh-less structural parents contribute their name to the path but get no entry of their own. Siblings that share a name are disambiguated Blender-style (wall, wall.001, wall.002).
pivot[x, y, z]The node’s world-space translation.
aabb{ "min": [x,y,z], "max": [x,y,z] }The node’s world-space vertex bounding box.
// compiled output — do not hand-author
"nodes": [
{ "id": 0, "name": "wall_north", "path": "buildings/block03/wall_north",
"pivot": [10, 0, -4], "aabb": { "min": [9, -2, -5], "max": [11, 2, -3] } }
]

A node’s id and path are deterministic — the same GLB always bakes the same manifest — so a dh.removeObject target or a collider’s nodeId stays valid across rebuilds.

patches is an ordered list of node-level edits the compiler applies to the imported geometry, using the same $type-discriminated patch model as avatars and objects. A map’s patches operate on the node manifest, so only dh.removeObject is supported — every other patch type (e.g. dh.setMaterial, dh.setTransform) is rejected at compile time with a message listing the supported set.

Removes an imported node from the map. Its target addresses a node by its manifest name or its hierarchical path; the path form disambiguates when several nodes share a name.

FieldTypeRequiredDescription
$type"dh.removeObject"yesPatch kind discriminator
targetstringyesA node name or path from the baked node manifest. Must be non-empty.
"patches": [
{ "$type": "dh.removeObject", "target": "buildings/block03/wall_north" }
]

An empty target is a build error. A target that matches no manifest node is a warning, not an error — the patch simply does nothing (warn-and-skip), so a rename in the source mesh downgrades a stale patch to a diagnostic rather than a failed build.

entities is a general, $type-discriminated list of the map’s world entities — the same discriminator pattern top-level DH assets use. Each entry names its kind with a $type field. New entity kinds (lights, triggers, props) slot into this same list without a schema change; an unrecognized $type is rejected at compile time.

This list replaces the former spawns array. A player spawn is now a spawnPoint entity in entities, not a separate top-level field.

A player spawn point: a feet position and a facing yaw. The server materializes one spawn per spawnPoint entity. Players are placed at a random spawn no one is standing on (falling back to stacking only when all are occupied); a map with a single spawn point spreads players deterministically so they never overlap.

FieldTypeDescription
$type"spawnPoint"Entity kind discriminator (required)
position[x, y, z]Spawn feet position in meters
yawfloatFacing yaw in degrees around +Y; zero faces -Z
{ "$type": "spawnPoint", "position": [0, 0, 0], "yaw": 90 }

A map that authors no spawnPoint still yields one spawn at the origin, so the server always has somewhere to place players.

A box brush: authored static geometry rendered as a solid, textured box and installed as a matching static collider. Unlike a bare collider — which is collision-only — a brush is both visible and solid, so authors can block out pedestals, ledges and floating blocks without a GLB. Brushes are static map geometry and are never network-replicated.

FieldTypeRequiredDescription
$type"boxBrush"yesEntity kind discriminator
position[x, y, z]yesBox center in meters
size[x, y, z]yesBox full extents (edge-to-edge size) in meters; every axis must be positive
rotation[x, y, z]noOrientation as euler degrees, applied in Z·Y·X order. Omitted or all-zero yields an axis-aligned brush.
materialstringyesA key into the map’s materials table; selects the dh.material the brush’s faces render with
{ "$type": "boxBrush", "position": [8, 0.75, 0], "size": [1.5, 1.5, 1.5], "rotation": [0, 30, 0], "material": "orange" }

The brush is fully oriented: its rotation rotates both the rendered mesh and the physics body — there is no silent yaw-only fallback. Note size is full edge-to-edge extents, in contrast to a collider’s halfExtents. The material is required and must name one of the map’s material slots; a missing or unknown material is rejected at compile time.

A named fixed viewpoint. Cameras are an authoring aid, not gameplay: nothing in the simulation reads them and they are never replicated. They exist so a map can carry a set of repeatable shots that offscreen rendering can shoot on demand — which is what makes two renders of the same map comparable at all.

FieldTypeRequiredDefaultDescription
$type"camera"yesEntity kind discriminator
namestringyesThe shot’s name. Must be non-empty and unique among the map’s cameras; a render is asked for by name, so a duplicate would make the shot ambiguous.
position[x, y, z]yesEye position in meters
yawfloatno0Facing yaw in degrees around +Y; zero faces -Z — the same convention as a spawnPoint
pitchfloatno0Facing pitch in degrees; positive looks up. Magnitude at most 90 (straight up or straight down).
fovfloatno75Vertical field of view in degrees, in (0, 179]
{ "$type": "camera", "name": "skyline", "position": [-30, 26, 180],
"yaw": 100, "pitch": -14, "fov": 60 }

Cameras carry their own name space, separate from the logic entities’ — they are never connection targets, so a camera and a door may share a name, but two cameras may not.

Every one of these is validated at compile time, never clamped: a blank or duplicate name, a position that is not three finite numbers, a non-finite yaw, a pitch past ±90, or a fov outside (0, 179] are all build errors.

Why the camera authors its own field of view

Section titled “Why the camera authors its own field of view”

Everywhere else in the engine, FOV is the player’s comfort knob — client.fov, with an axis toggle and a speed-FOV offset on top, and a map pointedly has no say in it. A fixed camera is the opposite case. Its whole purpose is to frame the same pixels every time, and framing is exactly what the field of view decides. Inheriting the viewer’s preference would make the same named shot compose differently on two machines, which defeats the point of naming it.

It is vertical degrees, like every other FOV in the engine, so the shot frames the same content whatever aspect ratio it is rendered at. The default is 75 — a touch tighter than the client.fov default of 90, because a composed still wants less of the wide-angle spread a moving player wants.

Logic entities give a map behavior. Each carries a map-unique name and a set of outputs; you wire an output to an ordered action list that runs when the output fires. This is the Source/EntityIO model: entities fire events, and those events drive inputs on other entities. Everything is server-authoritative — the server owns the run-state and replicates only the resulting visual state to clients.

A logic entity’s connections array binds one of its outputs to an action list:

"connections": [
{ "output": "onPressed", "actions": [
{ "call": { "target": "mainDoor", "input": "open" } },
{ "delay": { "seconds": 1.5 } },
{ "call": { "target": "lamp", "input": "turnOn" } }
] }
]

An action list runs top-to-bottom with serial, blocking semantics. There are exactly two action kinds in v1, discriminated by their single wrapper key:

ActionShapeBehavior
call{ "target": name, "input": input, "param"?: bool }Invokes input on the entity named target, optionally passing a boolean param. Runs immediately, then the list continues.
delay{ "seconds": float }Blocks the rest of the list for seconds (quantized up to whole simulation ticks, so it is timescale-correct), then the following actions run.

Several connections may share one output name; each contributes its own independent run when the output fires. The activator (the player who pressed a button, say) flows through a whole run, including across timer delays, so a downstream action knows who set it off. Re-entrant chains are bounded by a fixed call-depth cap (8), so a wiring cycle can never stack-overflow the server.

Both ends of every connection are validated at compile time: the target must name an entity in the map, the input must be one the target’s kind declares, and a boolean param must be present exactly when the input expects one. A dangling or mistyped wire is a build error, not a runtime surprise.

KindSolid?InputsOutputsNotes
buttonyes(interacted with Use/E)onPressedPressing fires onPressed with the presser as activator; a short per-button cooldown debounces rapid presses. Replicates a pressed-latch bit for client feedback.
dooryesopen, close, toggleonFullyOpen, onFullyClosedSlides from closed to open over moveDurationSeconds along moveDirection for moveDistance m; the open fraction replicates so clients interpolate the slide.
timernofireonTimerfire waits delaySeconds, then fires onTimer preserving the activator. Overlapping fires schedule independently.
andGate / orGatenosetA, setBonTrue, onFalseBoolean levels; fires only on an edge of the computed level (A AND B / A OR B). Re-writing the same level never re-fires.
notGatenosetAonTrue, onFalseBoolean; edge of NOT A.
lightoptionalturnOn, turnOff, toggle(none)A real analytic light and, optionally, a fixture box that swaps between materialOn and materialOff. Its on/off state replicates as one bit, which drives both halves — see Lights below.
physicsPropyes (dynamic)(none)(none)A rigid body, not a logic node: it has no wiring at all. The server simulates it — gravity, contacts, tumbling, and Coulomb friction against whatever it rests on — and replicates its position and orientation to every client. Walking into one pushes it. See Physics props below.
pressurePlateno(none)onStartTouch, onEndTouchA proximity trigger with no Use key: the server checks capsule-vs-box overlap against player pawns every tick and fires on the occupancy edge — onStartTouch when occupancy goes 0→1, onEndTouch when it goes back to 0 — so additional overlapping touchers never re-fire either output. The activator is the pawn that caused the edge. Occupancy count replicates for gizmo/inspector display.

Solid kinds (button, door, and a light that has a fixture box) additionally take position, size, rotation and material fields like a boxBrush and are installed as static colliders; a physicsProp takes the same fields but is installed as a dynamic body that the world is free to move; pressurePlate takes the same position, size and rotation fields to place its trigger volume but installs no physics collider — it is a non-solid detector, not something a player can stand on or bump into; the non-visual kinds (timer, gates) carry only their name, connections and kind-specific fields.

{ "$type": "button", "name": "doorButton", "position": [-3, 1, 3], "size": [0.5, 0.5, 0.3], "material": "button",
"connections": [
{ "output": "onPressed", "actions": [ { "call": { "target": "slidingDoor", "input": "toggle" } } ] }
]
},
{ "$type": "door", "name": "slidingDoor", "position": [-3, 1, 7], "size": [2, 2, 0.3], "material": "door",
"moveDirection": [0, 1, 0], "moveDistance": 2.2, "moveDurationSeconds": 1.2 }

The engine’s core pallet ships a logic_map demonstrating the whole model: a button that toggles a sliding door, and a second button wired through a timer and an orGate to switch a light on — and the lamp really lights, flooding the surrounding floor and walls a second after the button is pressed. Three physicsProp crates sit between the two halves: two on the floor to walk into and shove around, and a third dropped in tilted from above so it falls, lands and settles. When the server logs at debug level, every firing is traced (doorButton fired onPressed -> slidingDoor.toggle).

A physicsProp is the one entity kind the simulation owns rather than the logic graph. It declares a box — position, size, rotation, material — plus a mass in kilograms (default 35), and from there the server’s rigid-body solver does the rest: it falls under the world’s gravity, lands on geometry and other bodies, tumbles, slides, and finally comes to rest and goes to sleep.

{ "$type": "physicsProp", "name": "crate", "position": [0, 4, 5], "size": [0.8, 0.8, 0.8],
"rotation": [12, 30, 8], "material": "crate", "mass": 30 }

It has no inputs and no outputs — no wire can start or end at one — so it never appears in a connections list. It is not a trigger and it does not fire anything; it is scenery that obeys physics.

Surface friction. Every simulation tick the server probes the surface directly under each prop and re-tunes the body’s friction from that surface’s surfaceprop grip, so the same crate slides a long way across ice and barely at all across rubber. The two coefficients combine the way the solver combines any contact pair, so a slick prop on a grippy floor still slides.

Pushing. Walking into a prop pushes it. The server resolves the overlap between each player capsule and each prop box and shoves the body along the player’s motion, capped so a sprinting player cannot launch a crate across the map. Because props are simulated server-side only, the client’s predicted movement does not collide with them — the same situation as doors and buttons — so a prop’s resistance to a walking player is felt on the server’s correction, not locally.

Replication. A prop’s position and full orientation ride the snapshot each tick and are interpolated on the client’s render timeline (the orientation by slerp), so a tumbling crate reads smoothly. Its size and material never ride the wire — both ends loaded the same map — and it is resolved back to its authored box by its index among the map’s physics props in author order, since a body the solver has moved can no longer be found by its spawn position. A “moving/resting” readout of the body’s sleep state replicates for the entity-gizmo overlay.

Tuning. The push and friction behavior is exposed as live world preferences — world.prop.friction, world.prop.groundProbe, world.prop.pushSpeed, world.prop.pushResponse and world.prop.pushReach — so all of it is tunable from the console without a rebuild.

Not yet supported: standing on a prop (the client predictor cannot see them), reliable tall stacking, and grabbing or carrying.

A light is both an emitter and (optionally) a visible fixture. It emits one analytic light, and it may render a box that swaps material with its on/off state — one replicated bit drives both, so the lamp’s glass and the room light up together and can never drift apart.

{ "$type": "light", "name": "lamp", "position": [3, 1, 7],
"materialOn": "lampOn", "materialOff": "lampOff", "startOn": false,
"light": "point", "color": [1.0, 0.88, 0.7], "intensity": 60, "range": 9,
"offset": [0, 0.6, 0], "innerConeDegrees": 30, "outerConeDegrees": 45 }
FieldDefaultMeaning
light"point"point, spot, directional, or none (emit nothing — a pure material-swap prop).
color[1, 1, 1]sRGB face values in [0, 1] — hue only, the same space a material’s tint is authored in. Linearized once, then multiplied by intensity. A component above 1 is a compile error; see Colors are sRGB, intensities are linear.
intensity25The linear, unbounded brightness half of the pair, in the engine’s artist-facing radiance units — the same scale world.render.exposure works against, not lumens. HDR values go here.
range10Influence radius in meters; the falloff reaches exactly zero here. Unused by directional.
falloffinheritphysical or unity — this light’s distance curve. Omit to follow the world’s default. Unused by directional.
offset[0, 0, 0]Emission point relative to position, in world axes — so a ceiling lamp emits from its bulb, not the box’s centroid.
innerConeDegrees30spot only: the fully-lit half-angle.
outerConeDegrees45spot only: the half-angle the light has fallen to zero at. Must exceed the inner one.
mode"realtime"Where this light is evaluated. realtime shades it every frame from the runtime light list. baked moves it out of the realtime budget and into the map’s lightmap instead — which requires an enabled lighting.lightmap block, because a baked light without one would otherwise vanish from the map entirely (a compile error). Any other value is a compile error naming the two that exist.

Two curves ship, and a light picks one — its own falloff, or the world’s default when it authors none.

ModeCurveReach for it when
physical(1 − (d/r)⁴)² / d² — true inverse-square, windowed smoothly to exactly zero at range. The engine default.You are lighting a scene in this engine. Energy behaves the way a real lamp’s does: doubling the distance quarters the light, so the bright core is small and the falloff is fast. It is also the curve Unity’s own URP, HDRP and lightmapper use, so a port from those needs nothing.
unity(1/(1 + 25·(d/r)²) − 1/26) · 26/25 — bounded at exactly 1 at the bulb, exactly 0 at range.You are reproducing a scene authored against Unity’s built-in (legacy) pipeline. Its attenuation is a curve-fit baked into a lookup texture, not inverse-square: it is bounded near the lamp, and it is much flatter. Roughly half brightness lands at a fifth of the range; at a twentieth it is 0.94, where physical is already up past 4.

The practical difference is the near field. Under physical, a lamp is blinding right at the bulb and dies quickly; under unity, it is a soft even wash. Intensities do not transfer between the two — a lamp tuned to look right on one curve will read wrong on the other, so switch the curve first and then retune, not the other way round.

directional lights ignore both: a sun has no distance term.

A spot or directional light is aimed by the fixture’s rotation: it emits down the rotated local -Y, so an unrotated fixture shines straight down and needs no direction field.

materialOn and materialOff are optional. Omit both for a bare emitter — an invisible, non-solid light with no box at all (size is then ignored and never validated), which is what a ceiling light or a mood light in a corner usually wants. Naming one slot opts into the fixture and then both are required. None of this rides the wire: both ends parse the same map, so the client recovers a light’s color, range and cones from the map definition keyed on the replicated spawn position. Only the on/off bit replicates.

Lights are ranked per frame against the camera and capped at 64 on the GPU — everything within reach of the eye first, brightest-nearest first inside that — so a dense scene degrades gracefully instead of popping arbitrarily.

lights.* console commands (admin/server, replicated to every client) retune a light for the session without editing the map. The map’s authored values stay the base; these are a sparse patch on top.

] lights.list
] lights.set lamp intensity 120
] lights.set lamp color 1 0.6 0.2
] lights.reset lamp

lights.set ... color takes the same sRGB values the map authors, and lights.list prints them back in sRGB — so a color you dial in at the console can be pasted straight into the map’s color field, and vice versa.

lights.set <TAB> completes the loaded map’s light names.

Wildcard targets: target also accepts a single * — at the start, middle, or end of the token — matching every light whose name fits. An exact name always wins first, so a light literally named * or containing one is never shadowed by pattern expansion.

] lights.set lamp* intensity 5
lights.set: intensity -> 5 on 12 lights
] lights.set *Door color 1 0 0
lights.set: color -> 1 0 0 (sRGB) on 4 lights
] lights.reset *
lights.reset: reverted 16 of 16 lights

A pattern matching exactly one light reports that light by name, same as an exact match. A pattern matching none reports the usual not-found error, naming the pattern.

A map may author the world’s sun and hemisphere ambient in an optional top-level lighting block. Every field is optional; an omitted one keeps the engine default.

"lighting": {
"sunDirection": [-0.35, -0.85, -0.4],
"sunColor": [1, 1, 1],
"sunIntensity": 1.0,
"ambientSky": [0.68, 0.715, 0.786],
"ambientGround": [0.618, 0.601, 0.584],
"ambientIntensity": 0.35,
"halfLambert": 1.0,
"falloff": "physical"
}

sunDirection is the direction sunlight travels, so an overhead sun is [0, -1, 0]. halfLambert is the diffuse wrap: 0 is a hard Lambert terminator, 1 the full Source wrap that keeps shadowed sides softly lit. falloff is the default distance curve every point and spot light in the map takes unless it names its own.

These seed the replicated world settings world.sun.direction, world.sun.color, world.sun.intensity, world.ambient.sky, world.ambient.ground, world.ambient.intensity, world.lighting.halfLambert and world.lighting.falloff when the map installs. A console edit afterwards is a live override on top and is never written back to the map — and because they are world.*, they are server-authoritative and identical for every client of a world.

A map can move some of its lighting off the frame budget and into the pallet. lighting.lightmap turns on a compile-time bake: the compiler parameterizes the map’s geometry into a lightmap atlas, ray-traces the chosen lights against the map’s real triangles, and stores the result as a texture the shader adds to every surface.

"lighting": {
"sunDirection": [-0.45, -0.8, -0.4],
"sunIntensity": 2.2,
"lightmap": { "enabled": true, "texelDensity": 4, "samples": 2, "bakeSun": false }
}
FieldDefaultMeaning
enabledfalseWhether this map bakes at all. Omitting the block entirely is the same as false: no atlas, no second UV set, everything realtime.
texelDensity4Atlas resolution as texels per meter of world surface — not an atlas size. The atlas size follows from this and the map’s surface area, so a wall and a floor of equal size always get equal detail no matter how the map grows. Must be positive.
padding2Gutter in texels between packed charts, and the distance each chart’s edge color is dilated outward. Bilinear filtering reads across a chart edge, so a zero gutter bleeds one surface’s light onto another.
maxSize2048The atlas edge length the packer may grow to before it gives up. A power of two, at most 8192. Hitting it is an error naming texelDensity, because the density is what to lower.
samples2The square root of the samples per texel: 1 shades the texel center only, 2 a 2×2 jittered grid, up to 4. Antialiases a shadow edge crossing a texel; costs its square in bake time.
bakeSunfalseBake the map’s sun instead of lighting it in real time.

Everything else the block gains after a build — texture, size, range — is compiler-generated and written back into the compiled map, the same way nodes is. Authoring them by hand does nothing; the next bake overwrites them.

Precedence is structural rather than a rule to remember: a light with "mode": "baked" is dropped from the realtime light list, so it is counted in the atlas and nowhere else. The two paths cannot double up, because they never both own the same light. bakeSun: true does the same to the sun — its replicated intensity resolves to zero and its cascaded shadow maps stop mattering.

The atlas stores diffuse irradiance only — the wrapped N·L and the distance/cone falloff, already integrated with ray-traced occlusion, but not multiplied by albedo. The material’s own base-color texture still supplies that at shading time, which is why a baked room re-textures without a rebake. Ambient is never baked, so a world’s sky can change live.

client.render.lightmapIntensity scales the baked contribution live (0 switches it off entirely, useful for an A/B against the same lights realtime), and client.render.debugView lightmap shows the raw atlas contribution on its own.

An outdoor map can replace its flat clear color with the engine’s procedural sky — a single-scattering Rayleigh + Mie atmosphere driven by the same sun the shadows come from. It is opt-in per map: a map that says nothing keeps the clear color, which is what an interior or a night scene wants.

"lighting": {
"sky": true,
"skyRayleigh": 1.0,
"skyMie": 1.0,
"skyMieAnisotropy": 0.76,
"skyHorizonSoftness": 0.15,
"skyIntensity": 1.0,
"skySunDisc": 30,
"skySunAngularDiameter": 0.53,
"skyGround": [0.22, 0.20, 0.18],
"skyGroundBlend": 0.01,
"ambientMode": "auto"
}
FieldMeaning
skyDraws the procedural sky behind the geometry. Default off.
skyRayleighMolecular scattering density; 1 is Earth’s atmosphere. Higher deepens the blue overhead and reddens the horizon harder.
skyMieAerosol (haze) density. Higher washes the horizon toward white and widens the halo around the sun.
skyMieAnisotropyThe haze lobe’s forward bias, [0, 1). Near 0.8 gives the tight halo a real sun has; 0 spreads it over the whole sky.
skyHorizonSoftnessHow fast the air mass grows toward the horizon. Small values give a hard bright band at the horizon line; larger ones spread the glow upward.
skyIntensityOverall multiplier on the sky’s radiance.
skySunDiscBrightness of the solar disc, on top of the sun’s own color and intensity. Large by default so the disc clears the bloom threshold.
skySunAngularDiameterThe disc’s angular diameter in degrees; the real sun is 0.53.
skyGroundLinear RGB albedo of the ground below the horizon.
skyGroundBlendWidth of the band the sky fades into the ground across — an antialiasing width for the horizon line, not a look knob.
ambientModeWhere the ambient fill comes from: auto, hemisphere or sky. See below.

The sky’s own sun direction, color and intensity are the map’s sun — there is no second sun to keep in step, and moving world.sun.direction from the console moves the sky with it.

Every field seeds a world.sky.* preference (world.sky.enabled, world.sky.rayleigh, …, world.sky.groundBlend) plus world.ambient.mode, on the same terms as the rest of the lighting block: replicated, server-authoritative, live-overridable from the console, never written back to the map.

With the sky on, the engine projects the analytic sky radiance into order-2 spherical harmonics on the CPU and shades against that instead of the two-color hemisphere fill. Nine coefficients per channel, projected from 1024 uniformly distributed directions, convolved with the clamped-cosine lobe — so the ambient a surface receives is the actual sky above it: bluer facing up, warmer facing the sun, darker on the side away from it. The projection deliberately excludes the solar disc, because the sun is already a shadowed direct light and an order-2 fit of a disc is a smooth wash no shadow could remove.

It is re-projected only when the sky parameters or the sun change, not per frame.

ambientMode decides which fill is used, and the map’s explicit ambient wins:

  1. A stored console value for world.ambient.mode overrules everything, as every world.* preference does.
  2. Otherwise an explicit ambientMode of hemisphere or sky in the map is honored verbatim.
  3. Otherwise (auto, or the field omitted) the sky’s harmonics are used only if the sky is on and the map authored no ambientSky / ambientGround — a map that went to the trouble of picking ambient colors keeps them.
  4. With the sky off, the hemisphere fill is always used. There is nothing to project.

ambientIntensity scales whichever fill is active, so it remains the one knob for “how much fill light”.

Both fills above are one value for the whole map: the same ambient reaches a rooftop and the back of a sealed basement. The optional lighting.giVolume block replaces that with a baked irradiance volume — a regular 3D grid of spherical-harmonic probes gathered at compile time, blended per fragment at runtime, so a room that cannot see the sky is genuinely dark and a wall near a lit floor picks up its bounce.

"lighting": {
"giVolume": {
"enabled": true,
"probeSpacing": 4.0,
"padding": 2.0,
"bounceAlbedo": 0.5,
"samples": 256,
"intensity": 1.0
}
}
FieldRangeDefaultMeaning
enabledon/offtrueWhether the volume is baked. Present so a map can switch the bake off for a fast iteration build without deleting the block and losing its numbers.
probeSpacing0.25644Distance between adjacent probes, in meters. Halving it multiplies both bake time and blob size by eight — start coarse. Indirect light is low frequency and a 4 m grid reads correctly in most interiors.
padding≥ 02How far past the geometry bounds the grid extends, so surfaces on the map’s outer shell sit inside the volume rather than exactly on its face.
bounceAlbedo010.5The gray albedo the bounce pass attributes to every surface. 0 disables the bounce, leaving sky visibility alone.
samples164096256Rays cast per probe. Trades bake time against noise; order-2 harmonics filter hard, so this can stay low.
intensity≥ 01Artistic multiplier on every gathered probe. The physically derived answer is 1.

There is no authored origin or extent. The grid is derived from the map’s own geometry bounds grown by padding — an author who moves geometry should not also have to move a box around it. If the derived grid would exceed the per-axis or total probe caps, the spacing is coarsened and the compiler says so.

Each probe stores nine RGB coefficients in the same order-2 basis, already convolved with the clamped-cosine lobe, as the sky-derived ambient above — the two are interchangeable to the shader, which is the point. Gathered per probe:

  • Sky visibility. Rays that escape the geometry collect the procedural sky if the map enables it, the hemisphere fill otherwise.
  • One albedo-weighted bounce. Rays that hit geometry collect the direct light at the hit — sun with a shadow ray, plus the map’s point and spot lights honoring their falloff mode and range — multiplied by bounceAlbedo.

The probes carry indirect light only. The sun and the map’s lights keep being evaluated live with their real shadows; they reach the volume through the bounce, at the surfaces they land on. Baking them directly as well would light every surface twice.

That holds for a "mode": "baked" light too — it bounces into the probes, and only bounces. Its direct term already lands in the lightmap, and a lightmapped surface reads both the atlas and the volume, so adding the direct term here would double it on exactly the geometry the atlas covers. The cost is at the other end: a dynamic object under a baked lamp — a player, a prop — has no lightmap of its own and sees that lamp only through the bounce, so it reads darker than it would with the same lamp left realtime. Where a moving object has to be lit convincingly, leave its lamp realtime.

A probe that lands inside solid geometry is marked invalid and dropped from the blend, with the surviving corners renormalized. That is why the sampler is a hand-written trilinear blend rather than a filtered 3D texture — hardware filtering cannot skip a corner.

A probe is judged buried by two rules, because one is not enough:

  • Backfaces. Two thirds of its rays landing on the inside of a surface means the probe is behind that surface.
  • Sealed and empty. No ray escaped the geometry and not one ray brought any light back. That is a probe in the dead space between two overlapping blockout solids: it sees its neighbor’s front faces over much of the sphere, so the backface ratio alone calls it usable, and it publishes an authoritative pitch black. A genuinely dark room is not affected — one lamp anywhere its rays can reach keeps the probe, and a probe that can see the sky at all keeps itself.

Inside the volume, the blended probe replaces the flat ambient term. Outside it, the existing chain (sky harmonics or hemisphere fill) is used unchanged, so a map is never worse off at its edges. Ambient occlusion keeps multiplying whichever ambient wins.

The replacement is weighted by how much of the cell survived the validity test, not switched on it. Where every corner is valid — the ordinary case — the volume is the whole ambient; where corners were dropped, the flat fill blends back in by exactly the missing weight. Switching instead would step the ambient across a cell boundary, and a single misjudged probe would draw a hard polygon edge on lit geometry.

The bake is a companion blob beside the map in the compiled pallet, not part of the map JSON, and nothing about it travels over the network — a client gets it with the map.

Two client preferences ride on top:

PreferenceDefaultMeaning
client.render.giVolumetrueWhether baked volumes are sampled at all. false falls every surface back to the flat ambient — the honest A/B for judging a bake.
client.render.giIntensity1A local multiplier on the sampled result, for eyeballing a bake without recompiling.

Before writing any of the numbers below, consider whether a named look already is what you want. The optional top-level look field adopts a whole art-direction preset by slug:

"look": "unity"
SlugWhat it reproduces
neutralThe engine defaults, stated explicitly. A no-op that documents intent.
unityUnity’s built-in pipeline: no tone curve (its reference post profile has no Tonemapper at all), plain Lambert shading (halfLambert 0), its legacy light attenuation (falloff "unity"), and the PPv2 bloom shape — threshold 1.721, knee 0.5, diffusion 6.2, anamorphic -0.24.
sourceSource-style shading: a full half-Lambert wrap, through the engine’s default curve.

A look reaches both the render and lighting blocks, because a look spans them — the curve and bloom are render, the diffuse wrap and the light falloff are lighting. It carries the response chain only: it never touches your sun direction, your sun or ambient colors, or the intensity scales, because those describe a particular scene rather than a reusable look. A map ported with its source engine’s ambient values keeps them.

The field also takes a reference to a dh.look asset, so you are not limited to the three built-ins:

"look": "looks/night.dh-look" // a look in this pallet
"look": "/looks/night.dh-look" // the same look, pallet root written out
"look": "io.mltn.looks:looks/night.dh-look" // one from a dependency

Slugs win: a token that names a built-in look resolves to it, which is why a pallet-authored look may not be named neutral, unity or source. A pallet can also name a look in its pallet.dh, which every map in it picks up — that layer sits below this one.

Where lighting authors what the map is lit by, the optional top-level render block authors how that light is resolved to the screen — the map’s exposure, its tonemap curve and its bloom. Every field is optional; an omitted one keeps the engine default, or the look if the map adopts one.

"render": {
"exposure": 2.0,
"tonemap": "reinhardWhite",
"bloom": true,
"bloomIntensity": 0.30,
"bloomThreshold": 1.721,
"bloomSoftKnee": 0.5,
"bloomDiffusion": 10,
"bloomAnamorphic": -0.24
}
FieldRangeDefaultMeaning
exposure> 01Linear multiplier applied to scene radiance before the tonemap curve.
tonemap"reinhardWhite"Curve name: reinhardWhite, aces, reinhard or none. Matched case-insensitively, so "ACES" is accepted.
bloomon/offtrueWhether this world’s bright areas bloom at all. false is exactly the pre-bloom image, not a very faint one.
bloomIntensity≥ 00.30Linear multiplier the resolved bloom pyramid is added back with.
bloomThreshold≥ 01.721Linear radiance above which a pixel blooms, tested on its brightest channel.
bloomSoftKnee010.5Width of the quadratic knee below the threshold, as a fraction of it. 0 is a hard cut (and pops).
bloomDiffusion11010How far the bloom spreads, in pyramid levels rather than pixels. At the default it saturates against the pyramid’s level cap, so the glow’s screen fraction varies mildly with resolution rather than being resolution-independent.
bloomAnamorphic-11-0.24Aspect distortion of the glow. 0 is round; away from zero it stretches — see the anamorphic ratio.

A value outside the range above is a compile error, not a silent clamp — the compiler validates the render block the same way it validates colliders.

These are art direction, not preference — the map author picks the mood, so they live on the world rather than on each viewer. Exactly like the lighting block, they seed the replicated world settings world.render.exposure, world.render.tonemap and the six world.render.bloom* values when the map installs: a console edit afterwards is a live override on top and is never written back to the map.

A player’s own client.render.* counterpart then sits on top of that, locally only. Every one of them is unset by default and an unset one simply follows the world, so a map’s look reaches everyone who has not deliberately opted out. See the color pipeline for the resolution order and the console readouts.

The client preference client.debug.entityGizmos (off by default; client.debug.entityGizmos 1 in the console) draws a debug overlay over the world for authoring and debugging logic. For every in-view logic entity that has a position — the solid kinds (button, door, light, physicsProp) and the non-solid pressurePlate — it renders a colored dot at the entity’s live pivot — a door’s dot rides its slide, and a physics prop’s rides the body wherever the solver has carried it — a label above it with the entity’s name and its live replicated value (a door’s open percentage, a light’s on/off, a button’s pressed/idle, a pressure plate’s live occupancy count, a physics prop’s moving/resting), and a wiring line from each output owner to every positioned entity its wiring reaches. Gizmos fade out with distance. The non-visual kinds (timer, gates) carry no position, so they show no dot; a wire that passes through one is followed transitively to the placeable entity on the far side, so a button wired button → timer → gate → light draws a single line straight from the button to the light — a terminal sink like the light always has its incoming wire drawn even though its only authored source is positionless.

A second overlay, client.debug.lights, draws each lit light’s influence volume into the depth-tested line pass — a range sphere for a point light, the outer cone for a spot — tinted with that light’s own color. A light that is switched off is not drawn at all, so it doubles as a readout of which lights are actually contributing this frame; a directional light has no bounded volume and draws nothing.

The overlay draws into Halcyon’s overlay draw list: above the 3D world, and recorded beneath the widget tree — so the menu, the settings screen and the console occlude the gizmos rather than being punched through by them, and the DigitalHeaven overlay’s own windows sit in the same list at the same depth.

Both belong to the wider client.debug.* family — see Debug Overlays for the rest of it, including the axis gizmo and the position readout that make authoring coordinates readable off the screen.

A dedicated dh.sound audio asset type is designed but not yet implemented. Until it lands, audio clips are consumed as plain binary resources. See the Engine Assets & Audio page for the runtime audio stack.