Level Editor
The level editor is not a separate application. You start the game normally and — with cheats enabled and world authority — one key press or one command switches the running session into an editor state. Everything the client already is (the world, the netcode, the renderer, the UI) stays exactly where it was; the editor is a fifth interaction state layered over the same session, with its own cursor rules, its own camera, and its own Halcyon surface.
Entering and leaving
Section titled “Entering and leaving”| Route | What it does |
|---|---|
editor | Loads the editor module if this is the first time, then asks to start or stop editing. |
| F2 | The default bind for the same toggle. Live in play and in the editor, dead behind a console, a settings window or a chat line. |
| The toolbar’s ✕ | The same request, from the editor’s own bar. |
All three are requests, never a state flip. The client puts pawn.editing 1 (or 0) on the wire; the server checks its own cheat gate, freezes or thaws the pawn, and sets or clears the replicated Editing marker. The client enters or leaves the editor when that acknowledgment replicates back — so a refused request changes nothing locally except the server’s refusal line in the console, and an admin clearing the flag drops you out of the editor without you asking. There is no local “I pressed the key, so I must be editing” path anywhere.
Without authority, the editor is read-only
Section titled “Without authority, the editor is read-only”The session is told what it may do, and the editor draws itself accordingly. On join — and again whenever the operator list or world.cheats changes — the server sends one small reliable editorAuthority message carrying two raw facts: whether cheats are enabled, and whether you are an admin. No verdict crosses the wire. The client re-runs the server’s own gate on those two facts, so the editor cannot reach a conclusion the console would not.
That gate asks two different questions, and they do not have the same answer:
| What you want to do | What it takes |
|---|---|
| Change the map — add, delete, move, rename, edit a material, bind a slot, bake | World authority: the host, the server console, or an admin. Cheats do not help. |
| Spawn an asset into the world | World authority or world.cheats 1. |
So the state worth naming is cheats on, and not an admin: you get in, you can spawn, and every edit to the map itself is refused. The editor draws exactly that.
Everything you may not do is drawn dead, not hidden. Add, Delete, Save, Discard, every writable inspector row, the Restore ledger, the material inspector’s fields, the lightmap bake — all still in their usual places, grayed and inert, because a menu that empties itself teaches you nothing about where a command lives and leaves you guessing whether the editor is broken or you are. The manipulators do not appear. What stays live is everything that costs the world nothing: flying, selecting, looking at any node’s values, the material preview, Inspect material, Build, and opening a different map.
And one sentence says why, on the toolbar, in the gate’s own words — editing this map: requires world authority (admin or server console). It is deliberately not the console’s usual “set world.cheats 1” advice, which is true for spawning and false for everything in the table’s first row.
Entering the editor is a separate question, and it stays admin-only: pawn.editing keeps its cheats-or-admin gate, and the read-only surface above is what the cheats-on-not-admin case gets rather than a door for onlookers.
Only one session can save the map
Section titled “Only one session can save the map”Authority decides whether you may change the world. It does not decide whether this computer can write the map’s authored source back — and on a server with two operators those come apart at once. The .dh-map lives in somebody’s workspace; a client connected from another machine has no file to write, and two clients that both have the pallet checked out would be two sessions racing one file.
So the room elects one holder. When you enter the editor your client tells the server whether it can resolve this map’s source on its own disk, the server picks the first session that could, and everybody is told who it is.
But everybody still presses Save. The row is live for anyone with authority, wherever they are connected from, and says nothing about who owns the file: the click is a request. It goes to the server, the server hands it to the elected holder, the holder’s machine does the write, and the one sentence its disk produced comes back to the session that asked — saved 3 object(s) to yard.dh-map, or the error, word for word. One writer and one workspace, which is the whole reason for the election.
Two situations answer rather than write. A second request while a save is already out is refused where it stands instead of queueing behind a write nobody is waiting on, and a holder that disconnects mid-save answers the waiting asker outright, because nothing will ever report on a write whose machine is gone. The only Save still drawn dead is a room where nobody declared a source at all, and the bar says so and names the way out: no one holds the source; use Save a Copy… — see A map nobody holds is saved as a copy.
The map is released when the holder leaves the editor or disconnects, and the next session that declared a source takes it. Your own edits are not lost by not holding it — they live in the running world and replicate like everything else; only the trip back to disk is exclusive.
Adding is not part of that election. A new box, light, volume, probe or camera goes out as editor.create, which the server applies to its copy of the map and replicates to the room, so the Add menu is live for anyone with authority — including an operator connected from a machine that has never seen this .dh-map. What genuinely needs the source on this computer is what runs here: Build and the lightmap bake are drawn dead where the loaded map resolves to no workspace pallet on this disk. Save is not among them, because Save no longer runs here.
A claim is about one file, so changing map retires it: the room is told nobody holds the new map, and every session in the editor re-states whether it can resolve that one. That happens on every map that lands under you — a typed map, a pallet hot reload, a server switching out from under you — so opening the editor on one map and then opening another is the ordinary way in rather than a way to lose the Save row.
Editing next to somebody else
Section titled “Editing next to somebody else”Two operators in one room are editing one world, not two copies of it, and everything either of them does replicates the moment it is done. That leaves exactly one thing to settle: what happens when two people reach for the same object at the same moment. The answer is different for a drag than for everything else, because a drag is not one edit — it is a hundred of them a second.
A drag streams, and the server is the truth. While you hold a gizmo your own client previews the motion locally, so the object follows your pointer with no round trip and nothing rubber-bands; each pose also goes out to the server at a sequence number of its own, and the server keeps only the newest it has seen for that object. A pose that arrives out of order — behind one already applied — is dropped rather than applied late. The pose written when you let go carries the highest sequence there will ever be for that gesture, so no reordering on the wire can leave a mid-drag frame sitting on top of where you actually put the thing.
A drag also takes a lock, and the lock is what the other editors see. From the first streamed pose until you release, the room knows the object is in your hand. For everybody else that object’s move, rotate and scale gizmos are simply not drawn, and its transform rows go read-only with one line saying whose pointer has it — wren is moving this object. They can still select it, still read every number, still rename it, still switch it off; what is withheld is the placement alone, for the length of the gesture. The lock expires on its own if the holder stops sending or drops, so a crash mid-drag does not leave an object locked for the rest of the session.
Everything else is last-write-wins, and the loser is told. A name, a switch, a light’s color, a material binding: the newest edit is the one that stands, and nothing is refused. What is new is that you hear about it — the edit that was overwritten produces an ordinary notice naming who moved what, wren moved entity 7, so a change vanishing under you is something you read rather than something you wonder about. The winner hears nothing, because from their side nothing happened. A spawned asset is covered on the same terms — spawn.transform, spawn.enable and spawn.remove are witnessed exactly as a map edit is, wren moved spawned 16777231 — and none of them ever marks the map unsaved.
While you are editing, your pawn is frozen on the server: it stops taking your input and stops being simulated. That is an ordinary per-pawn character flag composed with a separate Editing marker, not a bespoke editor mode: pawn.editing sets both, and nothing in the simulation asks whether anyone is editing.
ESC pauses over the editor. The pause menu opens on top of the editing session and Resume returns to the editor, not to play — the flown camera stays parked where you left it for the length of the pause. The toolbar stays where it is underneath, dimmed by the menu’s scrim, because the pause is a screen over the editor rather than a way out of it. If the server ends your editing session while that menu is up, the pause resumes into play instead, because the editor it would have resumed into no longer exists.
Alt-tabbing away changes nothing. Losing the window’s focus is what takes ordinary play away — it frees the pointer and stops your input reaching your body — and in the editor both have already happened. So the editor simply stays up: the toolbar, the camera and every gate are exactly as you left them when you come back. The first frame back is still swallowed, so the click that refocused the window cannot spin the camera.
Some developer readouts step aside while you edit. The effects readout (top center) and the sound-cue panel (top right) are hidden for the length of the session: both report on your pawn’s motion, and your pawn is frozen where it stood. The frame stats, the position readout and the axis and entity gizmos stay — those describe the frame and the world you are editing. Nothing changes their client.debug.* preferences; they come back with your body.
The ones that stay move into the picture instead. With the editor open the world is not the display — it is the game view, a docked pane like any other — and every readout that anchors to a corner anchors to that pane’s corners. Dock a column down the left and the frame stats come in from the left by exactly that column’s width, drag the divider and they follow, and closing the pane’s neighbor gives the corner back on the same frame. The world, the projection, the pick ray and the readouts are all sized from one rectangle and cannot disagree.
The rectangle is a plain value the module publishes — the game view’s bounds every frame, alongside the bar’s height when it registers its toolbar — so a module that redesigns the bar or a person who rearranges the layout moves the readouts with it on the next frame. Nothing in the client hard-codes the editor’s dimensions, and no readout knows the editor exists: it is handed the rectangle the world was drawn into and anchors to that. Floating windows claim nothing — they float over the editor, and the game view underneath keeps its size.
The toolbar
Section titled “The toolbar”The bar across the top is the developer overlay’s bar in the editor’s colors, and that is the whole design. Same height, same inset, same padding, the same flat full-height items that light up under the pointer, and the same red ✕ in the far corner. They are built from the same Halcyon widgets — BarItem, BarMenu and BarCloseBox, and the editor’s options window from the same WindowFrame the overlay’s windows use — rather than from two descriptions of one idea, so they cannot drift apart. A person who has used the overlay’s bar has already used this one.
It is a menu bar: seven words on the left, the ✕ on the right, and the name of the map you are editing across the middle.
- DH.Editor — the mode’s own name, still painted in the accent, and also the menu that holds Pause and Options…. It wears the program’s own prefix rather than the bare word, because “Editor” on a bar over a game is a word that could mean anybody’s editor. Pause raises the pause menu exactly as Escape does over play, but skips Escape’s own first step in the editor — dropping the selection — so you can pause without losing what you had selected.
- File — the map’s own file first, then the write-and-build loop: New Map…, Open… and Open Recent name, open and recall a map from the workspace (the picker they raise is the main menu’s own), then Save, Discard Changes and Build write the change down, throw it away, or build it back into the world you are standing in. Save carries the number it would write — Save (3) — and every row is drawn dead in place when there is nothing for it to do. Discard Changes puts the map back as saved — the server throws the session’s edits away (
scene.discard) and the map comes back from its source as a quiet revision — so it counts every pending edit rather than the savable ones, and it goes through the same confirmation the editor puts in front of anything else that would lose them. - Edit — the selection in bulk: Select All, Select Disabled, Select Invisible, Deselect All and Invert Selection. The first three and the last replace what is selected; they work over every selectable object in the loaded map, so a filtered or folded Hierarchy never narrows what “all” means. Disabled is the objects whose own switch is off — the Inspector’s Enabled — and Invisible is everything not currently drawn, which is those plus anything whose renderer component is off. Deselect All is dead in place when there is nothing selected. Under a rule of its own, last, sits Delete — the only row here that changes the map rather than moving a highlight around. It takes the selection out of the world — nodes, logic entities and spawned assets alike — and it is dead in place when the selection holds none.
- Add — everything that makes a new object: Volumes ▸ Light Probe Volume / Reflection Probe, Lights ▸ Point Light / Spot Light / Directional Light, Logic ▸ Button / Door / Pressure Plate / Physics Prop, and Camera. It sits between Edit and Map because that is the order the three are reached in — what is already selected, what to bring into being, and then the map as a whole — and every row with siblings of its own kind is nested one level under its family, because a flat list of them would read as one family with several members. Camera sits flat beside those two families rather than inside one, since it belongs to neither and a submenu of one is a submenu nobody opens twice. Its rows go dead in place with no editable source, on the File menu’s rule.
- Map — what the map is made of: Lighting…, and under it Capture thumbnail and Bake all reflections. It takes the shot for real, but not in this window: the picture is framed by the map’s own thumbnail camera at its authored
thumbnailSize, which is not the shape of the window you are editing in, so the click launches a second engine process offscreen — the same--captureMapThumbnailsrundh buildshells out to — and it writes the PNG beside the map’s source. A toast says where it landed, or why it did not. The picker’s copy of that picture is dropped the moment the shot lands — the decoded thumbnail is forgotten by barcode on the frame the capture reports success, so the next map list reads the new file rather than showing the old picture until something notices. Nothing polls for it: the refresh tick still exists for adh buildthat rewrites a pallet underneath, and a pallet the catalog’s watcher reports as rebuilt drops every thumbnail in it the same way. The stamp behind all three folds in both files a map’s picture can live in — the companion inside the built pallet and the PNG beside the authored source — and a read prefers whichever is newer, which is what makes a capture visible with no rebuild in between. It photographs the map on disk, so save first if the shot is meant to include what you just moved. Bake all reflections is the opposite in exactly that respect: it runs the reflection card’s Bake over every probe in the map, in this process, against the world on screen — so it photographs what you just moved rather than what is on disk. One sidecar is written at the end of the pass. The map’s fog and its skybox belong under this heading next. - View — what is on screen: Hierarchy, Inspector, Tools and Hover Names. Each is a toggle rather than a command, and a toggle that is on is lit in the accent where it sits, so the menu reads as a list of switches at a glance instead of one you check by trying it. Under them, Debug View ▸ unfolds every
client.render.debugViewas one checked group — Off, Albedo, and on through Lightmap and Lightmap Texels — so the world can be switched to a diagnostic view without the console, and the console’sset client.render.debugViewmoves the check on the next frame. Lightmap Texels is the one to tune lightmap sizes in: every checker square is one baked texel, blue under the bake zone’stexelDensity, green on it, red over it, and hatched gray where a surface has no texels. Change a node’slightmapScalein the map source, rebake and Build (a new scale repacks the charts, and the world only takes new lightmap coordinates from a build), and watch it turn green. The targets are the loaded map’s, so a zone’stexelDensityedited in the source is measured against after the same Build. Like the exit modes, the rows are a setting rather than an act, so they live one level down. - Player — everything aimed at your pawn: Teleport Self Here, and under it Body ▸, whose submenu holds the standing three-row exit-mode choice — Follows Camera, Brought Along On Exit, Left Behind. The choice lives one level down because it is a setting and the row above it is an act: flush against each other with nothing between them, the four rows read as one list of things that happen when you pick them.
- The ✕ at the far right is the only way out, flush with the corner and the bar’s full height, red under the pointer. It is the chip every window in the program wears, one size up so it reads at the top of the screen. Leaving lives in the same corner here, on every window, and on the overlay’s own bar.
Nothing sits loose on the strip. Every command lives under a heading, because the strip is where actions go to run out of room: the next verb beside a long one makes the row unreadable before it makes it full.
The File menu opens the same picker
Section titled “The File menu opens the same picker”Open… raises the launch window’s map browser, not a list of paths: the same MapPickerPane — category rail, search box and thumbnail tiles wearing the dimmed-barcode-bright-name style — composed here with a footer of New Map…, Open and Cancel instead of the launch window’s server settings. Open and close stay where they belong, which is the flavor of the window that starts a session; an editor already standing in a world has nothing to ask about a player limit. Because it is one builder rather than two spellings, the categories, the favorites star, the counts and the thumbnails are the same here as on the main menu, and pressing a category and sliding onto another switches the list while the button is held, on the shared UiNavRail the options window’s rail is.
Workspace leads that rail, above every category and fenced off from them by the rails’ own hairline rule, wearing the browser’s folder mark: it is not another slice of the catalog, it is the disk the catalog was read from. Picking it mounts the asset browser’s own pane inside the picker — the pallet tree, the breadcrumb and the listing, filtered to maps — so a map in any source pallet, including one the registry never compiled, is opened without leaving the window. It is the browser’s pane, mounted, not a second tree beside it. The change-map dialog shows no Workspace row at all: that composition offers no browse, so neither the row nor the rule under it is drawn.
New Map… asks for a name, shows the source file that name would become under the field — the folder dim, the file bright, the same split every barcode in the editor is written with — and offers two templates. It opens on a suggestion rather than on nothing: new-map, counted past the maps already in that folder (new-map-2, and on), so the path under the field names a real file from the first frame.
A map name is kebab-case, always. Lowercase words joined by dashes, judged by DisplayName.IsKebab — a space or a capital is refused inline, in words, and Create stays dead until the name is one a pallet can spell. It is also dead while the name is blank, carries characters a file name cannot, or names a map already in that folder.
The templates show what they make. Each is the picker’s own map tile: Flat map carries core:maps/flat-map’s picture, asked for through the same thumbnail lookup the picker’s rows use, and Empty carries the blank plate with the map badge — which is exactly what it writes. Empty writes an empty map. Flat map writes a copy of core:maps/flat-map: maps carry no inheritance mechanism, so the template is duplicated into the target pallet at creation and owes its origin nothing afterward. The map is written and then opened in the editor, through the same route Open takes.
The unsaved mark
Section titled “The unsaved mark”While anything is pending the bar hangs a colored asterisk off the end of the map’s name, in the middle of the screen where the name already is. It is not a count and not a sentence — a star is a state you can see without reading, and what is pending in detail is what the Inspector’s own marks are for. The mark’s ink is client.editor.pendingColor, an RGBA array, so it can be turned to taste from the console.
The bar itself warms up with it. A soft amber wash blooms across the middle of the strip, centered on the map’s name, clipped to the bar and painted under everything written on it. It says the same thing the asterisk says and both stay: the mark is the precise statement — it is there or it is not, and a person looking for it finds it — and the wash is the peripheral one, the part that is seen without being read while you are looking at the world instead of at the bar.
The wash is a circle, not an ellipse fitted to the bar — one whose radius runs many times the strip’s own height, clipped by the strip. What the bar shows is a horizontal slice through it, so the light fades slowly along the bar’s length and barely at all down its height. A gradient squashed to the bar’s box completes its falloff in fourteen pixels and reads as a lozenge parked on the strip, which is a different picture entirely.
What animates is the radius. The wash grows out of the center when work goes unsaved and contracts back into it when the work is written down; a wash that faded up in place would read as a light switched on behind the bar rather than as a mark arriving from where the change happened. The bloom is read through an ease-out and the contract through that curve mirrored, over client.editor.pendingGlowSeconds. Its color, peak alpha, reach and falloff are client.editor.pendingGlow*; Halcyon blends in linear light, so the authored alpha is the honest number rather than a corrected one — the defaults are the ones that make the rendered frame match the authored picture, not the ones that repeat its numbers.
The mark is absent rather than dimmed at rest, which is the opposite of the selection readout’s rule and deliberately so: “nothing is different from the file” is the resting state of the whole editor, and a mark that was always up would stop meaning anything the first time it was true.
Leaving with work outstanding
Section titled “Leaving with work outstanding”Every route out of a map asks first when there is pending work: the toolbar’s ✕, the map picker, the pause menu’s Quit, the console’s quit, the window’s close box, Ctrl+C, and a connection that drops on its own. The box names what is about to happen rather than asking a generic question — Leaving this world will drop 3 edits — and offers Save, Discard and Cancel. Save is save what can be saved: if part of the pending set is something the map source cannot express yet, the box says so before the click rather than after it. Cancel genuinely restores the status quo, because nothing was torn down on the way to the question.
A room where nobody holds the map’s source is not asked. Its Save is already drawn dead with the reason on the bar, so a box leading with Save would offer the one thing this session cannot do, and the edits it would be asking about live in the running world rather than in a file anyone here could write. Every route out goes straight through. A room where somebody else holds the source still asks, because there Save is live and forwarded.
F2 is the exception, and it is not a hole in the guard. Pressing F2 out of the editor puts you back in your body in the same map, with every pending edit still held — nothing is written, nothing is dropped, and pressing F2 again brings the editor back with the work exactly where you left it. There is nothing to warn about, so nothing is asked. Bouncing out to try a jump and back in is the loop the editor is for, and a dialogue in the middle of it would be a question about nothing, asked constantly, until it stopped being read.
What answers for it is a badge in the top-right corner: the same asterisk in the same client.editor.pendingColor, with the words unsaved edits beside it, on screen for as long as the pending set is non-empty and the editor is shut. The bar left with the editor, so the mark takes a corner instead. It is drawn on the shared HudPlate — the dialog bezel’s rings, lighter, over a blur of the world — like the speedometer. It sits in the HUD band, which means a pause menu’s scrim dims it along with the world it reports on, and the console and the options window still cover it — the same rule the speedometer follows, for the same reason.
Build recompiles the source pallet that owns the map you are editing — not the whole workspace — and the map comes back by itself. One click, and the change you made in your editor is the world you are standing in. It reads only that pallet and the pallets it declares as dependencies, so an avatar pallet somewhere else in the workspace with an asset this compiler cannot parse is no error of this build.
It is the same compiler the Studio app runs, invoked in process rather than by shelling out to dh.exe: one toolchain reached two ways is two toolchains the first time they disagree about a diagnostic, and a shipped host has no dh.exe beside it to find. The compile runs off the frame loop, so the picture keeps moving while it works.
Nothing about how the map comes back is new. A successful build rewrites the pallet, the pallet watcher sees the write, and the same hot reload that already answers a dh build typed in another window brings the map in — server-side validate, revise-or-keep, re-advertise, through the one load path a map switch takes. There is no second path and nothing to race. A build started with --noMapReload, or while you are playing on somebody else’s server, still compiles; it just does not bring the map in, which is what that flag means.
It swaps in quietly. A Build of the map you are standing in is a revision: only what the build changed is brought in — a re-shaped mesh, a moved light, a new texture, a retextured brush, a changed collider — as the difference from the world on screen. No loading box opens and nothing stops: you go on flying or walking while it loads, and the world swaps in one frame. What it says is one toast, naming what it changed — Map rebuilt: changed entity streetLamp — for everyone in the room.
With unsaved work it asks first. A rebuild carries the session’s edits onto the new build, but an object the build restated stands where its new file says, so an unsaved edit on it would be lost with nobody told. So the automatic swap only happens while you have nothing pending. With edits outstanding, the rebuilt pallet stays on disk and a box asks — Reload and discard, or Keep editing. Reload and discard throws the session’s edits away first (scene.discard: the map as saved, with what you created taken out of it) and then brings the build in, so what arrives is exactly the build. Escape means keep editing, and a further rebuild while the question stands replaces what it is about rather than stacking a second copy of it; whichever build lands last is the one a later answer brings in. Plain play, with no editor open, takes every rebuild as it lands.
A Build of what you saved comes back clean. The rebuilt map carries your saved edits as authored ones, so nothing is marked afterwards: an object you created and saved is an ordinary entry of the new map rather than a creation waiting to be appended again, and the rebuild does not stand a saved entity or brush at its old offset a second time while the server restates the world.
The rebuild keeps your place. The editor camera stays exactly where you flew it, the interaction mode, the handle space, the panes and their tabs are untouched, and the selection comes back — by each object’s authored id, so a node or a brush is selected again the moment the rebuilt map answers to it and a light or camera once its new replica arrives, with the same member active. An object the build removed is simply not selected; nothing else is selected in its place. Clicking something yourself before it all arrives wins. Undo survives it whenever the build renumbered nothing the history names — every brush and every logic object both builds hold still answers to the same number — which is every Build that moved, re-shaped or retextured things in place and every one that only appended new objects. A build that inserted an object ahead of others renumbers them, and only then is the history dropped, since its entries would aim at different objects.
While it runs the row reads Building… and refuses a second click. The refusal is real rather than cosmetic: the host holds the gate, so nothing else can start an overlapping compile either.
A box comes up and says where the compile is. It is the loading box’s own card — the same bezel, the same bar, centered the same way — because a compile and a map load are the same promise: something is happening, here is how far it got, here is how to stop. Its title is the pallet’s barcode, dimmed to the last segment and bright on the name, the way a map’s name is written everywhere else. Under it the current stage and the file it is on, a bar, the percent, and how long it has been running.
The stages are the compiler’s own, reported as it crosses them: resolve → validate → collect → assets → package. Model imports, collision cooks, lightmap and GI bakes report from inside the asset stage, since that is where they actually happen — the box names the file it is on rather than spinning, so a pallet that sits for twenty seconds on one 8K texture says so.
Cancel stops it, and Escape is Cancel. The card takes hold the moment you press it — the step reads canceling… and the bar returns to a sweep, the same interim a lightmap bake shows — because the compiler will not notice until its next boundary and a card stuck on the last stage in the meantime reads as hung. The boundaries it polls are stage and per-file, plus, inside every phase where one file’s own work can run long: between a texture’s encode passes, between a map’s cooked collision geometries, per texel row (and per triangle while it walks the geometry) inside the lightmap bake, and per probe inside the GI volume and reflection-probe bakes. A canceled bake lands inside the bake, not after it, and produces nothing — no partial atlas, volume or cubemap set is ever packaged. The one stretch that still runs to completion once started is an FBX→GLB import: it is a single blocking call into an external Blender process, with no safe point inside it to stop without risking a half-converted GLB. The card clears itself the instant the compiler’s canceled report lands — there is nothing left to press Close on, the same as canceling a map load. A canceled build leaves the pallet on disk exactly as it was: the compiler writes to a sibling temp file and only swaps it in at the end, so there is no half-written pallet to come back to.
What it says when it finishes. The box stays up with one line — built in 3.7s, or the error that stopped it. A build that finished with warnings says how many and offers to show them; the list opens inside the box and scrolls if it is long, so a clamped collision hull or a look that did not resolve is something you read rather than something you find later in the log. A build that failed offers Rebuild beside Close, and a long compiler error scrolls inside the box rather than being cut off — the message grows the card up to a ceiling (client.ui.buildFailureMaxHeight) and only then starts scrolling, so a diagnostic that names its file and its whole reason is something you read in full, not something an ellipsis leaves you guessing at.
Every diagnostic still goes to the console on the build channel, spelled file(line): message the way a build log spells it. A build with no box watching it — one typed at the console — reports the way it always did: a toast, and one sentence.
The rebuild a failed map load offers raises the same box, over the failure it was started from. Succeed and the box goes with the failure it answered and the map load picks up where it left off; cancel and the offer comes back, because a build somebody stopped is not a load that failed twice.
When there is nothing to build, Build is drawn dead — no handler, no highlight — rather than dropped, exactly as an unwired menu row is. That is the case for a map read out of a pallet somebody sent you: the bytes are here, the source is not. The engine’s own bundled maps are the one exception, and only inside a checkout — see Editing the built-in maps. The test is whether the compiled .pallet the map came from sits in your workspace’s own output directory, which is a path comparison rather than a scan for a matching pallet.dh — a pallet’s id is what its manifest says it is, never what its directory is called, so the honest check would walk every source path each time a map loaded.
Saved is not built
Section titled “Saved is not built”Save writes the map’s source. Build turns that source into the pallet the world is loaded from. They are two steps because they answer to two different things — the file is yours the moment Save returns, and the world you are standing in came out of a pallet that was compiled at some point in the past — and the whole of the confusion this section exists for is the gap between them.
A build that fails changes nothing about your save. The compiler writes to a sibling temp file and only swaps it in at the end, so a failed build leaves the previous pallet exactly where it was: the world keeps loading the old bytes, and every edit you saved is still in the .dh-map on disk, waiting for a build that succeeds. The failure line in the build box says so in as many words, so “the build failed” is never read as “my edits are gone”. Quit and come back and the saved edits are still there — what you are looking at is last night’s pallet, not last night’s file.
The stale mark says that gap out loud. When the map’s source is newer than the pallet it was loaded from, the bar hangs a yellow dot off the end of the map’s name, beside the unsaved asterisk. It is the same dot the asset browser marks a stale pallet with — one thing to learn rather than two — and it stays up until a build of that map’s pallet succeeds. A toast says it in words the first time the map comes up that way, and it names both ways out rather than only warning: water-eval source is newer than its build; Rebuild in the editor or run dh build to see the saved edits.
Opening the editor never compiles anything, and never bakes anything. Staleness is shown, not acted on: the yellow mark and the toast stand there and Rebuild is the click. Entering the editor is not an instruction to build — a compile that starts by itself drags the lightmap bake along behind it, and an editor you cannot open without setting something off is an editor you learn to avoid opening. client.editor.autoRebuild, off by default, brings the old behavior back: with it on, opening the editor on a stale map raises the ordinary build — the same compile the Build button raises, the same box, the same hot reload when it lands — once per open, so a build that fails leaves the mark up and does not loop.
Loading a map to play never compiles anything either. A play session is not an authoring session and a compile it did not ask for is a stall it cannot explain, so there the mark and the notice are the whole of it — and the preference above cannot reach it.
A source the compiler would refuse is repaired on the way in. Two shapes the editor can write — a .dh-map with the same id on two entries, a .dh-mat whose water flow names no direction — stop every build of the pallet they live in. The repair for them runs as part of the build rather than riding along with a save, because a session that opens such a map and changes nothing has no save to carry it: every route to a compile — Rebuild, the failed-load offer, the opt-in editor-open pass — fixes what it finds before it compiles, and says what it fixed. Nothing else in the file is touched, and a sound file is left byte for byte as it was.
Editing the built-in maps
Section titled “Editing the built-in maps”The maps in core: — the debug map, the logic map — ship as compiled bytes inside the engine’s own content/core.pallet, so on any ordinary copy of the engine they are read-only exactly as a pallet somebody sent you is: Save is dead with the reason on the bar, Save a Copy… is offered instead, and Build is dead.
A host running out of the repository is the exception. It looks for the checkout it was built from — a bounded walk up from its own binaries, in any configuration, matched on content/core/pallet.dh rather than on a directory called content — and when it finds one, core: maps become editable like any workspace map, and that checkout is the source holder for them. The File menu then reads Save to core (n), so the item says where it goes: it writes Engine/content/core/maps/<map>.dh-map in the repo, and the toast says it saved into core. A core map is loaded from the compiled pallet rather than from its source, so Save then rebuilds the core pallet — the same in-process dh build --core below — and the map comes back through the pallet watcher’s hot reload, with the build box showing it. If a build is already running, the build the pallet to ship these edits note is raised instead and Build is the click that ships it.
That build is dh build --core Engine/content run in process — one implementation, shared with the CLI — so it excludes sounds/ and tools/ and pins its build stamp the same way. Rebuilding without changing anything leaves the committed core.pallet byte for byte identical, and git status says whether you actually changed the engine’s content.
client.dev.coreSourceDir names the checkout explicitly, and wins over the walk-up. Set it when the binaries are not inside the tree they were built from; empty (the default) leaves the walk-up to answer, and an engine with no checkout above it behaves exactly as a shipped engine does. A path that is set but is not a content tree resolves to nothing rather than quietly finding a different one.
Your own workspace wins over the repository. If you copied the core pallet into your workspace to change it, Save writes your copy — the checkout is searched last, so the repository’s content is only edited when nothing else claims that pallet id.
A map nobody holds is saved as a copy
Section titled “A map nobody holds is saved as a copy”Where no one in the session holds a map’s source — a core map outside a development checkout, or a pallet somebody sent that carries no sources — Save is dead, and says why: the bar reads no one holds the source; use Save a Copy…, and the File menu (and the map name’s own menu) offers Save a Copy… in the row under it. Ctrl+S does not do nothing and does not write anything unasked: on such a map it raises the same dialog.
The Save a Copy window asks for a pallet id and a map name, opening on the original’s name with a -copy suffix under local.maps. so a copy never reads as the map it came from. Confirm writes the map as it stands — with the pending edits applied — into your workspace as a new pallet of its own (<source>/<id>/maps/<name>.dh-map), builds that pallet and switches the session to it. A pallet id already in the workspace is refused, never overwritten. The original is untouched, and since the session leaves it, the unsaved-changes question is not asked.
A compiled map is turned back into source on the way: the part table the compiler generated is dropped, and every pallet-relative reference becomes an external reference into the pallet it was compiled in (an origin other than core is declared as a dependency of the copy). A map that places geometry from a pallet whose source is not on this machine builds with the compiler’s own error, because geometry is read from the pallet being built; the copy’s files are kept and the build box says so.
Save, and what “pending” means
Section titled “Save, and what “pending” means”Pending is derived, never accumulated. Nothing keeps a list of things you did. Every frame the editor asks one question of the live session — how far is this object from how its source has it standing, what is it called, and is it switched off — and the answer is the pending set. Three surfaces read that one answer: the asterisk on the bar, the marks in the Inspector, and the click on Save. They cannot disagree, because there is only one of them.
A transform is an absolute delta from the authored pose — offset, euler rotation and scale together — which is what makes this cheap and exact: drag a brush across the map and back, or turn it a full revolution, and it is not pending, because it stands as the file says it does. Each of the three answers for itself, so a saved turn is not undone by reverting a move. No snapshot of the map is taken, no protocol changed, and nothing has to be remembered across a frame.
Save writes the map’s own .dh-map source, surgically. It does not re-serialize the file: it locates the bytes it needs to change and carries every other byte through untouched. Your comments, your column alignment, your trailing commas and your key order all survive, because the writer never read them as JSON in the first place — it only located them. A malformed source is refused rather than rewritten.
A pose has two homes in that file, and Save writes both. An authored record is spelled out in the objects list, so its transform is a byte replacement over its existing fields, found by its authored id. A part of the world model has no record of its own, so its transform is an insertion: a part override appended to the world instance’s children, creating that list if the instance has none. Save the same part twice and the override it already wrote is updated in place — a part has one savable state, so it gets exactly one override, and the file can never hold two answers about it. The override carries the pose members the save states and no others, so saving one row rewrites the override without disturbing what the other rows put in it.
- A transformed part saves as a delta, not as a pose, because there is no authored pose to overwrite: the geometry’s placement is baked into its vertices. The override says “and half a meter up from wherever the GLB puts it, turned thirty degrees”, so re-exporting the model carries your edit along instead of fighting it.
- The override is written at its defaults. Anything you did not change is left out — no
rotationon a thing you only moved, noscaleon a thing you only turned — and a part put back exactly where it started has its override removed rather than written empty. - It names its part twice, by the part’s local
idand by itspath, so a rebuild that renames the node in the model still finds it by id, and an id the model’s sidecar no longer knows still finds it by path. That is how an override finds its part, not something the editor does differently. - A map made entirely of imported geometry is fully savable. That is the ordinary case rather than the exotic one, and it is the case this was built for.
- A moved or turned logic entity saves as an authored pose, through the same byte replacement a brush’s takes. A light, a button, a door, a plate or a physics prop is spelled out in
objectslike any other authored record, so there is no override involved: the number in the file becomes the number you dragged it to. For a door the pose you are writing is the closed one its travel is measured from; for a physics prop it is the pose the prop spawns at.
The rename and the whole-object switch have the same two homes, and Save writes both. An authored record gets a name and an enabled field written beside its id — inserted if the entry has none, rewritten in place if it does. A part has no record to write to, so both go into that part’s one override, alongside whatever the pose contributed: move a part, turn it, rename it and switch it off, and one Save writes one override saying all four things.
- Switching something back on removes the statement rather than adding one. Everything is in the world unless something says otherwise, so there is no
"enabled": trueto write: the field is dropped from the part’s override, and if the pose and the name had nothing to say either, the whole override goes with it and the file is back to not mentioning the part. An authored object’s own field is rewritten totrueinstead, because the field is already there and a hole in an entry is not tidier than atrue. - An authored-off object is off the moment the map installs, in exactly the state
scene.disableputs it in — not drawn, not solid, and switchable back on live. There is no second kind of “off” to reconcile with the first; the authored value is just where the session starts.
A deleted node saves as a removal rather than as a switch. Delete and the object switch do the same thing to the world — the node stops being drawn and stops being solid — and they differ in what the file is made to say: a switch writes enabled: false, a delete writes "removed": true on that part’s override, and the map that loads next simply does not have the part.
- A removal is idempotent: a file that already removes the part says everything a second save of the same delete would say, so nothing is written twice.
- A part you moved and then deleted owes the file the removal alone. The move is not written in the same pass: the override says
removed, keeps whatever else it already said, and gains nothing from a pose the part no longer has. - The removal names the part by its local
idand itspath, the same two addresses every override carries, so a rename in the model after a save still lands on it. - Only parts are removable. A brush or a logic entity is a record in the file rather than something an override removes, and nothing offers to delete one yet.
Static writes to the same two homes, following the same defaults-only rule. An authored record gets a static field beside its id; a part’s goes into its override beside the pose, the name and the switch. Only false is ever written — turning it back on drops the field (and the override, if nothing else survives it) rather than asserting true. It has no effect at runtime; the map installs identically either way. For a part it decides whether its geometry is in the next lightmap or GI bake; on every other record it is reserved, since nothing but the world model bakes today.
A retuned field saves too. A field edit — a light’s emission, a light probe volume’s grid, a door’s travel — is a scene edit the server replicates, so a lamp you brightened is bright for everyone watching the map, and it marks as pending like a move does, on the same derived question: what does this object wear, and is that what its entry says? Save writes each field you actually changed into that object’s own entry under the key its description names — color, intensity, probeSpacing, moveDistance — rewritten where the entry already spells it out and inserted beside its id where it does not. An object gets one mark rather than one per field: the entry is visited once and what a person wants to read is that the lamp changed. A light’s color is sRGB end to end, the face values the file is authored in, so re-opening the map gives you back the color you typed.
A light probe volume saves through the ordinary entity path. It is spelled out in entities like any other authored object, so its pose, its name, its enabled, its static, its probeSpacing and its insetProbes are all byte replacements found by its id — there is nothing volume-shaped about any of it, and no patch is involved.
A retuned reflection probe saves its capture settings, on the light’s exact terms. capturePoint, boxProjection, resolution, importance and blendDistance are written into that probe’s own entry, rewritten where the entry already spells a field out and inserted beside its id where it does not, and the probe takes one mark rather than one per field. Two things differ from a light and both are worth knowing. A probe is retuned as a set rather than as a sparse patch, so the baseline the next frame’s marks are measured against is the whole group of five. And the writer can rewrite a key or insert one but never delete one, so a field the file left blank is written resolved — a probe still sitting on the map’s inherited resolution saves the number that was actually in effect. A file that states what it was doing beats one that stays terse and then drifts when the default moves.
What a .dh-map still cannot hold is marked and reported anyway, never quietly dropped:
- A Renderer state cannot be written. It has no place in the save path yet, anywhere — the readout says so in those words rather than folding it in with the object switch above it. A collider is not in this list: it is a component its owner states, and every collider edit saves into the owner’s
components— a part’s one part override, alongside the pose, the name and the switch, or a brush’s or a plain object’s own record.
The toast says what happened either way: how many objects were written and to which file, and — when there were any — how many changes the map source cannot hold. A failure changes nothing. The live world is left exactly as it was, so a change that could not be saved is still there, still marked, and still yours to deal with.
After a successful save the marks clear without anything moving. The saved displacement becomes the new baseline the marks are measured against, rather than the offset being zeroed — zeroing it would snap every saved object back to its old authored pose, because the world you are standing in still came out of the pallet you have not rebuilt yet. Press Build after Save and the world catches up with the file.
That baseline is the room’s, not yours. The holder’s write is announced to everybody and every session derives its pending set against it, so a save clears the marks for the whole room at once. It has to be this way: pending is “how far is this from what the file says”, one file is what was written, and a baseline kept privately would leave every guest wearing an asterisk for edits that are already on disk — and offering to write them again.
Save and scene.save are not two names for one thing and neither supersedes the other. They write to different places: Save puts what you changed into the map’s source, where it becomes part of the map for everybody, forever — which is only possible for a map you own. scene.save puts hide/disable/delete state into the engine’s own per-map delta store, beside somebody else’s map without touching it. Positions only ever go the first way and hidden/deleted only ever go the second; on/off is the one fact both can carry, and it is not two answers — a delta is read over the map, so the authored value is the starting state and the delta is what a session did to it.
When Save is dead it is drawn dead — dimmed, in place — rather than dropped, exactly as Build is. Three things do it: nobody in the room holds the map’s source, so the click would travel nowhere; a save is already running; or there is nothing pending that can be written. That last one is why the number in the label is the savable count: a Save that offered to write six and wrote none would be a smaller version of the same lie. Note the first one is about the room rather than about this machine — the write happens on the holder’s disk, so not having the file yourself is not a reason for a dead row.
A map that inherits another
Section titled “A map that inherits another”A map with an inherits is shown folded: its parent’s sky, sun, look and material slots, and the parent’s records beside its own, exactly as the game loads it. Save still writes only this map’s own file, and only what you changed, so what reads through from the parent stays the parent’s:
- A rebound material slot is the child’s override. It is written into the child’s
materialstable, which the file grows (as its last member) if the map stated none of its own, and every slot you did not touch keeps reading through. - An inherited record is drawn, selected and inspected, but not edited. The Inspector’s name row says Inherited from
<parent barcode>: edit it there, its rows are read-only, no gizmo is offered for it and no Add Component either. Overriding or removing an inherited record arrives with the next inheritance lane, so nothing here writes one; an edit that reaches one anyway (a drag in the tree) is marked pending and counted among the changes the map source cannot hold, never written. - Save a Copy writes the map flat. The copy holds everything it read from the parent as its own and inherits nothing, so it never states a record twice.
Adding an object
Section titled “Adding an object”Add makes the thing and puts it in front of you. The new object lands client.editor.addDistance meters along the way the editor camera is looking, rounded onto client.editor.addGrid so a box added by eye still sits on the grid the rest of the map does. A light probe volume comes up client.editor.addVolumeSize meters on every axis; a light comes up realtime, with no scale at all, because a light has no extents and an authored scale would read as a size the schema does not give it. A reflection probe comes up the same size, for the same reason — it is a box too. A camera comes up with no scale either, and for the stronger version of the same reason — its transform is an eye and an aim. It gets a name nothing else in the map wears — lightProbeVolume, then lightProbeVolume2, and reflectionProbe and camera beside them — a fresh id, and it is selected the moment it exists, so the gizmo is already on it.
The server is what makes it, and everybody gets it. Add does not append to your own copy of the map — it asks the server to, over editor.create, gated exactly like every other scene mutation. The server appends the entity, numbers it after the map’s nodes, and it reaches every client in the session as part of one full-state set that also greets whoever joins later. So a light somebody adds is lit, pickable and movable at once, for the whole room, and two people editing one map are editing the same map rather than two drifting copies of it. A guest with no authority makes nothing, and the menu says so before they try.
It joins the loaded map immediately, which is what makes it a thing you can move rather than a line queued for a file. Every shell splices it in the same way, the iPad included: a light somebody adds on a desktop is lit on a tablet in the room the same frame, and a box is drawn and stood on there too.
Undoing an Add takes the object back out without a trace. Every kind Add makes is one history entry. Ctrl+Z sends editor.uncreate <id>: the server takes the object out of the map, its bodies and its runtime node go with it, every edit you made to it since is dropped, and every client in the room loses it at once. Nobody who joins later hears of it, and a Save afterwards writes nothing for it, so the source never learns it existed. Redo makes it again under the same id, at the place and size it was first made at. The object’s number is released rather than left as a hole: anything created after it moves down by one, and no object the map authors is renumbered, since every created object is numbered after them. Once a Save has appended the object to the source it is the source’s, and its undo is then the removal a Save writes, the same one Delete sends, with the Restore ledger’s switch as its redo.
A Box is map geometry from the first frame. Add → Box makes an object holding a unit dh.box (one meter on every axis) in the map’s first material, named box, box2 and so on, at the same placement point. It is drawn, solid and pickable for everyone in the room at once, with the exact-box collider the compiler gives every box, and it is one undo, like every Add: undoing it takes the box out without a trace and redoing makes it again. Its size and material are the Box card’s, edited live like any other box’s, and Save appends it as the boxBrush sugar.
A Button, a Door, a Pressure Plate or a Physics Prop is a box its logic drives. Add → Logic makes an object holding a dh.box in the map’s first material, its exact-box collider and the logic component: a hand-sized block with a dh.button, a person-sized slab with a dh.mover, a thin pad with a dh.pressurePlate, or a half-meter crate with a dh.physicsProp, named button, door, pressurePlate and physicsProp the way a box is named. Each is pressable, movable, touchable or pushable for everyone in the room at once, a target the wiring card can name the moment it exists, and one undo like every Add. Save appends it as every made box is appended, the boxBrush sugar, with its collider and its component in its components list; dh map add takes the same four kinds (dh.button, dh.mover, dh.pressurePlate, dh.physicsProp) and writes the same entry.
spawn lands where the editor is looking too. A bare spawn <asset> typed with the editor camera detached or ejected would otherwise arrive in front of the body you left behind — somewhere off screen, in a room you are not in. So while the camera is off the body the console fills the position in for you, off the same aim, the same client.editor.addDistance and the same client.editor.addGrid rounding Add places by — one helper, so the two gestures cannot come to disagree about what “in front of me” means — and what goes out is the ordinary three-number spawn the server already understood: nothing about the command changed, only who answered “in front of what”. Stating a position yourself always wins, and possessed — camera on the body — it is the player’s eye again, unchanged.
Save appends it, and this is the one write that adds an entry rather than changing one. The new object is spliced into the entities array — after the last element if there is one, into the empty brackets if there is not — carrying its $type, its id, its name, its position, and whatever else its kind is defined by. Everything already in the file is carried through untouched, exactly as a field rewrite is.
- One save writes everything you did to a created object. Its pose rides the append. Everything else — a rename, a lens, a grid, an emission, a rewire — is written by a second, in-place pass of the same save, once the entry exists for it to address: a rewrite aimed at an id the file does not hold yet is a hard save failure, so the two are ordered rather than combined. The toast and the marks describe both passes as one save, so a created and renamed camera is clean after one Save rather than after two.
- Saving twice does not append twice. Once the entry is in the file the object stops being created and becomes an ordinary authored one: move it again and the second save rewrites the entry it wrote, the same as any other object’s. The match is by
id— never by an object’s place in the list and never by a session-local “this one is new” flag, which is exactly what once let a second save append a second copy of the same camera. The writer asks the file whether it already carries that id, and an id the file carries is rewritten in place whatever the session believed about it. - A source that already carries a duplicated id is repaired on the next save. The first entry keeps the id, since that is the one existing references point at; every later twin is given a fresh id and a fresh name, and the save logs which ordinals collided. A map with two of one id is a map the compiler refuses, so it is fixed rather than picked between.
- It keeps the identity it already had. The file appends it after every authored entity, so after a Build and the reload it comes back with the same id — not as a new object that happens to look like the old one. Its object index moves from after the nodes (where a session numbers what it created) to after the authored entities, and the reload carries every edit across by id, not by index.
What is being edited
Section titled “What is being edited”The map’s barcode sits in the top center of the bar, split the way the console’s maps command splits it — but short: the bundle prefix and its colon in the faintest ink, debug-map — the part you would say out loud — in the brightest, and no extension at all. Every map is a .dh-map; three characters of gray that are true of everything on the shelf are three characters of noise between you and the name. The two inks are client.editor.mapPrefixColor and mapNameColor. The editor is a mode you can be in for an hour, and “which map is this” is the one question the picture on screen cannot always answer.
Rest the pointer on the name and a plate drops under it spelling the whole barcode out, extension included — the exact string a map line is typed with. It is held behind a short dwell (client.editor.tooltipSeconds) because the name sits across the middle of the strip, which is where the cursor crosses on its way to the menus; it leaves the instant the pointer does, with no second dwell to sit through. The pointer wears the hand over the name, which is the only run of text on this bar that answers one.
Click the name with either button and a menu opens at the pointer, the way every context menu in the editor does — the same panel the menu bar’s own pull-downs are, raised on the map instead of on a heading, because everything on it is about that map. Save, Discard Changes and Build are the File menu’s own three rows, off the same constants and reaching the same seams, so they carry the same labels, the same counts, and go dead under the same conditions. Under a divider, Select in Assets shows the map’s own file in the Assets panel: the panel’s tab is brought to the front wherever it is docked or floating (and opened the way the View menu opens it when there is none), the folder holding the .dh-map is opened in the tree and the listing, and the file is selected and scrolled into view. Copy Barcode, Copy Source Path and Copy Pallet Path put the map’s full barcode, its authored .dh-map in the workspace, and the built pallet it was loaded out of on the clipboard. A path the host cannot name — a map out of a pallet somebody sent, with no source in this workspace — leaves its row dead in place rather than dropping it. A second press on the name moves the menu to the new point, and Escape or a press anywhere else shuts it. The barcode plate stands down while the menu is up: both hang under the same word, and a tooltip spelling out a string the menu is already offering to copy would be explaining a surface you are already looking at.
The readout is also a little larger and heavier than the menu headings beside it (client.editor.barFontSize), and the unsaved asterisk is larger still (client.editor.pendingMarkSize). The name is set in a genuine bold cut — the family ships two files and the atlas bakes both, so the weight is ink the type designer drew rather than a second pass of the same run nudged sideways. That trick was tried here first and rejected: at interface sizes the strokes it thickens are about a pixel wide to begin with, so the copy reads as a blur rather than as a heavier stem. Every run on the bar is drawn once.
The asterisk is sized on its own axis rather than as a multiple of the name, and it is meant to be big. It is a mark and not a letter, so it is centered optically on the name’s line rather than sitting on its baseline, and it is laid out in a box the size of that line and allowed to overhang it. Raising pendingMarkSize therefore changes how big the star is and never where the row sits.
It is centered on the display, not in whatever room the row of menus and the selection readout have left over, so it does not drift sideways as the selection’s name grows and shrinks: the bar is three columns, the name between two halves that share out the rest of the bar equally whatever they hold. The menus keep their full width on the left, and the right half is where the selection readout lives, so a long selection gives way before it reaches the name rather than running under it. It takes no left click. With no map loaded there is nothing to say and the bar says nothing.
The menus behave the way every menu bar you have used behaves. Click a word to open it, click it again to shut it. With one open, sliding onto the other word switches to it — no second click. Pressing anywhere else, or Escape, puts it away, and the press still reaches whatever you aimed it at, so dismissing a menu never costs you the click you actually wanted.
Nothing in those menus is a placeholder. A row is either wired or visibly dead — no handler, no highlight under the pointer, in its final place at its final size. That is the opposite of a grayed-out promise: the shape of the bar never depends on how much of it exists yet, so nothing moves under your pointer between builds. Tool chips and further entries still arrive with the edit operations they would invoke.
Keyboard shortcuts
Section titled “Keyboard shortcuts”Seven chords and five bare keys reach the menus and the tools without opening anything, and they are live whenever the editor is:
- Ctrl+A — Select All.
- Ctrl+Shift+A — Deselect All.
- Ctrl+I — Invert Selection.
- Ctrl+S — Save, and only ever the File menu’s Save: a map with no workspace source or a session with nothing savable refuses the chord exactly as it draws the row dead, and a toast says which. It works wherever the keyboard is — after a click on a chip or a dropdown, and inside a name or coordinate box too, which keeps the caret after Enter commits it. What the box has committed is saved; a half-typed value stays in the box, and pending. Only the console’s prompt and the chat line keep the chord.
- Ctrl+Z — Undo.
- Ctrl+Y, and Ctrl+Shift+Z — Redo. Both, because half the tools a level author has used bind one and half bind the other, and the hand that reaches for the wrong one should not be told it pressed nothing.
- Delete — Delete, and only ever the Edit menu’s Delete: a selection holding nothing deletable deletes nothing and says so, exactly as the row draws dead. It is bare rather than a chord, because that is the key every tool a level author has already used binds this verb to, and a destructive verb should not need a chord a hand can mistype.
- W, E, R, T — the Scene panel’s four modes: Move, Rotate, Scale, View only. Bare for the same reason Delete is, and these letters in particular because they are the ones every other level tool binds. Taking a mode with a key does exactly what clicking its segment does, View only’s cleared selection included.
- X — cycles the handle space between Global and Local. Bare and beside the mode keys, because it asks the same kind of question they do: what a drag in the viewport is about to mean.
W is the camera’s forward while you are flying. The tool keys act only when the pointer is the editor’s and the right mouse button is not held — the same rule the framing key F follows, and the same rule Unity’s. Hold the right button and W/A/S/D fly the camera as they always did; let it go and W is the move tool again. Nothing about the press is remembered across the button, so a W typed mid-flight is a W spent on flying and nothing else.
A field with the caret in it keeps every one of them, the four letters emphatically included — W, E, R and T are four of the commonest letters there are, and a node’s name typed into a box must not switch the tool four times. Typing a coordinate into the Inspector, or a line into the console, means Ctrl+A selects that text and Delete removes the character in front of the caret — the shell stands down for the same reason it stands down for the Enter that commits the box. Ctrl+Z is withheld there too, and withheld rather than repurposed: a chord that took back the last object you moved while your hand was in a text box would be the most surprising key in the editor. A person clearing a coordinate box must not also lose the object the box describes. None of the chords means anything without Ctrl held, so A is still your strafe key and Z is still yours.
What is selected
Section titled “What is selected”The right end of the bar states the selection, just ahead of the ✕: how many things are selected, faintly, and then the active member’s name in the accent — the two facts every operation about to be performed depends on. It takes the settings rows’ treatment when the bar runs short, at 2x on a laptop for one: the count keeps its floor and never wraps, and the name is the asset label every barcode in the editor is spelled by (AssetLabel), so its pallet prefix elides from the left first and the name itself is cut at its own edge last. With nothing selected it says Nothing selected rather than vanishing, because “nothing is selected” is one of the things a bar most needs to tell you; a readout that disappears when it has bad news is a readout you stop trusting.
Beside it is Clear, which empties the selection. It is a plain bar item rather than a second glyph box: a square carrying an ✕ next to the square that leaves the editor would be two destructive corners in a row, told apart only by a letter. With nothing selected there is nothing to clear, so it is drawn dead — no highlight, no handler — exactly as an unwired row is.
The console badge
Section titled “The console badge”Right of Clear, just ahead of the ✕, three counters say what the console is holding: an i for ordinary lines, a triangle for warnings, an octagon for errors, each with its number beside it.
A counter with nothing in it sits in the bar’s faint gray — mark and number together. A counter with anything in it takes its own ink: white-neutral for info, amber for warnings, red for errors. So a bar with nothing wrong on it is three gray shapes you never look at, and the moment something goes wrong the color arrives before the number does.
The three are different silhouettes and not one shape in three colors, because at rest they are all the same gray and that is exactly when you still have to be able to tell which is which. They are drawn as shape lattices rather than as glyphs — the font atlas bakes printable ASCII and nothing else, so a triangle typed as text would come out as tofu. The lattices live with the console rather than in the editor module, since the console header’s severity filter draws the same three signs.
The counters state what the console holds right now, not a running total: a line that has scrolled off the end of the scrollback stops being counted, and clear in the console takes all three to zero. 999 is the ceiling; past it a counter reads 999+ rather than growing the strip until it walks into the ✕.
The whole strip is one click target and it is a two-state switch for the console: a click puts the console up — opened, raised and given the keyboard — and the next click puts it away again. The finer ask lives in the console’s own header, whose severity filter wears these same three marks and counts, one switch each.
With the console up the badge stands in the accent wash every already-open thing in this editor wears, so it says the console is up without being pointed at; under the pointer it goes one ground louder. The lit state is read from the console’s real visibility every frame rather than from the badge having raised one, so a console opened or closed by the key, by a command or by the menu moves the badge on the same frame. A badge with nothing wired behind it still shows the lit fill — it is a readout of where the console is, and only the click goes away.
The three inks are client.editor.consoleInfoColor, consoleWarnColor and consoleErrorColor.
Everything solid in the world can be picked: the map’s compiled geometry, the map’s authored boxBrush slabs — turned ones are picked where they are drawn, not anywhere inside the box around them — the map’s logic props, and every live entity, players included, your own frozen body among them. A player is picked by the body, not by the point their position is replicated at: that point is the soles of their feet, so aiming at a person’s chest is what selects them. Fly the camera inside a body and it stops being a target, because a body you are standing in is a body you cannot see.
A button, a door, a pressure plate and a physics prop are picked by the box they are drawn as — the authored extents from the same catalog the renderer resolves, turned with the fixture, so a rotated door is met at its face rather than anywhere inside the box around it. And the hit names the map’s own object, not the session’s wire id: a click on the door and a click on its row in the Hierarchy are one selection, which is the whole of how the entity surfaces are keyed. The kinds with no drawn shape — a timer, the gates — have nothing for a ray to meet; their row in the list is how you reach them.
A light and a light probe volume are picked by shapes they are given, because neither draws anything a cursor could meet. A light is a sphere of client.editor.lightPickRadius at its emission point — a directional light included, which is picked where the map authored it — so clicking a lamp is clicking the lamp rather than aiming at a dot. A light probe volume is picked by its box, and it is ranked at the box’s far wall: a volume is a region, routinely authored around the very lights and walls you are trying to click, so it stands behind everything it contains instead of swallowing it. Fly inside one and it is still the thing behind the room rather than the thing in front of it. A volume is only a target while it is on screen at all — see Lighting for what puts it there.
A selected or hovered thing wears the same ring everything else does, and a logic prop’s is the box it is drawn as. The outline is the editor’s one selection cue: the yellow active stroke, the white hover stroke, the black backing and the dimmed pass where the mark is behind a wall. A button, a door, a plate, a prop and a lamp’s fixture are ringed as the very box the frame drew — a pressed button’s ring sinks with the button — through the same lane the Use-highlight rings a target in play. A volume’s ring stands on its bounds; a light with no fixture, a timer and the gates draw nothing to hug, so theirs stands on a small marker box of client.editor.lightMarkerSize. All of them read depth. A camera’s ring turns with the camera, since a camera is the one box of the three that is allowed a rotation at all — it is the handle you grab rather than a region, so an upright cage around an aimed camera would disagree with the cone leaving it. It is the same turned box the pointer picks it by.
And both announce themselves before you have found them. With the editor open, every light carries its dot and label, and so does every light probe volume — at the center of its bounds, in client.editor.volumeMarkerColor rather than the light dot’s green, so a room full of both is legible at a glance. The dots are markers and nothing more: what a click lands on is the pick shape above, which is a separate thing on purpose.
Everything else about the selection is what the two View windows are for.
The Gizmos menu
Section titled “The Gizmos menu”One split button on the Scene toolbar owns every world gizmo. Gizmos sits beside the Global / Local switch. Its body toggles every gizmo on or off (lit when they are on, an outline when they are off); its chevron segment — a full-height segment of its own, with its own hover, press and click — opens the menu, and the body never does. The manipulator you grab to move, rotate or scale is a tool rather than a decoration and is not switched by it.
Every setting is a persisted preference, so the menu holds nothing but whether it is open, and a console line and a click are the same write:
| Row | Preference | Default |
|---|---|---|
| Gizmo size | client.editor.gizmos.size | 1 (0.5 to 2): a multiplier on every dot and label |
| Show within | client.editor.gizmos.showWithin | 60 m (5 to 500) |
| Labels within | client.editor.gizmoLabelDistance | 30 m |
| Fade with distance | client.editor.gizmos.fade | on: gizmos ramp out over the last quarter of their reach instead of stopping at it |
| Declutter labels | client.editor.gizmoLabelDeclutter | on |
| Selection outline | client.editor.gizmos.selectionOutline | on: the selection’s outline; what the cursor is over keeps its own |
| Hover names | client.editor.hoverNames | off: the View menu’s own switch |
| The body | client.editor.gizmos.enabled | on |
Under Kinds is one row per thing the editor draws, each with its icon and a Shape and a Label box (Label only where the kind has names): logic objects, lights, spawned assets, probe volumes, reflection probes, camera frustums, logic wires, colliders, bones, the probe preview and the selection pivot. The list is GizmoKinds (Engine.Client), the one table the menu lists and the overlay reads, so a kind is named, iconed and switched in one place. A box is the kind’s own preference: colliders’ Shape is client.debug.colliderGizmo and bones’ is client.editor.boneGizmos (the inspector’s Show bones box), so one fact has one switch; every other kind’s is client.editor.gizmos.<kind>.shape and .label. A Shape box gates a kind in addition to whatever asks for it — the editor still draws logic gizmos without client.debug.entityGizmos — and a cleared Label keeps the dot and drops the name, even a name the bone label rule would have pinned.
On a short screen (an iPad at 2x) the kind list scrolls inside the room the popup has rather than running off the bottom.
The gizmo labels
Section titled “The gizmo labels”A dot reaches farther than its name. Every entity gizmo’s dot is drawn out to client.editor.gizmos.showWithin (60 m), but its label only to client.editor.gizmoLabelDistance (30 m), fading over the last quarter of that like the dot fades over its own. A Source map places a prop every few meters, and labels as far as the dots put sixty unreadable names on one screen of gm_fork.
A crowd keeps the names nearest you. With client.editor.gizmoLabelDeclutter on (the default), labels are placed nearest first and one whose plate would come within client.editor.gizmoLabelGap (2 px) of a plate already placed is left off. Its dot stays. A bone the label rule names is exempt from both: that rule already chose it, so it is drawn wherever its dot is.
The Gizmos menu drives these and every other gizmo setting through one settings object (GizmoSettings, Engine.Client) that every surface drawing a gizmo reads, so a switch reaches all of them at once.
The wiring lines
Section titled “The wiring lines”With the editor open, a map’s wiring is drawn over the world — a line from every output owner to each entity its wiring reaches, in the Tooling band with the dots and labels. It needs no console line: client.debug.entityGizmos keeps its own meaning, which is “show all of this during ordinary play as well”.
The lines wear the interface’s own inks rather than a hue the debug overlay picked for itself:
- A wire touching the selection wears the selection accent — the same one token everything else in the editor selects with, so a wire and a ring never state the same fact in two hues. Select a door and the whole of what drives it lights up at once.
- Every other wire recedes to the chrome’s muted line ink at
client.editor.wireDim(0.3). Dimmed, never hidden: how a level is wired is a picture worth keeping, and hiding the wires you did not click would answer a question you did not ask. - With nothing selected, every wire is at full strength. Nothing is being singled out, and a uniformly faded diagram is only a dimmer diagram.
client.editor.wireThickness(1.5device pixels) is the line’s width. Fixed pixels, like every other number in the gizmo overlay: the wiring is a diagram laid over the world, not a thing in it.
A trigger brush is a wire end like any other, drawn from its authored center, so a brush dragged this session keeps its line at the pose the map states until the edit is saved. A brush that fires nothing is scenery and draws no line, whatever it is called.
The lines are the TRANSITIVE flow, not the map’s direct wiring: a chain through a timer and an orGate is followed to the placeable entity on the far side, because neither carries a world point a line could end at. The Inspector’s Wiring card is the other reading — what the map literally says — and the two are deliberately different pictures of one fact.
Options
Section titled “Options”Editor → Options… raises a small window with the editor’s own settings. It is the same window chrome the developer overlay’s windows use — same title bar, same close box, same drag, same dress: a flat dark title bar, and a fold box and close box that are small rounded chips resting in the theme’s neutral well. Both answer the pointer at the same strength and in different hues: the close box goes red, the fold box goes to the accent. Folding a window away is the least destructive thing its chrome does, and two squares side by side that both turned red would hide which of them discards your window. Nothing in the chrome is tinted to say whose window it is — a colored header would set this one apart from every other window in the program. What tells you whose a window is, is the name on it. Drag it by the title bar, close it with the ✕ in its corner, or press Escape, which puts away the top-most thing you raised: the pointer back from a focused game view before anything else, then this window, then a selection — and the pause menu before all of them, since that menu covers the editor entirely. The Hierarchy and Inspector windows are not closed by Escape — they are your workspace, not something a stray press should take down.
It is also resizable and collapsible, and both are capabilities of the shared chrome rather than anything the editor built for itself:
- Drag any edge or corner to resize. The bands are generously wider than the hairline border they sit on, the corners win the region they share with their edges, and the pointer tells you which is which. Only the edge you grabbed moves, the window stops at a minimum instead of turning inside out, and it can never be dragged so its title bar hides under the toolbar.
- The − beside the ✕ shades the window to its title bar, and the + brings it back. It wears the bar’s ordinary hover rather than the close box’s red, because folding a window away is the least destructive thing its chrome does. The ✕ still closes a shaded window, and the grips go away while it is shaded — there is no body to size.
- Both are opt-in per window. A window sized to its content stays that way; a window whose content benefits says so. Options benefits on both counts: it floats over the thing it is adjusting, and its sensitivity control is a slider whose precision is its pixel length.
The size you choose is kept for the session, so reopening the window hands back the fit you picked. Where it sits is not: it opens in the middle of the room the bar leaves — centered on both axes, not hung from the strip — every time, unshaded, because a question that reappears wherever you dismissed it three resolutions ago is a question you have to go looking for.
Today it holds these controls, all applying on the very next frame and all the same preferences you can set from the console:
- Look Sensitivity — degrees of camera rotation per pixel of mouse movement (
client.editor.lookSensitivity). - Wheel Dolly Speed — fraction of the focus distance one wheel notch covers (
client.editor.dollyStep). - Undo History Depth — how many acts the history keeps before the oldest falls off the back (
client.editor.undoDepth), on the same slider control the two above use, over10–1000steps. Lowering it below what is already recorded trims on the next act rather than the moment you let go of the handle. - Bounding Box Picking — a switch that trades the triangle-accurate pick for the coarse box one (
client.editor.pickBounds). It is named for the mode it turns on rather than for the accurate one it turns off, so the switch and the console line cannot disagree about which of them is “on”. - Undo Selection Changes — a switch deciding whether a change of selection is something the history remembers (
client.editor.undoSelection, on by default). Off, Ctrl+Z walks straight past every click to the last thing that changed the map.
The bar itself takes the pointer; everything below it does not. The editor’s screen root spans the viewport so the bar can hang off its top edge, but it is a frame rather than a surface — clicks anywhere but on the bar reach the world underneath, which is what makes right-drag flying work with the toolbar mounted.
The bar arrives on the chrome transition rather than the house panel one: 240 ms, no stagger, and it slides down out of the top edge it is welded to. The panel recipe front-loads its motion into the first 75 ms, which is exactly what makes a dialog snap into place and exactly what makes a strip the width of the display look thrown at the top of the screen. Entering and leaving the editor is a change of mode, and it is paced like one.
Lighting
Section titled “Lighting”Map → Lighting… raises a small window that says what the loaded map’s lightmap currently is, what the next bake will obey, and offers the three verbs that make, discard and inspect one. It wears the same chrome as Options — drag, fold, resize, ✕ — and opens centered in the room under the bar for the same reason.
Its first line is the running state, and there are three of them:
- Atlas bound — an atlas is uploaded and being sampled right now. The window then also carries the atlas’s own size in texels and how many lights it baked, so “which bake am I looking at” is answerable without a console line.
- No atlas bound — the map was built with lightmap UVs but nothing is uploaded. This is where a Clear Bake leaves you, and it is the state a fresh bake can drop straight into.
- No lightmap UVs — build the map first — the loaded map carries no lightmap coordinate at all. A bake still runs and can still be looked at; what it cannot do is land on the world, because an atlas is packed against a UV set and there is none here to pack against. Building the pallet is what creates one.
Under those sit the map’s authored bake settings — bakeSun, bakeAmbient, samples and texelDensity — as read-only rows, with a line saying where they come from. They are properties of the map rather than of the session looking at it, so they are authored in the .dh-map source under lighting.lightmap; a window that could write them would be a second place a map is edited from. The one thing the window does write is the request itself — see Render Bake below.
The three actions are null-means-disabled, the same rule the File menu’s rows follow: a verb that cannot be used is drawn dead in place rather than dropped, so the window’s shape does not move as a bake starts and finishes.
-
Render Bake bakes what its two switches under the chips name — Lightmap and Probes, both on by default and kept as
client.editor.bakeLightmapandclient.editor.bakeProbes— and Clear Bake clears the same. With both on, the light probes are gathered through the lightmap’s own bake of every (zone, set), each set’s probes reading that set’s finished atlas for their bounce, are written beside the source as<map>.dh-map.dh-lightprobesand are swapped onto the running world when they land. The rest of this entry is the lightmap’s half. It bakes the loaded map on this machine’s GPU — a Vulkan compute kernel tracing a software hierarchy, with the CPU reference as the fallback when there is no device — in the background, and reads Baking… while it does. The result is written beside the map source as<map>.dh-map.dh-lightmap, and that file is the lightmap: Build packages it and never bakes. A missing or stale file (baked against different geometry) is a warning and an unlit map, never a failed build or a refused load. The bake reads the session’s own materials — alpha cutouts, textured albedo for the bounce, emissive surfaces — so it lights with the map as it is loaded, and the card’s first step readsreading materialswhile their textures decode; the log names any slot that did not resolve, which bakes as a neutral gray. The GPU work is sliced into dispatches sized towardclient.editor.lightmapBakeDispatchMs(40 ms by default; 4 ms at the gentle pace below) and pumped once a frame, so the editor keeps drawing and no submit trips a driver watchdog. No dispatch runs any one sample’s whole walk: a lightmap sample’s lights, sky and occlusion rays, a bounce texel’s gather rays and a probe’s sphere are all split across submits, fewer rays per sample before fewer samples, because one sample or probe can outlast a submit by itself; sliced or whole, the lightmap and the probes come out the same to the byte. The console commandlightmap.bakedoes the same thing, and the host’s--bakeLightmaps --map <id>(and--bakeLightProbes, the Probes switch) bakes headless and exits (with--renderit photographs the result). Progress is on the frame’s progress card — the same card a pallet build uses — and a running build outranks it there, because a build is the thing you are waiting on. The card names the phase —loading geometry,packing charts,baking,writing— and its bar advances inside each, so the long stretches are legible rather than a bar that sits still through a mesh conversion. A map whose bake zones have sets bakes every set in the same press, and the card’s second line names the zone and set being baked (zone 'Room', set off), as the log does. Every refusal a bake can make from the map document alone is made before a byte of geometry is read: a map that enables the lightmap but bakes no light is answered in milliseconds, not after the twenty seconds it takes to load a city and reach the same conclusion.A bake runs gently by default, and one press switches it to full speed. A third switch under the chips, Full speed, is the bake’s pace,
client.editor.bakePace: off (the default) is Gentle, which leaves room for a game or a headset running beside the editor — short GPU dispatches with an idle gap after each, the CPU work on half the cores, and the process at below-normal priority on Windows (the pace). While a bake runs, its progress card carries the same choice as a button beside Cancel, labeled with the pace it runs at now (Gentle or Full speed), and the card’s phase line starts with it (gentle pace · zone 'Room', set off). Pressing the button switches the running bake at its next GPU dispatch and its next parallel loop, with no restart, and logsbake pace switched to …. Both the switch and the button write the preference, so the next bake — and the next session — starts at whichever pace was chosen last. A canceling card drops the button, since there is nothing left to pace.Cancel stops it. The press is logged the moment it happens, the card’s step line changes to
canceling…and the bar returns to a sweep — a determinate bar frozen at the fraction of a bake nobody is waiting for is the one thing that reads as a hang. The baker polls for the cancel per texel row, per chart and per dilation row rather than per triangle, so the worker returns in tens of milliseconds even on an 8192² atlas where a single triangle’s raster can run for the better part of a minute. The one stretch that cannot be interrupted is an FBX→GLB conversion, which is a separate process with no token to hand it.A finished bake logs its atlas size and texel density before the raster starts, and its per-phase seconds on the completion line, so the next slow bake is diagnosed from the log rather than from a stopwatch. Dead when there is no map source to bake. Pressing it is itself the request: a map with no
lighting.lightmapblock gains one —"enabled": true, and nothing else, so every other bake setting keeps following its schema default — and the same press goes on to bake against it, rather than refusing and sending you to a text editor to type the field the button already implies. A block that exists but is switched off has only itsenabledflipped; everything else you authored in it is left alone. Like Clear Bake, the write is a byte splice, so your comments, spacing and property order survive it. -
Clear Bake drops the bound atlas from the running world and deletes the
.dh-lightmapbeside the map source, so the next Build ships the map lit in real time. The map source itself is not touched. With no bake beside the source it only drops the atlas, and the window says so plainly.An imported lightmap (
lighting.lightmap.origin: "imported") is handled differently, because it cannot be regenerated without the source file it came from. Render Bake andlightmap.bakerefuse it, in a toast and in the console, withthis lightmap was imported (origin: imported); remove origin to bake it here, which replaces the import: nothing is overwritten and nothing is changed behind your back. Clear Bake asks first, in a dialogue with Clear Bake and Keep (Escape is Keep, and a click on the backdrop answers nothing); clearing only the probes does not ask. The Probes switch still bakes on an imported map once the Lightmap switch is off. -
View Lightmap opens the bake in a picture window — the one this session made, or the one the pallet build shipped. A shipped atlas is not held in memory to make that cheap: the first click reads it back off the pallet on a worker and the verb reads Decoding… while it does, then the window opens on its own. Dead only when the map names no atlas and nothing has been baked.
A finished bake is hot-swapped onto the running world only if it matches what is uploaded — same UV set, same vertex count. Anything else is a bake against geometry that has since changed, so it is kept as a preview and a toast says to build. Either way the window then carries Preview only — build the pallet to ship this bake. That note and the bar’s unsaved asterisk are different statements: the asterisk means the map source has changes a save would write, and this note means the world is showing light the compiled pallet does not carry. A bake normally writes nothing to the source, so it normally raises the second and never the first — the exception is the press that has to author the lightmap request, which genuinely changes the file and so raises both.
The picture window
Section titled “The picture window”View Lightmap opens a plain window with one image in it — the same window a texture opens in, from a material’s channel or a browser tile. There can be several at once, because comparing two bakes means holding both on screen. The picture fills its plate with no margin at all — a margin around it is a margin taken off the thing being looked at — and it opens fitted, so a square atlas in a window dragged wide is centered rather than stretched. From there it is navigated exactly the way the material preview is, on the same arithmetic: middle-drag pushes the picture with the pointer, the wheel magnifies about the cursor so the texel under it stays under it, F or a double click or the Fit chip reframes it, and 1:1 puts one texel on one interface pixel. The bar along the bottom reads the image’s size and the current magnification, and the checkerboard behind a transparent image scales with the picture so its squares stay readable at any zoom. The range is client.editor.imageViewerZoomMin to client.editor.imageViewerZoomMax, and a notch and the travel limit are the material preview’s own client.editor.materialPreviewZoomStep and client.editor.materialPreviewPanLimit. Nothing about a viewer is remembered between sessions: it is opened to answer a question and closed once it is answered.
What it shows is a tone-mapped sRGB decode of the atlas, not the atlas’s own bytes. A lightmap stores linear HDR irradiance (shared-exponent, plus a direction layer the preview leaves out) and holds values well past 1, which as a picture is a white rectangle. The decode multiplies by client.editor.lightmapPreviewExposure, runs a Reinhard curve and encodes to sRGB, which is a photograph of the bake and deliberately not a measurement of it — read the real numbers with client.render.debugView lightmap on the surfaces themselves.
Every picture the interface can draw takes one of a fixed set of texture slots the UI backend reserves at startup, client.render.uiTextureSlots, 32 by default. It is read once when the backend is built — a descriptor array is sized, not grown — so changing it takes a restart. Baking repeatedly costs one slot rather than one per bake: the preview is uploaded into the slot it already has whenever the atlas size has not changed. Running out is not an error and never a crash; the image simply has nowhere to live and the viewer opens empty.
The Hierarchy
Section titled “The Hierarchy”View → Hierarchy raises a tall window against the left edge, under the bar. It is the same chrome the Options window wears — drag it by the title bar, resize it by any edge or corner, shade it to its title bar with the −, close it with the ✕ — and its close box is the menu row: closing the window unchecks Hierarchy in the View menu, because one flag deserves one answer rather than two switches that can disagree.
It lists one tree: the map’s records, each filed under the record its parent names, in the order the map authors them.
- Records file by
parent. A folder holds what names it; a light, a box, a door, a camera or a spawn point under a folder sits one indent under it, and a record with no parent sits at the map’s top level. Nothing is sorted: the authored order is the order the person who wrote the map sees in their own tool. A parent the map does not hold, or two records naming each other, files the record at the top level rather than losing its row. - Every model instance is a row with its parts under it. The map’s world instance is a row named
world, and its children are its parts, from the compiled part table, flattened into names with an indent:district/block03/wall_northreads asdistrict→block03→wall_north. A second placement of a model is a row of its own over its own parts. - A part filed elsewhere stands where it is filed. A part whose override names a
parentis listed under that record — a folder, an object — and takes its own children in the model with it; its old place in the model is empty of it. - An object instance carries its source’s pieces, nested the way the object nests them (below).
- What no map authored — players, dropped props, runtime spawns — closes the list, marked
Spawned.
Anything with something under it carries a chevron you can click to fold the branch away; which branches are folded is remembered by the row’s path — a record’s by its id — rather than by its index, so a spawning entity never drifts a fold onto its neighbor, and a renamed or re-filed record keeps its fold.
The tree is headed by a root row wearing the map’s name — the container every node originates from. It is a real selection, not a heading: clicking it opens the inspector’s root arm (below), and F frames the whole map. It is the one thing you can select only from the list — nothing in the viewport resolves to it, since no ray meets a container — and it always selects alone: ctrl-clicking it replaces the selection rather than joining one, shift-ranges skip over it, and Select All never includes it.
Every branch starts folded. Opening a map hands you its roots alone, with the world instance left open so its top-level parts show, rather than everything it contains — you expand toward what you want instead of hunting through a wall of rows nobody asked to see. What you fold and unfold is session state, not saved anywhere: opening a different map folds everything shut again, but a hot-reload of the same map — a pallet saved and picked back up — leaves your open branches exactly as they were, since nothing about what map is loaded actually changed.
Grouping is a lens
Section titled “Grouping is a lens”Editor → Options → Group Hierarchy by Component (client.editor.hierarchyGroups, on by default) gathers the same rows another way: the model instances’ trees stand first, and every other record is listed under a heading for the component it carries — Boxes, Lights, Logic (buttons, doors, plates, props, timers, gates), Cameras, Probes, Objects (object instances, spawn points, plain objects) and Folders — with what no map authored under Spawned. A part filed under a record stays under it. Off lists the authored tree above. Both list every record and every part exactly once; the lens changes their order and nothing else, so a drag in either writes the same parent. It is on by default because the maps imported today are hundreds of flat records with little authored structure, and a heading is what makes one of them findable.
The groups the scene was built with are real rows. A mesh-less node in the GLB — the empty a modeler parents a building’s walls to — is carried in the part table as a structural part with an id and an objectId of its own, so clicking it selects it, it can be renamed, and its switch turns the branch off. What it cannot do is move, turn or resize: a structural node is a name in the hierarchy rather than a thing in the world, so the Transform card offers it nothing to type into.
A row the part table names nothing for at all — an intermediate name a path implies but no node in the GLB carries — is still a group, and a group selects nothing. It is a piece of a string rather than an object, and a click that selected “whatever the folder happens to contain” would be an operation nobody asked for. A node that also has children is one row and still selectable, structural or not; it just gains a chevron.
Clicking a parent row selects it. The chevron is the only fold. A row that both selected and folded would make one of the two an accident, and the one you did not mean is the one that scrolls the list out from under you. The whole chevron column is the target rather than the glyph — client.editor.hierarchyChevronHitWidth, 18 authored pixels by default — and it lights on hover so it can be found before it is hit.
Every row carries a kind mark, and a filled mark means linked. The small glyph between the chevron and the name says what the row is — the map root, a group (a folder wears its glyph), a node, a brush, a volume, an entity, a mesh, a bone — and it is drawn outline in the row’s ordinary ink for anything the scene owns itself. A row whose entity is a linked instance — a map instance, the world instance itself, a spawned dh.object or dh.avatar, something that inherits a definition living in a pallet rather than in this map — takes the filled variant of the same glyph, in the editor’s own linked ink. That ink is a token of its own, derived from the interface theme seed rather than from the accent, so “this is linked” can never be read as “this is selected”; retune it directly with client.editor.linkedColor. A disabled row still dims to the disabled ink — absent outranks linked, since a switched-off instance is not doing anything for you to open. The slot after the name says Spawned, in the pending ink, on everything the world is holding that the map does not: a spawned asset, a dropped prop, another player. The rule is has no map object id, never a kind, so a linked spawn wears both marks — the filled glyph in front and the word behind — and the two facts a row can carry never share a color or a slot. Nothing done to a spawned thing ever marks the map unsaved; it is the world’s, not the file’s, until you make it persistent.
A map’s instance placements are linked rows too, and they are the ordinary case rather than a special one: a crate in the map is a pointer at core:objects/physicsCube.dh-obj plus its own delta, so it wears the filled mark for the same reason a spawn does. Its children are the source object’s pieces, nested the way the object nests them and named by their own node names — the tree says what the placement is actually made of rather than stopping at one opaque row. They are the object’s rows, not the map’s: nothing under a placement is a node this map authored, and the map file holds only the pointer and the departures. A part the placement overrides wears the filled mark too: a part with an entry in the instance’s own children departs from its source the way a world part departs from the model. Each piece is a selection of its own — clicked here, or reached in the viewport by clicking the placement a second time where that piece is — and its Inspector shows the part: each component it resolves to as a card ghosted against what the source object states, its departures marked. An edit there is held by this session, and Save writes it into the instance’s own children through the same object writer an object file is edited with, one override per part. The part moves with its placement, so the tools stand on the placement’s pivot.
The world instance is the same idea for the map’s own model. Its row wears the filled linked mark, and a part the map overrides wears it too: a part with an entry in the world’s children departs from its model the way a placement departs from its object, so the mark says “this map says something about this part”. The world row is a selection, as every model instance’s is: selecting it outlines every part it places, and the Inspector states where the placement stands and what its root carries. It moves and turns as one: the gizmo and the transform rows take the whole placement, its parts following, and its scale stays as the map states it. A folder row and a plain record’s row are selections too: a folder has a name and a place in the tree and nothing else, so its Inspector is its Identity card alone.
The rows are compact, and the density is yours. A hierarchy row is one band client.editor.hierarchyRowHeight tall — 16 authored pixels by default, with the mark and the name centered in it — inset by client.editor.hierarchyRowPadding on each side. Both are live preferences rather than compiled numbers, so a tree can be tightened up on a small display or opened out on a large one from the console, and the list re-lays itself on the next frame. The default is the density a scene tree is normally read at: enough rows on screen to see a building’s structure without scrolling, and still a comfortable click target.
A row’s trailing marks say what the thing carries. At the far right of each row is a short chip strip — one small mark per component card the Inspector would draw for that thing: its renderer, its collider, its light, its camera, its reflection probe, its probe volume, its spawned asset, an authored logic entity’s own card, its wiring. The universal cards are deliberately absent: every object has an identity and a transform, so a mark for either would be a mark that says nothing. The list is read from the same description the Inspector is drawn from, so a chip can never name a card the panel beside it would not show.
At most client.editor.hierarchyChipMax marks are drawn — four by default — and anything past that becomes a single +k standing for how many were left out rather than a fifth mark nobody could tell from the fourth. Hovering a chip names its card; clicking one selects the row and folds the Inspector to that card, leaving the named card open and the rest shut. That is the ordinary fold state the card chevrons write, not a second mechanism layered over it, so anything you folded away is one click from coming back.
Clicking a row selects what it names, replacing the selection; ctrl-clicking toggles that row in or out of it; shift-clicking sweeps the range from the anchor — the row your last plain click landed on — to the row you clicked. The anchor stays where it is, so holding shift and clicking further down grows the same range, and clicking back up shrinks it, rather than each press starting a new one. A range is measured over the rows you can see: groups are skipped, because a group selects nothing, and a folded branch is not swept up by a sweep that passed over its parent.
This is the same selection the world picks into — one set, not two views to keep in step — so a row selected here is outlined in the world, and a thing picked in the world is filled in the list. A selection made anywhere else scrolls the list to it, unfolding whatever it was filed under: click a wall in the viewport, or take a Select All from the Edit menu, and the active row is on screen when you look. A click in the list never moves the list — the row is already under your pointer, and a shift-range that yanked the scroll would take the list out from under the hand reshaping it.
A selected row wears a fill; the active member — the one an operation would pivot on — wears the accent on its name as well, so a multi-selection tells you which of its members is next without a second color inventing a second kind of thing. Hovering a row fills it faintly.
Hovering a row also lights the thing it names in the world, in the same yellow the pointer draws when it is over that thing in the viewport — one highlight, from either end. That is what makes a list of names usable at all: block03/wall_north is a string until the wall it means flashes. While the pointer is over the window the row wins, because a pointer resting on a list is not aiming at the world behind it.
With no map loaded and nothing alive, the window says Nothing to show rather than presenting an empty well.
Dragging a row files it
Section titled “Dragging a row files it”Drag a row onto another and it is filed there. Hold a row and move over the row it belongs under — the row a drop would land on wears the pressed fill while you hold — and let go: a record dragged onto a folder or an object takes it as its parent, a model part dragged onto one is filed under it through its override’s parent, and a row dropped on the map’s root row goes back to the top level (a part, to its own place in the model). A part takes nothing, a record is never filed under itself or anything already under it, and a row a drop cannot take is not marked. Letting go over the row you picked up files nothing and selects it, as a click does.
A re-file is one undo step and a pending change: Save writes a record’s own parent field — inserted after its name, rewritten where it stands, or taken out for the top level — and a part’s into its override, keeping everything else the override says. Filing is a statement about the tree, never about where a thing stands: positions are world positions, so nothing moves on screen, and a mover picks up what is filed under it when the map is next loaded. The drag is this editor’s own until it is saved; other editors in the session see the new parent once the map is rebuilt.
Right-clicking a row
Section titled “Right-clicking a row”Right-click a row and a menu opens on it, parked where you clicked, dismissed by Escape or by a press anywhere outside it — the viewport’s menu in every respect but its contents.
It offers Make Persistent — live on a spawned object or avatar, dead on everything else — and Delete. There is no Select worth offering, because the row you clicked already names the thing; and a tree of what the map holds is not where a person reaches to place something new, so Add is not here either. A group row — a piece of a path no node in the model carries — raises no menu at all, for the same reason it selects nothing.
What it acts on is the right-click rule the viewport uses, unchanged: a row outside the selection is taken first and then deleted; a row already in the selection deletes the whole selection.
A parent carries its subtree
Section titled “A parent carries its subtree”Selecting a node that has children selects that node. Acting on it reaches everything beneath it:
- The object switch turns the whole branch off — every descendant stops being drawn and stops being solid, exactly as if you had clicked each one.
- A transform — the gizmo, or a typed number — carries the branch along, move, turn and size alike. The children keep their places relative to the parent, so a branch rotated about the parent’s pivot swings around it rather than each child spinning where it stands, and a branch scaled grows away from that same point.
Only manifest nodes have anything beneath them: a brush and a live entity are leaves, and pass through untouched.
What the cascade is not is a new kind of edit. The editor expands the branch and then does the ordinary thing to each node in it, so what reaches the server is the same per-node scene.disable / scene.transform lines you could have typed, and what reaches the file is one part override per part. There is no “and everything under this” patch, nothing on the load path expands anything, and a hand-written file listing the same nodes behaves identically to one the editor saved.
Turning a branch back on returns what that click took, and nothing else. If you switch a single wall off, then switch its building off, then switch the building back on, the wall stays off — it was already off when the building went, so the building’s switch never took it and has nothing to give back. A branch you switch on that this session never saw go off — a map that authors it off, or a change another client made — comes back whole, which is the only reading of “turn this on” available with nothing to consult. That memory lasts as long as the map does and is discarded with it.
Deleting a node
Section titled “Deleting a node”Delete takes the selection out of the world. It is on the Edit menu under a rule of its own, on the viewport’s right-click menu, on a Hierarchy row’s, and on the Delete key. Four surfaces, one row and one verb underneath it — the row is built in one place and read from all four, so they cannot come to mean four slightly different things.
It is the object switch’s destructive sibling, built out of the same parts. A parent takes its subtree exactly as the switch does, and what reaches the server is the same per-node scene.delete lines you could have typed — so the server stays the authority, and a peer watching cannot tell a group delete from somebody clicking each member in turn. The node stops being drawn and stops being solid the moment the server’s state comes back.
What it deletes is everything the editor can select but a brush. A node goes out as scene.delete; a logic entity — a camera, a light, a reflection probe, a light-probe or GI volume, a button, a door, a pressure plate, a physics prop, authored in the map or created here — goes out as scene.removeEntity <objectIndex>; a spawned asset goes out as spawn.remove <id>, which takes the whole entity out of the world rather than emptying its slot.
A brush is the one thing left. scene.removeEntity acts on logic entities, and a brush is world geometry rather than a thing that ticks, so the verb refuses its object index. A mixed selection deletes the rest and says out loud what it left alone: delete does not remove boxes yet. Refusing the whole gesture would make a multiple selection less capable than clicking its members one at a time, which is the rule every other bulk edit here follows. With nothing in the selection this verb can name, all four surfaces draw the row dead in place.
The selection empties behind it, and that is the one place this parts company with the switch. A disable leaves the selection standing precisely so the same switch can undo it; the way back from a delete is the Restore ledger rather than the thing that is no longer there.
Nothing is lost until you Save. A delete is a pending edit like a move or a rename — it counts toward the number on Save, it stops being pending the moment you restore the object, and Save writes it into the map source: a part of the world as "removed": true on its part override, an authored entity as the plain absence of its entry, which the writer deletes outright rather than flagging. An entity created in this session and then deleted owes the file nothing at all. Until then the object is a row in the ledger and one click from coming back.
Making a spawn persistent
Section titled “Making a spawn persistent”Make Persistent turns a spawn into part of the map. It is on a spawned object’s or avatar’s Hierarchy row menu, above Delete, and at the foot of its Object Instance or Avatar Instance card, after Open Source. The spawn leaves and an instance of the same asset joins the map at the pose the spawn stood at — position, rotation and scale — named after the asset, and selected. From that moment it is the map’s: its row loses the Spawned mark and moves to the map’s own entities, it is edited through the same verbs as any authored object, and it counts toward the number on Save like anything Add made. Save appends it to the map source as an ordinary instance whose source is the asset’s barcode, and the next load stands it exactly where it was.
An avatar is an object, so an avatar spawn is made persistent the same way and becomes an instance whose source is a .dh-avatar.
It is one server step. What reaches the server is spawn.persist <id> <objectId>: the instance is created and the spawn removed in the same line, so nobody in the session sees both, or neither.
A spawn wearing a session layer is refused and told which one — a bone pose, a hidden renderer, a rebound material, a driven blendshape, a switched-off state. A map instance cannot hold those yet, and dropping them on the way in would change the thing you were looking at. The row stays live, since the layer is the server’s to measure; the refusal arrives as a notice.
It is undoable. Ctrl+Z takes the instance back out of the map and spawns the asset where it stood, and Ctrl+Y makes that new spawn persistent again. Make Persistent is dead on a player, on a dropped prop, on a spawn with no asset loaded and on an open prefab.
Undo and redo
Section titled “Undo and redo”Ctrl+Z takes back the last thing you did to the map, and Ctrl+Y — or Ctrl+Shift+Z — does it again. The same two verbs sit at the top of the Edit menu, and they say what they would take back rather than the bare word: Undo Move Wall_03, Redo Delete 3 objects. With nothing to take back the row reads the bare Undo and is drawn dead, on the bar’s ordinary rule that an unwired row is visibly dead in its final place rather than absent.
Every act announces itself the way the rest of the editor does, on a toast: Undo · Move Wall_03.
The history is yours, not the map’s. It lives in this client and is never replicated: it is a record of what you told the server to do, so a second person editing the same map has their own and neither can walk back through the other’s work. That is also why an entry can go stale — see below.
One gesture is one entry
Section titled “One gesture is one entry”A drag is one entry, not sixty. The gizmo writes a transform on every frame your hand is moving; the history opens when you press and closes when you let go, so what lands in the log is the whole displacement under one label. Ctrl+Z puts the object back where it started, not where it was a sixteenth of a second ago.
The same rule covers everything that fans out:
- A multiple selection moved, turned, sized, switched off or deleted is one entry —
Move 4 objects— and Ctrl+Z returns all four. - A subtree that came along because its parent was acted on is inside its parent’s entry. The cascade is not a second thing that happened.
- A gesture that changed nothing records nothing. Pressing the gizmo and letting go without moving leaves the log where it found it.
- A drag is one entry with one step per row, however many values it sent. Scrubbing a number commits a value every frame it moves, and each of those is a write the history hears about — but a gesture folds every write of the same row into the one change it opened with, so an eight-frame drag files the first value it found beside the last one it left. Without the fold a single Ctrl+Z would try to unwind eight steps over one another and report seven of them skipped, which is what an undo that “barely works” looks like from the outside.
What an entry actually holds
Section titled “What an entry actually holds”Console lines, and never a picture of the map. An entry is the line that puts the value back beside the line that set it, because the editor’s writers are already absolute — scene.transform states a position rather than a displacement, so the way back is the same verb with the old numbers. Nothing is snapshotted, nothing is diffed, and a hundred entries cost what a couple of hundred strings cost.
A material step reads through the edits still in flight. A material’s override layer is replicated: a line reaches the server on its next tick and the collapsed layer comes back a message later, so for a frame or two the layer still states what it stated before. A guard that read it raw inside that window would compare an undo against a state not yet holding the edit being taken back, and refuse it as somebody else’s — so the editor remembers what its own last line laid on each material, and answers from that until the layer speaks again. A value somebody else moved bumps the layer for real, drops the memory, and is refused exactly as before.
The selection is part of the act. Every entry remembers what was selected before it and what was selected after, so taking back a move hands you back the objects it moved, ready to be moved again. A pure change of selection is an entry of its own with no steps in it — clicking a wall, then clicking a lamp, then Ctrl+Z leaves you holding the wall. Growing a selection counts the same way: three shift-clicks are three entries, and Ctrl+Z peels the newest member off rather than emptying the set. Landing on what is already selected changed nothing, so it records nothing.
What you were looking at counts too. The material the Inspector is pinned to rides in the same snapshot, so clicking a wall, then a lamp, then opening a material and pressing Ctrl+Z three times hands back the lamp, then the wall, then nothing at all. A frame that opened a material without moving the selection is an entry named for it: Inspect materials/foo.dh-mat.
You can turn this off. client.editor.undoSelection (the Options window’s Undo Selection Changes) decides whether selection changes are recorded at all. Off, the history holds edits alone. What the switch does not reach is the selection an edit carried — a delete still empties the set inside its own entry — so turning it off can never leave an undone edit selecting something the edit never selected.
When an entry cannot be taken back
Section titled “When an entry cannot be taken back”An entry that reaches for a node that is gone selects nothing, and says so. Walking back to a selection whose objects have since been deleted cannot hand them to you, so it hands you an empty selection and the toast reads Undo · Select Wall_03 · selection is gone rather than quietly seating the survivors.
A step whose subject has moved on is skipped, out loud. If somebody else changed the object between your act and your Ctrl+Z — or you deleted it, or the map went away underneath you — the history does not force its old numbers over the top. It runs the rest of the entry and says what it left alone: Undo skipped: Wall_03 changed by someone else. A history that silently overwrote a peer’s work would be worse than one that occasionally cannot reach.
Every kind of edit is checked this way, not just moves: switching something off, renaming it, marking it static, respacing a light probe volume, retuning a material or a light, deleting and putting back, dropping an asset in. Before a step writes, the editor reads the value back out of the world exactly the way it read it to spell the undo in the first place, and writes only if what it finds is what it left there. Redo asks the mirror question — it writes only while the world still holds what your act found — so walking forward again is as safe as walking back.
The History window
Section titled “The History window”Edit → History… raises a list of everything recorded this session, oldest at the top, on the same chrome and the same dock as the Hierarchy and the Inspector. Unlike the two rows above it in the menu, the row is alive with an empty log — a window that lists what has been done is worth opening before anything has been, and it is where you go to find out that nothing has.
- The row the log currently stands on wears the accent and takes no click.
- Rows above it are what Ctrl+Z would walk back through; rows below it are faint, and are what Ctrl+Y would walk forward through.
- Clicking a row walks to it — an undo eight acts back is one click here and eight presses otherwise.
- The top row is Map as loaded: the state with nothing applied, which without a row of its own would be the one position in the log you could not click.
It is one list rather than an undo stack beside a redo stack, because it is one list — and drawing them apart would hide the fact that doing something new throws the lower half away.
What the history keeps, and for how long
Section titled “What the history keeps, and for how long”A new act clears the redo half. Walk back three entries, then move something, and the three you had walked back through are gone; the thing you just did is the end of the log.
It survives a Save, a trip through play mode and a Build. Saving writes the map source and changes nothing about what you did to get there, F2 is a change of viewpoint, and a Build of the map you are in is a revision of it that keeps every object’s number unless it inserted one ahead of others. It is cleared by loading another map, by a Build that renumbered, and by Discard Changes: an entry names objects by their numbers in one compiled map, and after any of those every line in it would be aimed at an object it was not recorded against — or, after a discard, at edits that were thrown away.
Depth is client.editor.undoDepth, 200 by default, over 10–1000, and the Options window has the slider. Past the depth the oldest entry falls off the back, so the number is a floor on how far you can reach rather than a budget you can spend.
What is not undoable yet
Section titled “What is not undoable yet”Everything the editor writes goes through one seam, and an act is undoable exactly when that seam can spell its inverse. These cannot, and so record nothing:
- The renderer switch, and the reflection-probe fields — the writers behind them state a component rather than a value the history can name back. The collider is not here: its state, surface and shapes are field edits, and adding or removing it is a component edit, each one entry of the history.
- The barcode on a spawn.
A delete is undoable, and Ctrl+Z is the second way back from one: the Restore ledger is the other, and both put the same node back and take the same edit off the pending count.
The Inspector
Section titled “The Inspector”View → Inspector raises a small window against the right edge, on the same chrome and with the same close-box-is-the-menu-row rule. It describes the active member of the selection and nothing else: a multi-selection has a count and an active member, and an inspector showing six things at once would have to invent a way of showing six disagreeing positions before it had shown one. Everything it writes goes to that member alone: a switch thrown here turns off the thing the Inspector is describing — and, if it is a node, everything under it — but not the five others selected with it. The gizmo is the one operation a multi-selection performs whole.
-
The header names it, with what kind of thing it is under that.
-
Identity — its name and its id, and the object switch: one
Enabledcheckbox that turns the whole thing off. Off is Unity’sSetActive(false)— nothing drawn, no collision, nothing ticking. A spawned thing saysSpawnedbeside its name, the hierarchy’s own word in the same ink.Instance of— on anything that stands on an object: a mapinstance, a spawneddh.object. It is the material row, reused rather than re-spelled — the small round rendering, the barcode spelled the quiet-prefix way (corefaint,physicsCubebright, the.dh-objdropped) and one Open chip that shows the object’s source. It sits here, on the identity, and nowhere else: the object is the thing and the cards under it are its components, so no component card carries a source row of its own. A node the map authored outright has no object to name and simply has no row, and selecting more than one thing drops it — two placements of two objects fold to nothing one line could name.- Under it, a
Staticcheckbox — present on manifest geometry and on adh.lightProbeVolume, and nowhere else. A lamp is what a bake is of rather than a surface the bake lands on, and the bake reads GLB node paths alone, so no entity kind has a static posture that reaches anything: the row is absent on every logic entity rather than sitting there inert, and what a light does about baking is the Light card’s Mode row instead. A probe volume keeps the checkbox: a region the bake reads is still an object the map states a baking posture for. It has no effect at runtime; on manifest geometry it decides whether the node is in the next lightmap or GI bake, and switching it off suits something that moves or gets replaced often enough that baking light onto it would be wrong the moment the map changes. - Under the id, a read-only
Wire index— but only on a logic entity. It is the object’s object index, its place in the map’s object table, the numberscene.wire,scene.renameand the other verbs address it by and the number every snapshot record carries, which is exactly what makes it worth having in front of you while you are debugging a wiring problem or typing a console line. It is drawn faintly, under the Guid, because it is not an identity: the index is re-minted every time the map loads and is written down nowhere, so it means nothing to the next session and nothing to a saved override. A manifest node, a brush and a folder have an object index too, but draw no row — and a replica states its own packedWireid in the identity row instead, which is a different number again. Selecting more than one thing drops the row: no two objects share an index, so a folded one could only ever be a dash. - The name is writable, and it commits on Enter or on leaving the field — never on a keystroke, so a half-typed name is never what the world is called. A blank name is refused rather than accepted as one. What the file gains depends on what the thing is: a brush or a logic entity is one of the map’s own entries and gains a
nameon it, while a mesh node has no entry at all and gains anamein its part override — an override laid over the name the GLB gave it. A renamed node keeps that GLB name in sight, faintly beside the new one, so it can still be found in the geometry it came out of. Renaming never touches the GLB or the identity sidecar; a node’s id is exactly what it was.
-
Transform — three rows, each three space-separated axes at up to two decimals, with the zeros it does not mean stripped off. Two decimals is a centimeter: finer is noise on a number read off a moving entity, coarser hides a pivot that is nearly but not quite on a grid.
- Position — a node’s pivot or an entity’s interpolated position, in meters.
- Rotation — euler angles in degrees, applied Z·Y·X about the thing’s compiled pivot.
- Scale — a per-axis multiplier about that same pivot,
1 1 1at rest. - Every one of them is a delta from the authored pose, not a world placement, which is what lets you re-export the geometry and keep the nudge.
0 0 0in Position and1 1 1in Scale is the thing standing exactly where the map put it. - A row is writable exactly where the gizmo can drag it, per operation — see the eligibility table — and the axis fields go out through the same verb the drag does, so typing a number and dragging to it are the same edit. A light offers Position and Rotation and no Scale, and the mover kinds — a button, a door, a plate, a physics prop — offer the same two on the same terms: the write lands on the authored rest pose the kind’s own behavior is stated from, so a drag re-seeds a door’s closed center or a prop’s spawn rather than fighting the travel or the solver. A timer and the gates show the numbers and do not take them — nothing about one is where it stands — and a structural node offers none of the three. A row that discards what you typed is worse than a row that never offered, so a row nothing may change simply has no field.
-
Renderer — a switch on the card’s own heading, and it means exactly what the word says: off, nothing is drawn, for everyone. Collision is untouched, so an invisible wall is still a wall you walk into — and still a wall you can click, which is what keeps the switch that turned it off reachable. It carries Size, the world-space extent of a node’s box — read-only, and on this card rather than under Transform because an extent is a fact about the geometry being drawn rather than a pose anything can set. A logic fixture carries it too, from the same catalog the renderer resolves the box through; a timer and the gates draw nothing, so the row — and the switch — is simply absent rather than present and lying. Under the extent it lists the thing’s Materials: one row per slot the mesh draws, in the mesh’s own draw order, captioned with the slot’s index and name and carrying the
dh.materialthe map’s table hands it. A slot the table does not cover saysmissingMaterial, which is the material actually being drawn — a blank there would read as “nothing assigned” over a red checkerboard. A box lists the one key itsdh.boxnames, a button, a door, a plate or a prop lists its own the same way, and a light with a fixture lists the off/on pair it swaps between. Each reference is spelled the way the bar spells the map: the pallet prefix quiet in front, the material’s own name bright, and the.dh-matdropped. Each row leads with a small round rendering of the material itself and names what it inherits on a second line, spelled the same quiet-prefix way, when the material stands on another one — nothing at all when it stands on nothing. At the right is one Open button, which shows the material in the material inspector. The whole row is that one press: the disc, the pallet prefix, the name and the button all open the same material, and the base on the second line opens the base. Nothing in it shows a pointer and then answers nothing. The list takes materials: drag one out of any row’s thumbnail and drop it on another row, and that slot wears it — or carry it out over the game view and drop it on the surface itself, which wears it while you aim. See Rebinding a material slot. The heading folds the list shut and keeps its count while it is folded. Selecting more than one thing lists the slots the selection shares, once each, with a—for the ones it does not: a slot is the map’s, so one drop is one edit however many things are selected. -
Mesh Renderer — the same card on a spawned asset, under the name the rest of the world uses for the thing that draws a model’s sub-meshes. A spawned entity is not a node in the map, so its switch and its slots go out through their own verbs —
spawn.renderer,spawn.materialandspawn.blendshape— and every one of them is per entity: two spawns of one avatar can wear different faces, where a map material binding rewrites the table every surface shares. The layer is this session’s, not the map’s; nothing about it is written on a Save.-
The card stands on a NODE, never on the spawn’s root. An object has as many renderers as it has nodes carrying one, so the hierarchy lists a spawn’s renderer-bearing nodes under it — named by their node names and nested the way the object tree nests them, which is the tree an avatar’s part overrides are authored against — and selecting one raises this card for that renderer alone: its own Size, its own switch, its own slots numbered from zero in that node’s draw order. The root keeps Identity, Transform and Asset Instance and shows no slots at all. The editor addresses a renderer by its node — node path plus slot ordinal — and the ordinal reads back to the slot’s name in the model, the key a
dh.rendereron that part binds it by. -
Skinning is a section of that card and appears only when the mesh actually has an armature — a prop shows nothing rather than rows of dashes describing a skin it does not have. It states the Armature the mesh deforms with and its Bones count, both read-only, and then one scrub row per blendshape, captioned with the shape’s own name and dragged the way every other number in the window is. A shape this entity drives away from its authored weight wears the deviation mark, and the heading carries the mark while the section is folded. Spring bones are simulated on each client and are untouched by any of this.
-
-
Light Probe Volume — on a
dh.lightProbeVolumebox alone, under Transform because that is what it is about: the box the transform describes, and what gridding it costs. Spacing (m) is the distance between adjacent probes inside it, and it is writable — the write is ascene.spacingthe server decides and replicates, so the box re-grids for everyone watching the map, not just under your own cursor. A volume whose source states no spacing shows the map’s ownlighting.giVolume.probeSpacingwith its label faint: that is the number the box is actually gridded at, and the faintness is what says the file has not stated one here. Typing over it gives the volume a spacing of its own — there is no way back to inherited from here, because deleting a field is not a splice this writer expresses. Under it, Inset, a checkbox for where the probes sit: checked, they fill the box’s cells at their centers; unchecked, they land on the cell boundaries and the outermost ones on the faces. It travels exactly as the spacing does — ascene.insetthe server decides and replicates — because it moves every probe the bake will fill, so it is the map’s business rather than this session’s. Under those, Probes, the count the box, that spacing and that layout come to — read-only, being derived from the three, and it moves when the checkbox does: a 2 m box at a 1 m spacing reads 8 inset and 27 on its faces. A spacing the session retuned wears the same gutter a moved object’s Position row does; an inherited one does not, since the box states no number either way. At the card’s foot sits Show Probes, a toggle button: pressed in, that box keeps its probe grid on screen after the click that selected it is spent. With the dots up it reads Hide Probes — a button says what pressing it DOES, so a toggle that is on wears the verb that turns it off. It is a switch on this editing session and nothing else — nothing is written to the map, and nobody else watching the map sees it.- A selected volume already draws its probes, with the Lighting window’s Show Probes chip down and this switch untouched: selecting a box is asking what gridding it costs. The dots obey the same distance and budget the global preview does, and the whole budget goes to the boxes actually asked about rather than being split across every volume in the map. The flat cage stays the global preview’s alone — a selected box wears its outline, and two cages around one box disagree about what is in front of them.
- Probes belong to a light probe volume alone. A
dh.reflectionProbeand adh.cameraare picked, listed, moved and framed through the same walk a light probe volume is. Neither of them samples anything on a lattice: a probe captures one cube from one point, and a camera has no region at all. The grid is derived from the light probe volumes and from nothing else, and the other two kinds are marked by their outline, the probe’s capture marker and the camera’s cone. - The dots need nothing but the box. A layout is a pure function of the box’s bounds and its spacing, so a volume shows its probes with no bake, nothing saved, and no
lighting.giVolumeblock in the map at all — a map is lit long before anyone asks it for indirect light, and that is the state every volume is created in. A block that is present supplies the default spacing and nothing more.client.editor.giProbes.sourcepicks between the derived layout and a compiled one; it prefers, it never vetoes, so asking forbakedon a map nobody has baked still draws the derived dots.
-
Light — on a
dh.lightalone, and a card with a switch on its heading like the component cards below. The engine has no “emits nothing but stays” state, so that switch drives the object’s ownEnabled— the same fact the Identity card’s row states, wearing the header a component switch wears everywhere else. Kind is a choice — point, spot or directional — written as the light’slightfield, so a spot can become a sun on the wire and the file follows on Save; a lamp that emits nothing is not among the choices. Color is a bezeled swatch beside its own three channel boxes, in sRGB face values — the space the map file authors and the console types, not the linear the renderer holds. Then Intensity, Range in meters, Falloff (physicalorunity, its label faint when the token is the map’s rather than this light’s own), the spot’s Inner and Outer cone in degrees — present on a spot alone — and Mode,realtimeorbaked. Every row writes the light’s own field by its description key as a scene field edit — the very editlights.setmakes — so the server decides the change, every client watching the map sees it, and each row’s undo is that field’s earlier value; the numbers scrub by their axis letter exactly as the transform rows do, and a lone number — Intensity, Range, a cone — scrubs by its caption, which is that row’s one letter. Falloff, Mode and Kind are enum rows: a segmented row while the set is four words or fewer, a dropdown past that, and the choice is the field’s rather than the card’s. The card’s three-dot menu carries Revert Light, which puts every field the card writes back to what the source authors as one gesture, and one undo. Selecting more than one thing drops the card: its rows are one lamp’s numbers keyed by that lamp’s name, and the notice under the column says so. -
Box — the card of a box’s
dh.box, generated from its description, above its Collider card. Size is the box’s full extents in meters and Material is the slot of the map’s material table it wears, drawn as the material field over that slot: the disc pictures what the slot wears and opens it in the drawer, and the text is the key, typed over to wear another. Both are field edits: an edit re-meshes the one box for everyone, rebuilds its generated collider on the server and in every client’s prediction, files one undo, and saves through the same writer as a collider — into aboxBrush’s own top-levelsizeandmaterial, or into thedh.boxof anobjectthat states it incomponents. A box’s Size is no longer a row of the Renderer card, and its scale stays on Transform. A box is added and removed live. Add Component → Box on a plain object gives it a unit box: it is drawn, solid with the exact-box collider the compiler would have given it, and water where its material is, for everyone in the room and for whoever joins later. The card’s removal takes a box off its object (Removing it removes the component from this object), and with it the drawing, the body the box made and its water. Each is one undo, which is the opposite edit, and Save writes it through the same writer: anobjectgains or loses itsdh.boxincomponents, and aboxBrushwhose box is taken off is rewritten as the plainobjectit stood for. A resized or re-materialed water box is water to its new size and material for the swim, buoyancy and the underwater view on the frame its mesh re-cuts. -
Collider — a described component’s card, generated from the
dh.colliderdescription like every other. State is a choice ofsolid,offandtrigger: off destroys the bodies and leaves the mesh alone (a wall you can see and walk through, and anyone standing on it starts falling), and trigger keeps the shapes, stops them blocking and makes them report what walks through them. Was Trigger shows only while the collider is off, the box that says whether switching it back on restores a trigger. Material is the surface the shapes report. Shapes is a list with one group per shape: a Type choice and the fields that type owns, so a box shows its center, half extents and rotation and never a radius, a sphere a radius, a capsule a radius and a half height. A shape is removed with the cross at its group, added with Add at the foot of the list (a new shape of the type it names, starting from that type’s own defaults: a half meter box, a half meter sphere), and a type change starts from the new type’s defaults and keeps the center, rotation and surface. A hull’s points are never drawn as numbers to type: a hull is replaced by anautoshape or by an import. A list replaces whole, so every shape row writes the whole list with that one change, and the server decides it like any other field. The card is present wherever the object carries adh.collider: a drawn part, a brush and a plain object. A button, a door, a plate and a physics prop show their collider too, since it is their body: its edits are recorded and saved like any other collider’s, but the live body is built when the map’s logic is installed, so they reach it on the next load rather than at once. A prop placed as an object instance shows none until instances take components. Selecting more than one thing keeps the card where every one of them has a collider, and a write fans out over all of them; each member’s shapes are changed from its own.- The card says what removing it does, before anything is pressed. A part and a box always have the map’s collider, so the card’s menu offers Revert to Map Default and the note under the rows says Removing it reverts to the map’s version: the shapes and surface go back to what the compiled map says (the state keeps its own revert). A plain object’s menu offers Remove Component and the note says Removing it removes the component from this object: its bodies go away. Either entry is dead where there is nothing to put back.
- Shapes are drawn in the world. Every selected owner’s shapes are drawn as wireframes in the tooling band, in the owner’s frame, read from the component rather than from the physics world, so a shape whose collider is off is drawn faint and can be seen and switched back on. A box is its twelve edges, a sphere three great circles, a capsule two circles, four lines and the arcs of its hemispheres, a hull the edges of the hull the engine keeps. A mesh shape is the edges of its cooked body while that body stands, else its owner’s bounds; an
autoshape that has been built into nothing yet is its owner’s bounds, dashed. Solid, trigger and off have a color each (client.editor.collider.solidColor,triggerColor,offColor), the line islineWidthPx, the dashes aredashPxanddashGapPx, and a mesh body overmeshEdgeBudgetedges (20000) is drawn as its bounds rather than stalling the frame. The band is clipped to a docked game view like every other gizmo, andclient.debug.colliderGizmo’s all-bodies view is unchanged.
-
Add Component — a button under the last card, present where the selected thing can be given a component it does not have. Its menu lists them; today that is the collider, offered on a plain object that has none, with a unit box (a half meter each way) to start from. Picking one is a component add with its undo, and the server builds that one owner’s bodies. A part and a box are never offered one: the compiler gave them theirs.
-
Mover, Timer and Physics Prop — the logic components’ own cards, generated from each component’s description like every other, one per component on the object and only where it has fields of its own to state. A mover’s card reads its Motion, Direction, Distance (m) and Duration (s); a timer’s its Delay (s); a prop’s its Mass (kg), the mass the solver gives it where none is authored, and Sim — spelled as the map file spells them. A button that also moves shows the mover’s card, and its title says Button, its first logic component. Every row writes the field it states as a scene field edit, with undo, on every selected member that carries the component; a scrubbed number is one undo step however long the drag. A prop placed as an object instance shows the same card from the same reading, and its rows take an edit live, and Save writes it into the instance’s own
components, over the body its source states. A button, a plate, a map start and the gates get no card — they have no fields, their size and material are the Box card’s, and an empty card is not a card. The rows arrive from the host as a list rather than as per-kind branches in the window, so the Inspector learns no kind. Selecting more than one thing drops the cards, on the Light card’s reasoning: two doors have two travels (a set of movers keeps one thing, the Open, Close and Toggle row).-
A door opens and closes from its card. A mover’s card ends in an Open, Close and Toggle row, with a read-only State row above its fields reading
closed,opening,openorclosingoff what rides the wire (a spin readsopenonce up to speed andclosingwhile it coasts down). A press sends the mover’s own input through the running session (logic.call <objectIndex> mover.open, which anyone with world authority can type in the console), so the real node answers: the picture, the collision, the replication and theonOpenedandonClosedit fires are exactly what a wired call produces, for everyone in the room. It plays the world rather than editing the map, so it is not an undo step, is never saved and leaves nothing pending. It works on every mover, slide, rotate or spin: a Source-imported door, a hand-placed one, and aninstanceof an object whose root carries a mover, whose card is read from the components the loader resolved onto it (its fields are read-only; the buttons act). Start open is the ordinary checkbox row and saves like every other field. Selecting more than one thing drops the travel but keeps the row when every member is a mover, and a press acts on every selected mover; a set holding something that is not a mover shows no row. A client without world authority sees the buttons dead. -
On a placement, the card is the OBJECT’s card with this placement’s departures marked. An
instanceofphysicsCubethat states only a"sim": "client"shows every field the object gives it, and the one row it moved wears the modified gutter with the inherited value ghosted beside it —inherits server, faint, at the end of the row. A row the delta left alone carries nothing after it, because there is nothing to disagree with. That is the whole reading of a delta in one card: what you see is what the thing is, and the ghost says what it would have been.
-
-
Wiring — on any object a wire touches or could fire, brushes included, and on no other. It is two lists. Outbound is the object’s own outputs, each with the ordered actions firing it runs: a
→row percall, naming the object it calls and the input on it (and the boolean it passes, where the input takes one), and a plainwait 0.5 srow perdelay, which points at nothing because a delay reaches nothing. Inbound is everything whose wiring reaches this object: a←row per source, naming it and the output on it, with what it calls on you at the far end of the row.-
Every name is a link. Click one and it becomes the selection, through the very path a Hierarchy row’s click takes — which is the whole of what makes a wiring list a way to walk a map’s logic rather than a paragraph about somewhere else. A name the loaded map no longer holds is still printed, in the muted ink, and simply does not click.
-
The card states DIRECT wiring, which is deliberately not what the wiring lines in the world draw. A lamp wired from an
orGatenames the gate here and has a line straight from the button out there: a gate has no world point a line could end at, so the picture collapses the chain, and a list has no such problem. Inheriting the collapse would tell you your lamp is wired to a button it has never heard of. -
An object that can never fire anything and that no wire touches has no card at all, on the window’s usual terms — a card with nothing in it is not an empty card, it is no card. A button that is wired to nothing does get one, reading
not wired: that empty card is the only place a first wire can be hung. A solid brush gets none until its collider is a trigger, because only a trigger can raise the touch edges. Selecting more than one thing drops the card, for the Light card’s reason doubled: it is a list of other objects, and folding two of those together would describe neither. -
The card is EDITABLE.
+ outputoffers the object’s own declared outputs — a button’sonPressed, a door’sonOpened, a trigger’s two touch edges, and the qualified form of any output two of the object’s components share (andGate.onTrue) — read straight off the same signal table the compiler validates against, so the menu can never suggest a wire the build then refuses. Under each output,+ calland+ waitappend to its action list. Acallrow carries a target picker over the map’s named objects and an input picker driven by the target’s kind, plus atrue/falsepicker where the input takes a boolean; awaitrow carries its seconds as a draggable number, the same scrub cell every other number in the window uses. Each action row carries↑ ↓ ✕: order is semantic — the runtime is serial and adelayblocks the rest of the list — so an action can be moved, and an end of the list has its own arrow drawn dead rather than missing, so the row does not change width as an action travels. The✕on an output’s own line cuts the wire, taking the whole output with it. -
Repointing a call resolves its input rather than keeping a dead one. Move a call from a door to an
orGateand the input does not stayopen— a gate declares no such thing. The row takes an input the new target actually performs (and the boolean that input requires, if it takes one), which is both what you see and what keeps the list one the build accepts. An input that takes a number or a color — a light probe volume’ssetIntensityandsetTint— starts at one and at white, and keeps a value it can already take; the card has no editor for that value yet, so a different one is written in the map source. A target that declares no inputs at all is refused with the reason, because a call on it could never be anything but dangling. -
Every refusal is the BUILD’s refusal, in the build’s words. The editor and the compiler call one validator, so an edit the card lets you make is an edit the map still compiles with. Wiring geometry that still blocks is refused with the same sentence a
.dh-mapgets for it. -
An edit is one whole list.
scene.wirerestates an object’s entire connection list rather than adding or removing one wire, which is what makes each gesture one undo entry (the previous list is the record), atomic under several editors, and exactly the shape Save writes back into the.dh-map. The running logic graph obeys it at once: a door newly wired to a button starts opening on the next press, with no reload. -
Not yet: drag-to-wire in the 3D view. There is no gesture that starts at one object in the world and ends at another. Wires are made on the card.
-
-
Reflection Probe — on a
dh.reflectionProbebox alone, and the Light Probe Volume card’s neighbor in every way that matters: it is under Transform because the box IS the transform, and its rows say what is captured inside that box and how the capture is read back. Capture is a three-axis offset in meters from the box’s center — where the cube was photographed from, which is usually where an eye will be rather than the arithmetic middle of the room. Box Projection parallax-corrects the lookup against the box. Resolution is a chip row over the four face sizes a probe may author. Importance is the overlap tie-break, and Blend is how far inside the boundary the probe’s weight eases in. The card is the one the generator draws from thedh.reflectionProbedescription, and every row is a field edit of that component, keyed by the field’s own name, which the server decides and a player who joins later hears. A resolution or a blend the file left out is shown resolved with its label faint, on the spacing row’s precedent: that is the number the bake actually used, and the faintness is what says the file has not stated one. Retuning any of the five marks the probe as pending and Save writes all five back into the map source, resolving the faint ones as it goes.-
A reflection probe is drawn as a mirror ball, a sphere at the capture point wearing the cube that was photographed from exactly that spot. It is the probe’s own picture rather than a marker of one: the ball is shaded as a true mirror — white, fully metallic, perfectly smooth, and with the whole diffuse half switched off, so the ambient fill cannot wash a bright plastic sheen over what the probe holds — leaving the cube plus the analytic highlight, so a bake that mirrors the wrong room is something you see instead of something you deduce. A probe on a map with no baked cubes is plain gray, which is the honest picture of “nothing captured yet” rather than a mirror of the sky. It stands whenever the volume dots do — the editor, with entity gizmos on — and its radius is
client.editor.reflectionProbeBallRadius, in meters, deliberately far larger than a GI probe’s few-pixel dot. The ball is also what a click lands on: a probe’s box is small and draws no cage, and a box is ranked at the far wall of the region it describes, so before the ball a probe was the one authored thing that was harder to grab than what stood behind it. The ball is ranked where the ray first touches it, like the solid object it looks like. -
Its Transform card is a transform card, the same as a light probe volume’s: the move and scale gizmos stand on a selected probe and the Position and Scale rows take numbers, replicating through the ordinary entity transform. Rotation is refused for both kinds, since a turned probe box is a compile error rather than a pose.
-
At the card’s foot sit two buttons. Edit Capture Point is a tool mode: while it is on, the move gizmo stands on the mirror ball rather than on the box, and dragging it writes the probe’s
capturePoint— the same value the card’s own Capture row scrubs, through the same verb and the same undo entry — so the box never shifts. Rotate and Scale show no handles at all in the mode, because an eye has no facing and no size. The mode is transient: it drops the moment the selection is something else, and it is written to nothing. Bake bakes, here. The six faces are rendered by this client against the world on screen — unsaved edits, the light you just moved, the material you just retuned — one face a frame, so a probe takes six frames and a selection of four takes twenty-four. The finished cube is prefiltered, swapped onto the GPU as the frames land, and written beside the map source as<map>.dh-map.dh-reflcook, with the same writer--bakeReflectionsuses, so the next Build packages it. Map ▸ Bake all reflections (orreflection.bake) bakes every probe, and Map ▸ Clear all reflections drops the cubes and deletes the file. A toast says how many probes were baked and where the file went. A face is rendered square at the probe’s own resolution, which is not the shape of the window — the scene targets are grown to fit for the length of the bake and shrink back after — and a capture binds no probes, so a cube can never mirror the previous cube of itself. Select several probes and the button bakes each. Seedh.reflectionProbeandEngine/design-notes/reflection-probes.md.- A bake of an unsaved edit is in-memory only until you save. The cube on the GPU is the one you just shot either way, but the sidecar is matched against the map source, so it is picked up on the next load only once the boxes it describes are in the file.
-
-
Camera — on a
dh.cameraalone. The third authored entry that reaches the inspector through the map rather than through a node, and the one that is not a box: a camera’s transform is an eye and an aim, so the Transform card above states its position and its rotation and offers no scale at all, while this card, the one the generator draws from thedh.cameradescription with the preview and two verbs below it, states the lens and the output. Fov is the vertical field of view in degrees. Near and Far are always shown, and zero is the honest value of each where the map states none: the engine’s own near plane, and a far plane derived past the map. Typing a positive distance authors the plane; typing zero gives it back. Aspect is the shape of the frame —viewport,16:9,4:3and1:1as chips, with a camera whose file states some other pair showing that pair beside them rather than misreporting it. Role is a dropdown over the camera’s jobs,noneormapThumbnail. At most one camera per map holdsmapThumbnail, so setting it here releases every other camera that held it in the same edit — one Ctrl+Z gives the role back where it was. Two rows appear with the role that uses them: Thumbnail size, the pixel size the shot is rendered at —512 × 512where the map states none, because every list of maps draws a square plate — and Auto capture, whether a build re-renders it when the map changed. Under that role the Aspect row stops taking clicks and states the ratio the size derives, so an unsized thumbnail camera reads1there — a picture that is 512 by 512 cannot also be 16:9, and the frustum cone must draw what the render will write.-
At the foot sit two verbs. Match Viewport stands the camera where the editor camera is standing and aims it the same way. Aiming a shot by typing three angles is a thing nobody does twice, and the angles cross without conversion because they are the same two numbers — a camera’s authored yaw and pitch are what a render feeds the view. It writes through the ordinary entity transform, the one the arrows write through, so a match replicates to everyone in the session, saves like a drag, and one Ctrl+Z takes it back. Roll is left at whatever the map authored: the view’s roll belongs to the fall-impact spring, which is something happening to the picture rather than a statement about a camera.
-
Capture thumbnail sits beside it. It is the same request the Map menu’s entry makes and crosses the same seam, so this process cannot come to two answers about one question — but the card names its own camera, so the shot is framed by the camera whose card you pressed, at that camera’s authored
thumbnailSize, while the Map menu’s entry names nobody and photographs the designated camera. A second engine process renders it and writes the PNG beside the map’s source, and a toast reports where it landed. One capture runs at a time; a second press while one is in flight is refused rather than queued.It lights up the moment this camera holds the
mapThumbnailrole — the role the Role row above is showing right now, not the one the last save wrote. There is one thumbnail per map, so a camera the map does not photograph from is refused rather than overwriting it with a frame no build would take again; the plate says which row fixes that. A save is not one of the conditions: the shot is rendered from the compiled pallet, so saving would not have put one more edit in the picture — only a build could. The one other thing that can hold it back is a map with no authored source on this machine, which has nowhere to keep a PNG; the plate says that too. -
Under the verbs sits Preview, a collapsible section that renders what the camera sees, live, while it is open and this camera is the only thing selected. See Cameras.
The lens rows are fully live. Every one of them is a field edit of the object’s
dh.camera, keyed by the field’s own name: the edit replicates to everyone in the session, a player who joins later included, is marked pending, saves into the component in the map’s own source and one Ctrl+Z takes it back, exactly like a drag. There is no camera console line:scene.camerawent when the camera stopped keeping a scene list of its own. -
-
Avatar Instance — on a spawned asset alone, and the in-game half of Unity’s
DigitalHeavenAvatarInstancecomponent, down to the row of verbs at its foot. It is titled Object Instance on a.dh-objand Asset Instance on an empty slot: the kind is not replicated — the wire carries a barcode and a table index and nothing else — so the extension is the only thing that can answer, and a slot with nothing in it says the generic name rather than guessing. Barcode is the barcode picker, taking objects and avatars, and it wears the same gutter a moved Position row does while what you picked or typed differs from what is loaded. Under it, Status —Resolved,Loading,PlaceholderorUnresolvable barcode— which is this client’s reading of the loaded barcode rather than anything the server said: a placeholder cube standing because the model is still being uploaded and one standing because the pallet does not parse are the same picture, and this row is what tells them apart. At the foot, Load, Reload and Unload, going out asspawn.load/spawn.reload/spawn.unloadagainst that entity’s id, so the change is the server’s and everyone in the session sees it. Load is live only while the field names something other than what is loaded — re-loading the loaded barcode is what Reload is for — and Reload and Unload are dark on an empty slot. Unload empties the slot, not the field: the barcode the card was showing is pinned as the draft, so Load brings the same asset straight back and the only thing that changes the field is picking or typing in it. Open Source, beside them, is the one verb here that reaches no server: it opens the asset browser at the definition the loaded barcode names, so a linked instance in the world is two clicks from the file it inherits. It wants both halves of that — a barcode actually loaded in the slot, and that barcode’s pallet present in this workspace — and its plate says which of the two is missing when it is dark, since a barcode from a server whose pallets are not on this machine names a folder nothing can be opened at. Under Status, Show bones draws or hides the rig’s bone dots — a view of this client’s own,client.editor.boneGizmos, so it survives an editor with no authority to edit anything. Selecting more than one thing drops the card, on the Light card’s reasoning: the field is one entity’s barcode.
Editor-only actions sit at a card’s FOOT, under the card’s rows — the Light Probe Volume card’s Show Probes is the first of them. A lone verb is a full-width button; several share one row in equal shares, the way the Unity component they mirror arranges them, because a stack of full-width buttons reads as unrelated things rather than as a set of alternatives. A foot button is about the LOOKING rather than about the thing selected: it changes what this session draws or does, never what the map says, which is why it is a button rather than another checkbox row. A toggle that is on wears the same pressed-in chip the toolbox modes wear, and one that has nothing wired behind it is drawn dead rather than hidden. They are declared in C# on the card’s model — [CardButton("...")] on a method, [CardToggle("...")] on a bool property — and appear in the order they are written, so adding one to a card is one attribute rather than a row of layout. A toggle may state a second, on-state label with OnLabel; the button’s KEY still hangs off the declared one, so it keeps its identity across the flip.
Every one of them is the same chip the dialogs wear — Chip.Build hands the theme’s ButtonDefault or ButtonPrimary chip to Ui.Chip, which is where a button’s look is decided for the whole program. A foot button therefore answers the pointer by moving its fill, never its ink, either way it is pointing: a word that changes color exactly when a person is about to press it reads as a second control, and accent ink on the accent fill a pressed-in chip wears disappears outright. Its label is centered against the full row rather than sitting against the left edge, and it stands as tall as the field rows above it.
A component’s switch rides its heading, where every editor you have already used puts it, and the pending gutter rides with it — the mark has to be beside the thing it is about. The object’s switch is deliberately not one of these: it is a fact about the thing rather than about one of its parts, so it stays a field on the Identity card.
The cards keep their natural height and the window clips them. Drag the Inspector short and it scrolls, the same way the Hierarchy does, instead of squeezing every card into a stack of its own headings.
Every label in the window takes a share of the panel, not a fixed column. One split runs across the whole Inspector — every card, every row, the material rows included — and it is a fraction of the row’s own width: client.editor.inspectorLabelFraction, 0.4 by default. Dragging the pane wider gives the names and the numbers each more room in the same proportion, which is what a shared split is for: a column pinned at 62 px was the same 62 px in a pane dragged to a third of the display, so a name like “heightReference” was cut off at a width where there was nothing but empty space beside the value.
The fraction is held between two ends. client.editor.inspectorLabelMinWidth is a floor in pixels, so a pane squeezed narrow keeps its names legible rather than shrinking them to a letter, and client.editor.inspectorLabelMaxFraction is a ceiling on the same width as a fraction of the row — and the ceiling is applied last, so it beats the floor. That order is the whole rule: in a pane too narrow for both, the value column is what must survive, because a row that shows its name and no number has stopped being a row. Between the two, the value column shrinks first and the label only starts losing width once the ceiling has taken it.
Every value control in the window stands the same height, and it is one number: client.editor.inspectorControlHeight, 20 authored pixels by default. A numeric field, a text field, a segmented chip, a dropdown button and the shader picker at the top of the material inspector are all held to it, at one font size, so a row of words and a row of digits sit on the same band however tall each of them would have measured on its own. A dropdown’s label reads in the field’s own ink, since the word in a dropdown is a value, not a caption.
A dropdown’s chevron is a mark rather than a letter. It is cut from the same lattice a docked window’s close mark is cut from — the same stroke, the same unit rounded to whole device pixels once — so it stays sharp at 100%, 150% and 200% instead of wearing whatever the font does to a v at that size. One drawing, used by every chevron the editor folds something with.
An enum option wears its own tile, or it wears nothing. Where an option has an icon — the surface kinds — the tile grows with the control height above it, nearest-scaled, so it enlarges cleanly instead of sitting small in a taller chip. An option with no icon of its own shows text alone. The rule is the enum path’s, not the mark’s — UiIcons.SurfaceIcon(null) still hands the generic surface mark to callers that are asking for a surface.
An open menu lines its rows up on one left edge: the tiles in a column, the words in a column after them, every row the same. The menu does not reserve a lane on its right for a scrollbar that never comes — the gutter is taken only where the surface actually scrolls, so a short list’s search box and its rows run the full width of the frame they are drawn in.
The whole heading folds the card, not the chevron alone: the strip is one click target running the width of the card, and the hover fill covers exactly what the click covers so the target is visible before it is found. The hover and pressed fills are clipped to the heading’s own rounded corners — a card’s head is rounded at the top, and an unclipped square fill would put two hard corners back every time the pointer arrived. The card’s verbs are cut out of it — the switch and the three-dot menu keep their own handling, since a press meant for a menu that folded the card instead is the failure this shape has to avoid. A heading with nothing to fold is not a control at all, and does not light. The name on it is set in the bold cut at the body rung, one step above caption size: a heading is the thing you read to find the card, so it is not the smallest text on it.
A card that folds into parts gives each part a heading of its own — the water card’s five, the Renderer card’s Materials list and its Skinning section — drawn as a full-width bar in a tint a step between the card’s head and the card’s fill, with the part’s mark, its name and its chevron, and, while it stands open, its rows inside a container that starts at the bar and ends after the last of them, so the eye reads which bar a row belongs to; a folded part is its bar alone. client.editor.inspectorGroupBarHeight, inspectorGroupInset, inspectorGroupGap, inspectorGroupRadius, inspectorGroupBarTint and inspectorGroupBodyTint tune it, the two tints as distances from the card’s own colors so they keep following client.ui.themeColor.
The two component cards are the finer grain under the object switch, not alternatives to it: an object turned off whole still reports both components as on, because switching the object back on is meant to give you back the thing you had. Which components an object has depends on what it is — a visual-only node has no collider component at all, and shows no row rather than an unchecked box. See Components for the full table and the console verbs the checkboxes submit.
A collider saves wherever it is edited. Its state, surface and shapes, and a collider added or removed, are written into the dh.collider its owner states — a part’s one override, a brush’s or a plain object’s own record — each field under its own key and a shape list whole; see what a save writes. After a Save, what the file states becomes the baseline the mark is measured against. A Renderer state is still live-only: it takes effect for everyone in the room and is gone when the session ends, because the save path has nowhere to write it yet.
With an empty selection it says Nothing selected, quietly, and that is all.
The barcode picker
Section titled “The barcode picker”Every field that holds a barcode is one control. A component field marked [Asset("dh.voxel")] (or any other type), the Avatar Instance card’s Barcode, and every field like them draws as the barcode picker (AssetPicker), never as a bare text box:
- The face is the asset’s picture on the left — the browser’s own picture for its kind, through the same builder the browser’s tiles use (
AssetPicture): a material’s ball, a texture’s decode, a map’s thumbnail, and the kind badge (VOX,OBJ) for a kind with no picture of its own — then its name as the breadcrumb every surface names an asset with (the pallet’s square, the pallet quiet, the type mark, the name bright), and under it what the asset is. Rest on the name for the whole barcode; right-click it to copy it. - The chevron beside the face opens a dropdown of every compatible asset in the workspace — only the kinds the field declares, each with its picture and its barcode in the map-name style — with a filter box that narrows the list as you type. Picking one is the field’s ordinary write.
- Browse opens the asset browser in pick mode: the browser itself, in its own window, filtered to the field’s kinds and standing where the field’s current asset lives. Double-click a row, or select it and press Choose; a row of any other kind does not answer. It is the window the material inspector’s
inheritsrow opens, given the field’s kinds. - Show in Browser raises the asset browser with the asset selected where it lives — the same reveal Open Source uses. It is dead on an empty field or a missing barcode.
- Drop an asset from the browser on the field and it is set — only an asset of a kind the field takes: the field lights in the accent for one, and in the unresolved red for any other, and letting go of a refused asset changes nothing.
- Edit turns the face into the barcode’s text box for typing one by hand (Enter or leaving the box commits; Done puts the face back). Typing is still possible; it is just no longer the face.
- Clear empties a field that may be empty — one with no default of its own. A required container offers no Clear.
- A barcode nothing in the workspace holds is MISSING: the face keeps the barcode, its name in the unresolved red, and says Missing: no pallet here holds it. A host that cannot list its pallets never calls anything missing.
Every write goes through the field’s own verb, so a pick is one history entry, fans out over a multi-selection the way a typed value does, and wears the same deviation gutter. A field the selection disagrees about says Mixed. A field that names no asset type (a packaged probe file, an audio clip) has no kind a picker could list and stays a text box; a dh.material field stays the material field.
The Voxel Volume card
Section titled “The Voxel Volume card”A placed dh.voxelVolume gets a card of its own rather than the generated one:
- Volume is the barcode picker, taking
dh.voxelcontainers only. - Source — what the container came from: the World name a Minecraft save states, the Platform by its name (Minecraft rather than
com.mojang.minecraft) with the format it arrived in, the Version and data version, and, off the world pallet’s manifest metadata where the import stamped it, the Game mode, Seed, Last played, the Launcher and instance the save was found under, and the folder it was Imported from. - Contents — Size of the largest shown grid in cells and in meters, Grids with how many are hidden, Regions stored and how many of the draw regions around you are streamed, Chunks stored, Voxels that hold anything, the Palette as entries (block states) and distinct blocks, the payload’s compressed size On disk, and a folded Placements list of the objects its cells stand for (doors, trapdoors, buttons), most first.
- Settings — Lighting as a choice of
default(the container’s, then its platform’s; the label says which mode that resolves to),dh,hybridandminecraft, and Grids as one switch per grid, the hidden ones off until switched on. Both are ordinary field edits with undo. - Problems — folded shut, the blocks the volume draws as a stand-in rather than as their block, one row per block: the swatch it draws in (the magenta checker for an entry nothing named a color for, its built-in color for a fallback cube), the block id, why, and how many cells hold it. It is read off the looks the volume already resolved against its platform pallet; nothing is resolved twice.
Nothing here scans on the frame thread. The header facts are read the frame the container opens; the cell counts (Chunks, Voxels and each problem’s cells) are taken once per container on a worker and read counting… until they land. A volume still opening says so instead of showing facts.
The root and the Restore ledger
Section titled “The root and the Restore ledger”Select the hierarchy’s root row and the inspector shows its root arm: an Identity card naming the map, read-only — the map’s name is the barcode’s, not a field — with no enabled switch and no Transform card, since a container has no pose. Under it sits one card the root alone carries, Restore.
The Restore ledger lists every node the scene currently holds switched off, one row each: the node’s name after its path drawn faint, the way the hierarchy files it. It reads the replicated off set rather than the file, so a node an authored part override switched off with enabled: false and a node whose switch you threw this session appear the same — both are things the world is not showing, which is the question the ledger answers. A map that ships forty-six nodes disabled in its patches finally has somewhere they can be seen.
Each row carries its own Restore button; the card’s foot is Restore all. Either goes through the same scene.enable line the Identity card’s switch sends, so a restore replicates to every client and lands in the pending edits like any other switch — the row clears the moment the server’s state comes back with the node on, and a save writes the result exactly as if you had ticked the box.
A deleted node is in that set too, and its row is the undo. A delete and a switch do the same thing to the world and differ in what a Save would write, so a deleted node is listed here beside the switched-off ones and Restore takes it back — through the same scene.enable line every other row sends. A destructive verb with a visible way back is worth more than one nothing can talk out of itself, which is why the runtime state gave up refusing to be re-enabled when the editor gained a place to list what had gone.
The ledger is nodes, and a deleted ENTITY has its own two ways back. A removed light or camera is still a row in the Hierarchy — nothing about it was torn down — so selecting it and ticking Enabled puts it back, through the same scene.enableEntity line Ctrl+Z sends. That is why the inverse of a removal needs to carry no pose, no name and no component state: none of them ever went anywhere.
What makes a delete permanent is Save, and then Build. Save writes the part override’s "removed": true, and the map the next build produces never even decodes the node — at which point the ledger has nothing to offer, because there is no deleted node to restore. (scene.enable can still bring an authored removal back for the session; that one node is then read from the pallet on its own.) Until that build the world you are standing in is the one that was compiled before you deleted anything: its lightmap and its GI were computed with the node present and still carry its shadow and its bounce. Deleting geometry does not re-light a room; building the map does.
The subtree rule holds here too. A node a parent’s switch took down with it is folded under the parent’s row rather than listed — restoring the parent replays the cascade and the whole group comes back. A node switched off on its own, before or after its parent, keeps its own row and is not resurrected by restoring the parent: the ledger draws exactly the distinction the switch draws. With nothing switched off the card says Nothing disabled and Restore all draws disabled.
The marks a changed field wears
Section titled “The marks a changed field wears”A field whose live value differs from the map source says so where the value is, rather than only in a count somewhere else: the row’s ink comes up to the brightest the window has, and an accent bar lights in the gutter to the left of the label. Move something and the Position row wears it; switch something off and the Enabled row does.
The gutter is always there. It is reserved on every row and merely painted transparent on the quiet ones, so a row that lights up cannot shove the numbers beside it sideways. Nothing on the card moves; something on it lights.
The mark is per field, not per object. A card with a bar across the whole of it would leave you hunting for which of its numbers had moved, which is the question the bar exists to answer. A component’s switch is the one thing whose field is its card, so its bar lights in the gutter at the head of that card’s heading, beside the switch it is about.
Every row answers for itself. Turning a thing lights the Rotation row and leaves Position quiet, resizing it lights Scale, and each answer is that row’s own value compared against what the source holds — so a field added to any card later gets its mark the same way, without being written down anywhere central.
Revert undoes exactly the field the bar is on, and it lives where the card’s other verbs live — in the section’s own three-dot menu, beside Copy and Paste. Revert Position, Revert Rotation and Revert Scale are under Transform, Revert Enabled under Identity beside the switch it reverts, and each is drawn dead on a field with no bar: a row that was always alive would be a no-op you have to press to discover. One row each rather than one verb for the card, because undoing a turn must leave a move you also made exactly where it stands. Reverting restores the source’s value live, the same way typing the old number in would, so the mark clears because the thing agrees with the file again — not because anything was forgotten. It names one object’s own source pose: a revert on a selection reaches every member that deviates, and never cascades into a subtree.
Right-click the mark itself and the row hands you its two verbs. Anywhere on a changed row except the value control — the accent bar, the gutter it sits in, the name beside it — opens a small menu at the pointer with Save and Discard for that field, named after it: Save Position, Discard Roughness. Save writes that one field into the source and leaves every other pending change exactly as pending as it was, and Discard is the same act the card menu’s Revert performs. The value control keeps the gesture off deliberately, so a right-click in a number box is still the box’s. A row with no bar on it still offers both, drawn dead, for the same reason the card’s Revert is: a verb that vanished when there was nothing to do would have to be discovered twice. Position, Rotation and Scale are three savable fields, one per row, because a part’s override states the pose members it is given and leaves the rest of the file alone. Save Position writes a position; the turn you have not saved yet stays pending and keeps its bar, and the rotation already in the file is copied back through the rewrite verbatim.
The section headings offer the same two over the whole card. Every card’s three-dot menu — in the material inspector as well as on the node cards, one header widget rather than two that look alike — carries Save and Revert for the section, acting on the rows in it that are actually pending and dead when none of them are. A material’s per-field save writes into the material this map already owns; on one the map only borrows it declines rather than quietly minting a variant, and says so — that is what the card’s Save button, which does mint one, is for.
The two component cards carry no revert row at all, which is the truth rather than an omission: a component state is session-only, so there is no authored value to go back to. What a save cannot write, a revert cannot restore, and a dead row would only promise otherwise.
Drag the letters to move the numbers
Section titled “Drag the letters to move the numbers”Every editable number can be scrubbed. The hot zone is the axis letter beside the field — X, Y, Z — or the row’s caption on a single number, never the field itself, so the box stays a box you can click into and select text in. Every number in the window is one Halcyon ScalarCell wearing a different letter: a vector is three of them, a color is three behind a swatch, a scalar is one whose letter is the caption — so a rule stated here holds for all of them because there is only one of them. Press the letter, the cursor becomes a horizontal resize arrow, and drag: right raises, left lowers.
- Shift is coarse (×4) and Alt is fine (×0.25), and they multiply — holding both is exactly the ordinary rate, which is the arithmetic rather than a special case.
- Sensitivity follows the number’s own magnitude, frozen for the whole gesture, so a value of 400 does not crawl and a value of 0.4 does not fly. The step is rounded to a sane number of decimals as it goes.
- A dead zone of a few pixels stands between the press and the first change, so a click that was meant as a click does not nudge anything.
- The cursor wraps at the edge of the screen and the travel does not: like the rotate drag, the gesture keeps its own odometer from raw motion, so a long scrub is not rationed by desk space.
- Escape cancels and puts back the value the press started from.
- A scrub is continuous typing. Every frame goes out through the same setter the field commits on Enter, which is why a scrub and a typed number and a gizmo drag are all one edit as far as the world, the marks and Save are concerned.
A row with no setter has no handle at all — a read-only Size offers nothing to grab, rather than a grab that does nothing.
A light’s override prunes itself: a field set back to exactly what the map authors (or, for a created lamp, what it was created with) drops out of the override rather than being stored as a change, so switching Falloff physical → unity → physical leaves the row unmarked and writes nothing on Save.
A number shows what it is, and nothing more
Section titled “A number shows what it is, and nothing more”Trailing zeros are stripped, and then the point with them. 0 reads 0, 1 reads 1, half reads 0.5, a concrete density reads 2400 and a tiling of one and four tenths reads 1.4. One formatter does it for the whole editor — every numeric field, every vector component, every color channel and every slider readout — so no row can spell a number a different way than the row under it. What it replaced was the fly-speed chip’s own formatter, which drops the leading zero and keeps the point: .00, 1.00. That spelling is a speed multiplier hanging over the world for a second after a wheel notch, where a fixed width is the point, so the chip keeps it and nothing in the Inspector does.
Precision is unchanged: each field still keeps as many decimals as it always did — two on a transform axis, whatever its own row asks for elsewhere — and the stripping happens after, on the text.
A field is never narrower than the number inside it. Every numeric box carries a floor into the row’s width distribution, so a pane dragged narrow takes the room out of the slider beside the field rather than out of the field itself. Without it a squeezed row handed the box less width than its own digits and the text scrolled the leading one out of sight — a density of 2400 read as 400 with the 2 cut off at the left edge, which is a number that is not merely cramped but wrong. A row with width to spare still grows the box past the floor as it always did.
The description hangs off the name
Section titled “The description hangs off the name”A tooltip belongs to the label, not to the value. Rest the pointer on a row’s name — including a name you can drag to move its number — and the row’s sentence appears after the usual dwell; rest it on the field, the slider or the dropdown beside that name and nothing appears, because a plate over the control is a plate over the thing you are reaching for. Unity’s inspector behaves the same way, and it is the reference here.
The plate itself is opaque (client.editor.tooltipOpacity, 1 by default — Halcyon blends in linear light, so the alphas that read as solid in a gamma-space tool let an inspector row show straight through), set at its own size (client.editor.tooltipFontSize, 15, a step above caption size) and wraps at client.editor.tooltipWidth authored pixels, so a sentence is a paragraph on a plate instead of one line running off the side of the screen. The dwell is still the one client.editor.tooltipSeconds every editor tooltip shares.
A slider says what it is about to give you. Hover a track without pressing and a small plate follows the cursor, sitting above it and holding one thing: the value a click there would apply, spelled by the formatter above. It moves along with the pointer and leaves the moment the pointer does. It is the same plate the descriptions use, positioned by the same placement rule with the anchor set to the pointer instead of to the widget — one rule with a parameter, not a second tooltip. The asset browser’s scale slider is the one track with no readout: its value is a step on a ladder of thumbnail sizes rather than a number anybody reads.
The material preview
Section titled “The material preview”The ball on the material inspector’s header raises a small window with a body wearing that material, lit and turning where you can look at it. The window carries the material row at its top — the same widget the Renderer card draws, inheritance line and Open button included — so what is being looked at names itself and the way back into the inspector is on the window rather than behind it. It is the actual material through the actual opaque pipeline the world draws with, not a thumbnail: shadows, ambient occlusion, lightmaps, indirect light and dissolve are switched off so nothing but the surface is being judged, and a key light sits over the viewer’s shoulder. There is no tonemap in the way.
A choice control at the top picks the body: Sphere, Cube or Plane. It is the same control the inspector gives every enum field — a segmented row while the options are few, folding into a dropdown when they are not — so the picker reads as part of the editor rather than as a row of chips of its own. The sphere shows every normal at once and is what a preview is usually asked for; the cube is turned off square so three faces read at once, and the plane is a single quad facing the camera, leaned back far enough for the key light to land on it — the honest way to look at a tiling texture. The choice is remembered in client.editor.materialPreviewShape, so the next slot you look at opens wearing it.
A Lit / Flat toggle beside it picks what is shining on the body. Lit is the key light over the viewer’s shoulder with a sky-and-ground fill under it, which is how a normal map, a roughness and a metalness read at all. Flat switches the key off and makes both hemispheres one white dome, so the picture is the material’s own color rather than a lighting result — the honest way to check an albedo, a tint or a texture’s seams. Nothing about the shader changes between them; it is the same world pipeline under a different rig, so a material that looks wrong flat is wrong. Unlike the object picker it offers the same two whatever the material is, water included: switching the key off is exactly how you read a pool’s tint out from under its highlight. The choice is remembered in client.editor.materialPreviewLighting.
A water material is the one exception, and it shows a pool. A body of water is not a shape you can wrap around a ball, and a sphere of it would smear a world-scale wave field over a curve, so the family brings its own body — and the inspector’s preview drawer says so in the picker rather than graying the standard three out under a preview none of them describes. The floating window still lists the standard bodies: it is opened on a SLOT rather than on a described material, so it has nothing to ask about the family. See water previews in a pool.
Drag inside it to turn the body. It is a turntable: drag right and the side under the pointer follows the pointer, the same as every other tool you have used. Yaw runs freely — a full turn is a legal drag — while the elevation is clamped short of the poles, since the up vector is world up and passing it would flip the picture rather than continue the motion. The whole body is the drag surface, so a drag that starts beside the shape still turns it.
Middle-drag inside it to slide the body. The grabbed point follows the pointer, in the sign and the feel the scene viewport pans with. The travel is measured as a fraction of the framed picture rather than in meters, so the same drag covers the same amount of the window at every magnification, and it stops at client.editor.materialPreviewPanLimit so the body can never be pushed out of sight.
F puts the view back — yaw, elevation, zoom and pan all at once — while the pointer is over the preview. The preview claims the key while hovered, so the editor’s own F (frame the selection) does not also fire; move the pointer off the picture and F is the editor’s again.
The wheel zooms, multiplicatively — one notch is the same proportional step wherever the zoom already sits — and clamped at both ends. The notch is taken by the preview under the pointer rather than by the pane it is docked in.
The picture fills the window. The camera frames the body’s bounding sphere against the tighter of the two field-of-view axes, so a window dragged tall, wide or square shows the whole shape with the same margin around it and no black bars.
The render follows the window’s size on every frame of the drag, so a preview being resized is never a stretched picture and the ball is never an egg. What is deferred is the memory, not the picture: the image is allocated with client.editor.materialPreviewHeadroom of spare past the window and the ball is drawn into a sub-rectangle of it, which the picture then samples. A whole gesture usually costs zero rebuilds — only a window dragged past its headroom grows the image, and that happens on the same frame too, because there is nowhere else to put the pixels. Giving memory back is the one slow move: the window has to sit client.editor.materialPreviewShrinkSlack inside its image for client.editor.materialPreviewResizeSettle before it is rebuilt smaller. The size is rounded up to client.editor.materialPreviewResizeQuantum so a slow drag crosses far fewer sizes than it does pixels, and capped at client.editor.materialPreviewMaxSize on each axis, past which the picture is stretched for good — a window dragged across a large display is not worth a full-screen color and depth target for a ball. client.editor.materialPreviewSize is the square size a preview opens at, for the one frame before its window has been laid out.
One window per slot, keyed by the slot’s name: asking for a slot already open raises the window that is up rather than stacking a second copy. Different slots open side by side. The title is the material’s own last name segment. Each preview redraws only when something changed — an orbit that ends where it began costs nothing — and recompiling the material redraws it live.
Each open preview takes one of the interface’s texture slots. Running out is not a crash: the window simply does not open, and the console says which slot it was and that you can close a preview or raise client.render.uiTextureSlots.
| Preference | Default | What it does |
|---|---|---|
client.editor.materialPreviewSize | 512 | Square size a preview opens at, in pixels, before its window has been laid out. Clamped to 64–2048. |
client.editor.materialPreviewMaxSize | 2048 | The largest a preview’s render target may grow on either axis, in pixels. Clamped to 64–8192. |
client.editor.materialPreviewResizeSettle | 0.1 | How long the image may sit oversized before it is rebuilt smaller, in seconds. Clamped to 0–2. |
client.editor.materialPreviewResizeQuantum | 8 | The step the render size is rounded up to, in pixels. Clamped to 1–256. |
client.editor.materialPreviewHeadroom | 0.25 | Spare image allocated past the rendered size, as a fraction of it. The drag budget: a window grown by less than this costs no rebuild. Clamped to 0–2. |
client.editor.materialPreviewShrinkSlack | 0.35 | How much smaller than its image a preview must be before the memory is given back, as a fraction. Never tighter than the headroom. Clamped to 0–4. |
client.editor.materialPreviewShape | sphere | Which body a preview opens wearing: sphere, cube or plane. Anything else reads as sphere. |
client.editor.materialPreviewLighting | lit | What a preview opens under: lit or flat. Anything else reads as lit. |
client.editor.materialPreviewFlatAmbient | 1.6 | How bright the white dome is in flat lighting, as a multiplier. Clamped to 0–8. |
client.editor.materialPreviewFov | 35 | Vertical field of view, in degrees. Clamped to 5–120. |
client.editor.materialPreviewMargin | 0.12 | How much empty frame is left around the body, as a fraction of its size. Clamped to 0–4. |
client.editor.materialPreviewOrbitSpeed | 0.008 | Radians the turntable turns per pixel of drag. Clamped to 0.0005–0.2. |
client.editor.materialPreviewPitchLimit | 85 | How far the camera may rise off the horizon, in degrees. Clamped to 1–89. |
client.editor.materialPreviewZoomStep | 0.12 | Fraction the picture magnifies per wheel notch. Clamped to 0.01–1. |
client.editor.materialPreviewZoomMin | 0.4 | The furthest the wheel may pull back. Clamped to 0.05–1. |
client.editor.materialPreviewZoomMax | 3 | The closest the wheel may push in. Clamped to 1–20. |
client.editor.materialPreviewPanSpeed | 1 | Scale on the one-to-one middle-drag pan. Clamped to 0.05–8. |
client.editor.materialPreviewPanLimit | 0.5 | How far a preview may pan off center, in framed view heights. Clamped to 0–4. |
client.editor.materialPreviewPlaneTilt | 18 | How far the plane leans back toward the key light, in degrees. Clamped to -80–80. |
client.editor.materialPreviewCubeYaw | 30 | How far the cube is turned off square, in degrees. Clamped to -180–180. |
client.editor.materialPreviewKeyIntensity | 2.4 | Intensity of the key light. Clamped to 0–20. |
client.editor.materialPreviewAmbient | 0.7 | Hemisphere fill multiplier. Clamped to 0–8. |
client.editor.materialPreviewBackground | 0.05 | Background as a linear gray level, over 0–1. |
client.editor.materialPreviewWavePhase | 8.3 | The moment of the wave a water preview is frozen at, in seconds. Clamped to 0–3600. |
client.editor.materialPreviewWaterElevation | 22 | How much higher a tub preview opens than the resting angle, in degrees. Clamped to -80–80. |
client.editor.materialPreviewPondElevation | 55 | How much higher a pond preview opens than the resting angle, in degrees. Clamped to -80–80. |
They all apply live — an open preview redraws with the new value rather than waiting to be reopened.
Material thumbnails
Section titled “Material thumbnails”Every material row wears a small round picture of the material it names, at the left of the row and ahead of the slot’s caption. It is the same body the preview window shows, through the same pipeline and the same lighting, rendered once at thumbnail size and drawn clipped to a circle — the corners of a square crop of a ball carry no material at all.
A water material’s picture is a tub, not a ball, and its plate is a rectangle rather than a circle. Water is shaded from what lies under it, so a lit ball says nothing about it; the icon is the tub looked down on, which fills the picture with the one thing being described. Circle-clipping a flat sheet of water reads as a ball anyway — the surface is flat, not spherical, so the tile that names it is a rounded rectangle instead, the same shape a texture’s tile wears. Every surface fed by the icon service wears both — the slot rows, the material inspector’s header, the browser’s grid tiles, the drag ghost — because the body and the plate’s shape are both chosen from the shader once, at the point the thumbnail’s cache key is minted, and the key carries the body. A cached ball is therefore never handed back for a water material.
A picture can be asked for by either name. A slot name is what the Inspector holds and a barcode is what a browser row holds, and the thumbnail seam resolves both to the same slot — so a tile, a drag ghost, a slot’s disc and the material inspector’s header all share one cached picture rather than four spellings of the request. A barcode no slot in this map wears has nothing to render: it falls back to the muted disc, which is the honest picture rather than a placeholder pretending to be a render. Every one of those sites draws through the same builder, header included, and asks the same question for the plate’s shape: a thumbnail is rendered over an opaque backdrop, so the wrong shape anywhere would show that backdrop as a gray fringe around the picture.
Nothing is rendered twice. A thumbnail is looked up by a hash of the material’s folded content: the merged definition after inheritance, the checksum of every texture channel it binds, and the size it is drawn at. Packed thumbnails share one atlas texture per size, so a node wearing thirty slots costs one of the interface’s texture slots rather than thirty; when an atlas fills, the thumbnail shown least recently makes room. Below the atlas the pictures persist under %LocalAppData%DigitalHeavenEnginecache humbnails as .dhthumb files, so opening a map a second time fills its rows without rendering anything at all.
A row whose picture has not arrived yet holds its place with a muted disc of the same size, and swaps the material in when it lands — a list never reflows under your pointer. Renders are budgeted per frame, so opening a node with a long slot list costs a couple of small offscreen draws a frame rather than a stall. Recompiling a material simply changes its hash: the row asks for a new picture and the old one is never consulted again.
| Preference | Default | What it does |
|---|---|---|
client.editor.materialThumbnailSize | 64 | The size a thumbnail is rendered at, in pixels, before the interface’s scale. Clamped to 16–512. |
client.editor.thumbnailsPerFrame | 2 | How many thumbnails may be rendered per frame; reads from the disk cache are not budgeted. Clamped to 1–32. |
Rebinding a material slot
Section titled “Rebinding a material slot”Drag a material out of its round thumbnail and drop it on another material row. The row under the pointer takes it, and every surface in the map wearing that slot is drawn with it from the next frame — for you and for everybody else in the session. The thumbnail is the handle; the whole row is the target. A short pull that never leaves the disc is still a click, so the disc opens the preview exactly as it did before, and a drag has to travel the same distance a dock tab does before anything lifts. Once it has lifted it is a drag until you let go — bringing it back over the disc it came from and releasing there lands nothing and opens nothing, because a gesture you backed out of is not a click.
Or out of the asset browser. A material row in the browser is the same handle: press it, pull past the same threshold, and what lifts is the drag the disc lifts — the same ghost, the same rows lighting up, the same drop on a row or on the wall in the game view. The ghost carries whatever picture the reference in your hand has — the browser’s rows and tiles ask by barcode and get the same ball a slot’s disc does — and falls back to the muted disc with the name on it for a material nothing in this map is wearing; everything past the lift is one gesture with one bind behind it.
What is in your hand is drawn: the ghost is the material’s own round picture and its name, on a faint plate that follows the pointer.
While something is in your hand, the rows that would take it light up — an accent border with a wash of the same accent inside it. A row that would not take the drop never lights: the row the material came from does not, since dropping a material where it already is changes nothing, and a heading that cannot grow a slot does not either. Escape drops it, and so does letting go anywhere that is not a zone; a gesture you backed out of lands nothing.
Or drop it on the surface itself. Carry a material out over the game view and the wall under the pointer wears it while you aim. The ray is the one a click already uses — collision-accurate, and answered against the triangles — so the slot dressed is the one the triangle under the cursor draws with rather than the node’s first: a thing wearing several materials takes the drop on the one you are actually pointing at. Crossing to another surface moves the picture with you and puts the first one back; leaving the geometry, coming back over any window, or pressing Escape restores it the same frame.
What you are looking at while you aim is a picture, not an edit. Nothing is sent, nothing is marked pending and nobody else in the session sees anything move — it is your own client wearing the material, through exactly the rebind the replicated edit uses, which is why it obeys the same rule about materials the map already loaded. Letting go is what makes it real: the preview is put back first and then the slot is bound for everyone, through the same material.bind a row-to-row drop sends. Letting go over a window is the row-to-row gesture, unchanged. Without authority over the world there is nothing to drag: the material rows are dead, so the gesture never starts and no toast has to explain half of it afterward.
| Preference | Default | What it does |
|---|---|---|
client.editor.dragPreview | true | Whether a material held over the viewport dresses the surface under the cursor before it is dropped. Off, the ghost and the drop are untouched and only the picture goes away. It is worth having off while hunting a rebind across a large map: a slot belongs to the map, so the preview repaints every surface wearing it for the frames it is up. |
A slot belongs to the map, not to the thing you selected. That is why this repaints every surface sharing the slot rather than only the node in front of you, and why selecting several things lists the slots they share and rebinds one for all of them. It is one edit either way.
The material has to be one the map already loaded. A rebind hands a slot a material this scene resolved when the map was built — its textures and its descriptor set are already on the device, and nothing on this path can upload more. That is why a browser row for a material this map never loaded is not a handle at all: it clicks and opens like any other row, and pulling on it lifts nothing, so the rule is answered before the gesture starts rather than by a toast after it. From a slot it is nearly always true anyway; when it is not, the slot keeps what it had and a toast says so rather than the map drawing a hole.
The edit is a server-authoritative material.bind, so it survives for anybody who joins afterwards, and the Renderer card’s Revert drops every binding its slots are wearing at once. Save writes the binding into the map’s own materials table.
A rebind is an unsaved edit, and says so where every other one does. The bar takes the asterisk and counts the binding among what is pending; the rebound row wears the accent gutter in the margin, and the Materials heading wears it too whenever any slot in the list disagrees with the saved map — including a slot the saved materials table has no entry for at all, so a folded list still says something in it has moved. Saving clears them.
A folder in the Inspector
Section titled “A folder in the Inspector”Select a folder in the asset browser and the Inspector answers it, in the same card every other subject is described by: the folder’s name as the title, its Path, how many entries it holds in all, and a line per kind it actually contains — Materials, Textures, Objects and so on. Kinds it holds none of are left out, because a column of zeros answers a question nobody asked of a folder they opened to find out what is in it.
A folder is the weakest claim on the window: anything selected in the scene wins it, and a locked pane keeps what it pinned. Nothing here is editable — a folder is not a thing in the world and carries no components — so the rows are the same read-only fields the identity card uses, and the counts come from the listing the browser already walked rather than from a second trip to the disk.
The material inspector
Section titled “The material inspector”Click anywhere on a Renderer material row and the Inspector shows the material itself — not the object wearing it. It is the same window, in a second arm: the node arm describes a thing in the scene, the material arm describes a dh.material, and a pane shows one or the other. The round thumbnail, the barcode, the name and the row’s own Open button are one press, on the reading that clicking a picture of a thing — or its name — opens the thing. The floating preview is raised from the material inspector’s own header ball once you are there.
The header is the material’s identity: a thumbnail of a slot wearing it, the barcode spelled the way the bar spells the map — the pallet prefix quiet, the material’s own name bright, the .dh-mat dropped — the shader, and what it inherits, if anything.
A material the map does not wear opens anyway
Section titled “A material the map does not wear opens anyway”Any material the browser lists can be inspected, whether or not the loaded map draws with it. One that nothing wears is brought up from its own pallet into the session the moment you open it — the same fold, the same texture residency, the same GPU upload a worn material gets at map load, filed under a slot nothing draws. So the cards, the round thumbnail and the preview drawer answer for it exactly as they do for a material the map is full of, and an edit behaves the same.
Loading runs on the map load’s own frame-sliced path, a slice a frame, budgeted by client.material.loadBudgetMs. Under the header a quiet line says where the material stands — loading from its pallet while it comes up, then not worn by anything in ‹map› once it is there, or cannot be loaded: … naming the pallet this machine does not have. It is a caption rather than a toast: the pane is already open on the material the line is about.
Nothing about the map changes. A pallet opened this way is a session resident — closed when the map switches, never written into the map’s manifest, never a dependency. Looking at a material is not authoring one.
The shader row names the pass a surface draws in, and it is a chooser like every other. A material is water either by naming water or by carrying a water block — the word decides when it is stated and the block supplies the numbers — so the row reads water for both, and the same one predicate settles it for the row, for the preview shape and for the slot that gets built.
Selecting a model is a live material.set, and the list it offers is exactly the list a .dh-mat may name: a selection is saved by writing that word into the file, so a word the chooser offered and the compiler refused would save a material that no longer compiles. lit and unlit differ by one flag the parameter builder sets and cost nothing. water costs a rebuild — the surface moves into another pass, which is the loader’s business, not the row’s — and the Water card appears or disappears with it, since the card is present exactly when the surface is water.
The inherits row is an input too. Click it — the parent’s barcode, or the quiet nothing a root material shows — and the asset browser opens as a picker filtered to materials, standing in the folder the current parent lives in, so the siblings a material is usually reparented onto are the first thing on screen with their own thumbnails. Picking one is an ordinary field edit on inherits: the child’s own stated rows are untouched and only the chain above it moves, so every row it never stated re-reads off the new parent, the deviation marks re-derive, one Material ‹barcode› inherits entry goes in the history, and a Save writes the new base into the file.
Under it the schema’s own grouping, one card each:
- Surface —
metallic,roughness,tint,emissiveand itsintensity,alphaMode,renderFace,kindand the surface’sfrictionmultiplier. The card’s own heading and thekindrow both wear a small tile naming the chosen kind — a hand-drawn one where the kind has one, the generic mark otherwise — leading the row’s own token on the chip and on every option the dropdown offers, sostonereads as stone before it reads as a word. - Textures — the six channels in order:
baseColor,normal,metallicRoughness,occlusion,emissive,height. A bound channel draws the texture itself at the head of its row: a rectangular, rounded thumbnail with the transparency checker behind it, so a cutout reads as a cutout rather than as a name. It is the same picture the browser’s tile draws, asked for by the same barcode through the same cache — a channel and a tile cannot show one texture two ways. Beside it the barcode — fully qualified, naming the pallet that actually answered the channel rather than the pallet-relative path the file states, which is what lets a material opened out of a pallet the map never loaded draw its own textures. It is dimmed but for the asset’s own name, the way every barcode in this editor reads. Click the thumbnail and the texture opens in the picture window, decoded atclient.editor.texturePreviewSizerather than at the row’s own edge, with the barcode as the window’s name; a texture already up is raised rather than opened twice. Click and drag are the same gesture read by the one drag threshold the editor has, so a pull is never also a click. Drag the thumbnail out and what lifts is the drag a browser row lifts, with the texture in your hand — onto another texture field, or anywhere else that takes one. Drop a texture on one and it binds, which is the samematerial.setline every other row writes — the channel is an ordinary schema field, and what it costs behind the scenes (a slot built again rather than a uniform rewritten) is the client’s business rather than the row’s. The zone lights only for a texture; a material dragged over one is the wrong kind and is refused the way every other field refuses it. - UV —
uvScale, two axes. - Water — present on a material that authors a
waterblock and absent on one that does not, and the one card that folds into parts. It wears a mark beside its name; preset sits on top of the card, above every part, because it seeds most of what is under it. Then seven collapsible sub-sections, each with a mark of its own: clarity (deep color, depth, haze, haze color, fog color), waves (height), ripples (strength, size), foam (shore width, color), current (current, direction X and Z then speed), optics (index, dispersion, caustics) and physical (density). The words are screen-only — the fields are still the schema’stransmittanceColor,atDistance,scatter,scatterColor,fogColor,waveScale,chopScale,chopWavelengthMeters,foamWidthMeters,foamColor,flow,ior,dispersion,causticStrengthanddensity, and a.dh-matis unchanged by any of it. Every row hangs a one-sentence tooltip saying what the eye sees when the number moves, and a field’s unit lives in that sentence rather than as a suffix on the value. - Parallax —
heightScale, the reference plane, and the self-shadow strength. - Stochastic — the one switch.
- Decals — the decal list, which is the one card in the inspector where a row can be brought into existence rather than only retuned. Each entry wears a head naming its position — decal 1, decal 2 — with ↑, ↓ and ✕ on the right, and under it that entry’s own eleven members, drawn by the same controls every other card’s rows use. At the foot is add decal, dead once the list is as long as the shader loops to. A material with no decals still draws the card, saying no decals over that button — the place a first entry is made cannot be a place that only exists once one has been.
The rows are described by the host, not assembled by the window. A material’s schema is a long flat list of independent knobs; naming each one on the wire record, again in the module’s builder and again in the console vocabulary would be one list written three ways, and it would drift the first time the schema gained a field. So the host describes the rows and the module draws whatever it is handed — which is why the cards come out in the schema’s own order and the editor never learns a field name.
Every edit is a material.set line: a material is an asset rather than a scene object, so its fields travel through its own verb rather than as a scene field edit. The server decides the change and replicates it, so a roughness nudged here moves for everyone watching the map rather than under your own cursor alone, and typing the line into the console and dragging the number are the same edit. The one edit that is not a material.set is a decal, which cannot be: the list is a single field and no typed value spells it, so adding, removing or moving an entry restates the whole list as one material.decals line — which is also what makes it one undo step rather than eleven. The Revert button at the foot is one material.reset: a material’s override layer is dropped whole, on the same reading that reverts a whole lamp. It is undoable, and one Ctrl+Z puts every retune it dropped back — the entry is spelled row by row out of the layer as it stood, since the one-word line itself has no inverse.
What a row’s marks say
Section titled “What a row’s marks say”Three facts about one row, and all four combinations happen:
- An accent label says the material’s own file states this value — a persisted override of whatever it inherits. It survives a restart, because it is in the file.
- The deviation bar in the gutter, the same bar a moved object’s Position row wears, says this session retuned it and has not saved.
- A dimmed value is pass-through: nobody in this material’s chain states it, and what you are reading is the base’s.
Every row reads the value the surface is actually shaded with. A material that authors two fields is still rendered with a full set of numbers, and a row that answered — for the rest was describing the file instead of the surface — so an unauthored roughness reads the engine’s own default, and an unauthored water field reads what its preset lends it, both dimmed. The one row that can still read — is a texture channel with nothing bound, where the absence is the fact.
A folded card whose material persists an override wears a dim accent mark on its head, because folded, that mark is the only thing left saying the card is not the base’s.
A row is editable exactly where material.set accepts it — which is every row the schema describes. A row the verb cannot reach is drawn as the word itself rather than as a control that would discard what you typed, the same rule the transform rows follow, and what is left of that case is a viewer with no authority over the map. A texture row’s thumbnail is both: a picture of what is bound, and a handle to drag it out of.
And a gray row says why it is gray. A control drawn dead beside a header that still looks ordinary reads as an inspector that has broken rather than as a value this surface cannot reach, so every refusal carries its sentence. Where the whole card is refused — Textures, Parallax, Stochastic — the head takes the disabled ink too and the reason is printed once, quietly, above the rows. Where a card is mixed — Surface, whose alphaMode and renderFace are refused while roughness beside them scrubs — the head stays normal and the sentence hangs off the odd row as a hover tip, which is where the pointer already is when the question gets asked.
One sentence is left. The Parallax card with no height channel bound says “needs a height texture”: the march is off entirely there, and no amount of retuning those knobs will displace anything until a height texture exists. Nothing else is refused: a texture binding, the alpha mode, the render face and the stochastic switch are all live.
The preview drawer
Section titled “The preview drawer”A material inspector hangs a preview at the bottom of the pane, under the cards rather than among them: a person turning a roughness knob is looking at the ball while they turn it, and a preview that scrolled off the top would make the edit blind. It is the same body, the same gestures and the same offscreen target the floating window uses — the shell owns one preview for whatever the inspector is on, so a drawer costs exactly what one window costs. The strip carries the shape picker and the Lit / Flat toggle and folds the drawer away — its chevron is the same fold control, at the same target size, as the one on a Hierarchy row and on the Renderer card’s material list — and the band above it is a grip that drags the drawer taller or shorter.
The picker on that strip lists the bodies the material itself offers. For an ordinary material that is Sphere, Cube and Plane; for a water material it is Pond and Tub, because water renders its own bodies and none of the three standard ones describes it. It is one control fed by a list rather than a standard row and a second forked one, so a shader family that brings its own preview objects — one of them, or several — appears here without a chip row of its own. The lighting picker sits beside it with client.editor.materialPreviewGroupGap of space between them: they answer different questions, and one unbroken strip of chips reads as one control.
The drawer spans the pane edge to edge, and the grip snaps: drag it more than client.editor.materialDrawerSnap past its shortest useful height and it folds shut, and drag a folded one that far back up and it opens — the same fold the chevron on its strip does, reached by the gesture that is already in your hand. Short of the snap it simply resizes.
A material no slot in the map wears gets a notice. The preview renders a slot that is really in the world, so a material nothing is bound to has nothing to render — the drawer says that rather than sitting blank. Binding it to any slot fills it.
Hovering a row shows what it would be
Section titled “Hovering a row shows what it would be”A bounded scalar row carries a track beside its number, and resting the pointer on that track previews the value under it: the drawer redraws the material as it would be at that value, and nothing else happens. Slide along the track and the preview follows. Take the pointer off and the drawer is the material again on the very next frame.
A hover is never an edit. No material.set line is sent, the world’s override layer never moves, no deviation bar appears and no undo entry is made — so there is nothing to clear and nothing to step past. Unhover is the absence of the preview rather than a second write putting the old value back, which is why leaving a row can never leave a value behind. The composition happens once a frame, on the way to the renderer, over whatever the server has replicated; everyone else watching the map sees nothing at all.
The track is the override, the mark is where it came from. The handle stands at the value the material is actually shaded with. A ghost tick stands at the value the row inherits — what a Reset on that row would restore — so a departed field shows both readings at once: where this session put it, and where it came from. A row nothing has departed from has its ghost exactly under its handle, which is the honest picture of a value that has not moved. Dragging the handle commits, as one undoable edit, exactly the way typing the number does.
Only the track previews. The pointer over a row’s name or its number does nothing, because a position over a label means no value — reading a card is not the same gesture as asking what a value would look like. A toggle has no such ambiguity: hovering one previews the material with that switch thrown, and hovering a choice previews that choice.
Water previews
Section titled “Water previews”A water material draws water, in the drawer and in the floating window alike, through the same transparent pass the world’s water goes through and over a real depth buffer. It is not the ball with a blue tint on it: the surface refracts, absorbs with depth, foams at its shore and carries the same waves the map’s water carries.
A water material rests on a tub, and can be moved into a pond. The tub is the body the preview opens on and the one every thumbnail wears — its floor slopes away from you, and depth is what decides a water material’s color, its refraction and where its shoreline lands, so a flat floor would show you exactly one of each. The near end sits about a meter under the surface and the far end nearly four, which puts the whole range in one picture. The pool is authored at the size of a real body of water rather than at a ball’s — the presets’ wavelengths run from a couple of meters to tens of them, and a bathtub would show a tilt rather than a wave. The basin is closed on all four sides for a reason you can see when it is not: water is shaded against whatever lies behind it, and a ray that leaves the far rim of an open slab and finds nothing is shaded as infinitely deep, which paints the far half of the surface solid black.
The pond is the other reading, and it is flat and undistracting. It offers an open rectangle of water on flat ground, looked down on from close to overhead, with nothing else in the frame — the plainest reading of the material, for when the tub’s slope is more than a question needs. The ground runs well past the water’s edge on every side for the same reason the tub has walls: a ray that leaves the water and finds nothing behind it is shaded as infinitely deep, and the far half of an open slab turns solid black. The apron and the steep camera together keep every point on the surface over ground.
The ground under either body is the same neutral floor, not the developer grid. A preview panel is too small for the grid to read as scale — it only reads as noise, and it made the pond’s flat ground and the tub’s basin alike look busier than the material sitting on them. One gray floor, shared by both bodies, keeps the water itself the only thing the eye is drawn to.
The waves are frozen, not animated. A preview redraws when something changes rather than every frame, so the surface is rendered at a fixed moment of the wave — client.editor.materialPreviewWavePhase — which you can move to see the surface at a different instant. The camera also starts higher over water than over a ball, because a surface seen edge-on is a line: by client.editor.materialPreviewPondElevation over a pond, which is nearly overhead, and by client.editor.materialPreviewWaterElevation over a tub, which is enough to see the slope of its floor.
Every gesture is the ordinary one: the drag turns the water, the wheel zooms, the middle-drag pans, F puts it back. The Water card’s fields apply live, the same as every other card’s.
Saving a material
Section titled “Saving a material”The button at the foot of the material arm writes this session’s retuning into a .dh-mat source, so it survives the next compile. Where it lands depends on who owns the material, and the button says which of the two it is about to do: Save on a material in the map’s own pallet, Make variant on anybody else’s.
Make variant is offered whether or not anything is retuned. A child holding an inherits and nothing else is a worthwhile file — it is the way to get an editable copy of a shipped material in the map’s own pallet, and every knob you turn afterward is written in place. A Save with nothing to write refuses and says so, since rewriting a file with the numbers it already holds is not a save.
- A material already in the map’s own pallet is written in place. Only the fields the override holds are touched: the file is edited as bytes rather than reserialized, so its comments, its ordering and its unauthored defaults all come back out exactly as they went in.
- A material the map does not own — anything from
core:or another pallet — is never touched. The map’s pallet gains a child of it atmaterials/<baseName>.dh-mat, holding aninheritspointing at the original and nothing but the fields that were retuned. The child is named the way the map already names its own materials:core:materials/water-pool.dh-matmintsmaterials/water-pool.dh-mat, withname: "water-pool"— the folder and one extension dropped, and the map’smaterialstable written in the same relative form every other slot in it uses. Every slot bound to the old barcode is then re-pointed at the new one withmaterial.bind— the binds land before the layer is dropped, so the surface never blinks back to the base it was tuned away from — and the map is marked as having an unsaved change.
The variant exists the instant it is minted, without a build. The mint declares it to the server as a material that inherits its base, so the renderer, the thumbnails, the Materials list and the inspector all answer for the new barcode immediately: the bind takes, the inspector switches to the child, and every knob you turn afterward is written in place. Until the build, the variant is live world state like any other override — it is replicated to everyone in the map, and it is the build that makes it durable.
Either way the session’s override layer is cleared afterward, because the value is now the material’s own. The write is a source edit and the running world is showing a compiled pallet: what is on screen already matches, but only a build makes it survive a reload, so the editor raises its needs-a-rebuild latch and says so.
Locking a pane, and a second inspector
Section titled “Locking a pane, and a second inspector”An inspector follows the selection unless you tell it not to. The padlock in the pane’s header — right-aligned, left of the close box — pins it to whatever it was showing, so a material can stay on screen while you click around the scene it is used in. Beside the padlock, a + spawns another inspector pane as an ordinary tab: it drags, docks, tabs and closes like any window, and nothing in the dock knows it is a duplicate. Every unlocked inspector follows the selection; a locked one holds still. An unlocked pane showing a material lets it go the moment you select something else or click in the viewport — the padlock is the only thing that keeps a material on screen. Two panes side by side, one pinned to a material and one following the nodes, is the arrangement the lock exists for.
Right-click the pane’s strip or any of its tabs for the same three commands as a menu — Lock (a toggle, wearing a mark when it is on), New Inspector, and Close. The chips are the fast path for a hand that knows they are there; the menu is the discoverable one, and it hangs off both places because both are where a hand tries first. A right-click on a tab names that tab’s pane rather than whichever one happens to be in front.
Spawned panes take their ids from a counter that only climbs, never from a position in a list, so a closed inspector cannot hand its id — and with it a stale pane’s lock and drawer — to the next one opened. They survive a restart: a second inspector is remembered on exactly the terms the first one is, wherever you docked or floated it, and the counter restarts above every serial the restored layout carries so the next spawn cannot collide with one that came back.
The asset browser
Section titled “The asset browser”View → Assets raises a window onto the workspace itself — every pallet you author, as a tree, with one folder’s contents listed beside it. It docks across the bottom by default, beside the console, and it is an ordinary window in every other way: it splits, tabs, tears out and closes like the hierarchy or an inspector.
It is one pane, not one window. Everything the browser is — the tree, the breadcrumb, the listing, the search box, the kind chips, the right-click plate — is a single composition, and the window is what mounts it with a footer. A later file or folder picker mounts the same pane with a different footer, so the two can never drift into two spellings of the same list.
What the tree holds
Section titled “What the tree holds”The tree’s one root is Workspace, and it is a row you can stand on rather than a heading: selected, the listing is every pallet you author, drawn as folders you descend into. Under it are the source pallets in your workspace, each with its own folder subtree, and then every compiled pallet with no source on this machine — core, or anything shipped without its sources. The second kind is read-only: it has no folder to walk, so its listing is synthesized from the pallet’s own file table and every write action on it is dead.
The browser opens on Workspace, not inside whichever pallet happened to be first, and the breadcrumb always leads with it — so one click is the way back out of anywhere. A pallet that disappears from under you (a workspace moved, a folder renamed) leaves the browser exactly where it was, listing nothing and still naming what it was pointed at. It never silently relocates you to a pallet you did not ask for: a listing that quietly became a different pallet’s is worse than an empty one, because only the empty one tells you something happened.
A pallet or folder with nothing under it draws no chevron — the disclosure is the hierarchy window’s own control, and where there is nothing to disclose the cell is simply blank rather than a triangle pointing at an empty subtree.
Each root wears a small square that says how far its build is behind its source:
| Marker | Means |
|---|---|
| Green | Compiled after the newest source write. What the browser lists is what the world would load. |
| Amber | Compiled, but something under the source has been written since. What resolves may be old. |
| Red | Source with no compiled pallet at all. Nothing here resolves yet. |
| A dim ring | Compiled-only. There is no source to be behind, and nothing to build. |
This is the only square that ever turns amber: the breadcrumb’s own owner square, worn on an inspector header or a slot to name which pallet an asset belongs to, takes the link ink when you hold the source of the loaded map — ownership is a link, not a warning, so yellow always means unbuilt changes and nothing else.
The state is decided from two timestamps — when the pallet was compiled, and the newest write anywhere under its source — and nothing else. The whole-tree stat runs when the watcher says something changed, never per frame.
The walk is lazy, and nothing is ever opened
Section titled “The walk is lazy, and nothing is ever opened”A folder is walked the first time you open it and served from memory afterward, so a browser left open costs nothing per frame and a pallet of ten thousand textures costs whatever is on screen. Nothing here deserializes an asset to draw a row. The kind comes from the extension, the name from the leaf, the size from the directory entry the enumeration already read — a browser that opened files to enumerate a folder would cost more to raise than the thing it is a browser for.
A size is stated only where one was measured. A source row says what its file weighs on disk; a compiled-only row says what its entry weighed going into the pallet, which travels beside the path in the pallet’s own table. A row where neither is known — a folder, or an entry with no measurement behind it — says nothing at all rather than 0 B, because a zero is a number somebody will believe.
A row reads as the file name with its asset extension dropped: asphaltWet, not asphaltWet.dh-mat. The extension is already said by the kind badge in the gutter beside it, and a column of names all ending in the same eight characters is a column you read past. Companion files and dot-folders are dropped — they are real entries nobody opens.
Folders wear their own icon, drawn from the same table Studio’s asset tree and the VSCode extension’s icon theme draw theirs from: materials, models, avatars, objects, rigs, textures, pallets, maps, sounds and platforms each have a mark and a color, and anything else takes the plain folder. The mark opens when the folder does, and it is tinted rather than colored — one white mask per folder, inked with the folder’s color at draw time. There is one table, in DigitalHeaven.Core, which every surface reads — a second copy would let the editor and Studio disagree about what textures looks like, which is the kind of drift nobody notices until the two are on screen together.
A narrow tree clips, it does not deform. The indent, the disclosure and the icon are fixed cells that never shrink, and the name is what runs out of room — so dragging the seam in changes where a name is cut off and nothing else. A long name never pushes the mark beside it out of line with the row above.
It watches the workspace
Section titled “It watches the workspace”The source tree is watched. Save a material from another window, drop a texture in with Explorer, or finish a build, and the listing catches up on its own — no button to press. Changes are collected and answered once the workspace goes quiet for client.editor.browserRefreshMs, because a build writes hundreds of files in a burst and re-walking on each one would be a re-walk per file. A burst that never stops is capped by client.editor.browserRefreshMaxMs, so a long write cannot hold the listing off forever — which is exactly the moment somebody is most likely to be watching it.
Refresh is on the right-click plate for a folder or a pallet, and on the window’s own header. It re-walks now, ahead of the quiet window. It is the same drop-and-re-walk the watcher’s timer calls, not a second walker: there is one answer to “what is in this folder”, and both routes go through it.
Finding things, and acting on them
Section titled “Finding things, and acting on them”The search box filters by name substring, descending up to eight levels and reporting up to five hundred matches. It searches from the root of what you are in — the whole pallet, or the whole workspace when you are standing on Workspace — not from whichever folder you happened to be looking at when you started typing. Typing a name three folders deep still finds the one beside it, which is what a search box is for; narrowing to the current folder is what the folder already does.
The kind chips — All, Map, Material, Texture, Object, Avatar — narrow the files to one kind and carry the count of what they would show. Folders stay put under a lit chip: a chip is a filter on what is listed here, not a claim that nothing is anywhere else, and a tree that emptied itself the moment you asked for materials would leave you no way to walk to them. At Workspace the counts are the whole workspace’s, fanned across every pallet.
The breadcrumb above the listing is the trail back up, and it is the map picker’s breadcrumb: one convention for both windows over one workspace.
A single click selects, and a folder is as selectable as a file. Clicking a folder puts it in hand — the footer names it and the Inspector answers it — and the double-click is still what walks into it. The two gestures are deliberately split: selecting is how you ask what is this, entering is how you ask what is inside, and a listing where the only way to look at a folder was to leave it could not answer the first. A tree row is the exception and does both at once, because the tree is the navigator; the Workspace row selects nothing, because it is a scope over several pallets rather than a folder.
Clicking the empty ground below the rows clears the selection, and the footer falls back to Nothing selected. The floor is the only way to put a row down without picking another one up.
Double-click acts. A folder descends; a map loads, through the same route the File menu’s Open takes, unsaved-work guard included; a material opens in the inspector, loading from its pallet if the map does not already wear it. Anything else selects and does nothing, because nothing else has an action yet.
A material row is also a handle. Pull one out of the listing and you are holding the same drag a slot’s thumbnail hands you — drop it on a material row in the Inspector, or on the surface itself in the game view, which wears it while you aim. A short pull is still a click, so double-click and the right-click plate are untouched. Only a material the scene has already brought up lifts — one the map wears, or one you have opened in the inspector; every other row stays a row until then.
And so is an object or an avatar. Pull a .dh-obj or a .dh-avatar row out over the game view and a wireframe box marks where it would land — the spot under the pointer, on the surface you are aiming at, or client.editor.contextAddDistance down the ray when you are aiming at sky, clamped into the map either way. It is the same arithmetic the viewport’s right-click Add places by, so pointing at a spot means the same thing in both gestures. Let go there and the thing is spawned at that point, through the same spawn the console takes; let go over any window, or press Escape, and nothing happens. A drop is undoable, and the thing you dropped is what ends up selected. The spawn carries a request token and the server answers it with the id it minted, so the client learns what its own drop became: that id is selected the moment its replica arrives, Ctrl+Z sends spawn.remove for it, and a redo spawns it again — the entry re-learns the new id from the same answer, so a second undo removes what the redo made rather than an entity that is gone. Nothing is oriented: a dropped object faces the way spawn leaves it, exactly as Add does, and a re-spawn restores the point rather than any pose.
A voxel container lands as a map object. Pull a .dh-vox row out the same way and the same wireframe marks the spot; let go and the container is placed as an object carrying a dh.voxelVolume whose origin cell stands there. It is not spawned: the drop sends editor.create dh.voxelVolume, so the server makes a map entity every client is told about, exactly as Add makes a light, and Save appends it to the map. It is selected when the server’s entity list brings it back. The drop is one history entry, on Add’s terms: Ctrl+Z takes the volume out without a trace and a redo places it again under the same id. Once placed it is an ordinary map object: the gizmo moves, turns and scales the whole container, the Inspector names it Voxel Volume with a card for its container and any grids its delta shows or hides, and a click picks it by the box its drawn grids fill. Its chunks stream in around the camera as the voxel page describes, so a world appears a few frames after the drop rather than all at once.
The mark is a place, not a preview: nothing is loaded, ghosted or reserved on your side until the server grants the spawn, so the box says where rather than what. Only rows whose pallet is built lift — a source pallet whose compiled form is behind it (the amber marker) spawns nothing, so it hands you nothing — and only barcodes that resolve to exactly one spawnable asset. A plain .glb is not spawnable: what spawns is a .dh-obj or a .dh-avatar that points at one. As with a material bind, the spawn is the server’s to grant, so on a session you do not run the toast says what was sent rather than claiming what happened.
Right-click the empty ground below the rows and you get the plate for the folder you are standing in — Refresh, Reveal Source, New ▸ Material — raised where you clicked. It is the same plate the folder’s own row offers, and right-clicking again puts it away.
Every one of these plates opens at the pointer, not at the corner of whatever it belongs to, and they all place through the one helper the toolbar’s menus place through — so a row menu, a blank-ground menu and a pane menu cannot drift into three ideas of where a menu goes.
Right-click a row for its plate, which offers only what the row actually has: Load Map or Open in Inspector for the two kinds that have a verb, Copy Barcode for anything with a barcode, Reveal Source for anything with a file on disk, Refresh on a folder or pallet, and New ▸ Material in a source folder. An entry with nothing behind it is left out rather than drawn dead — a menu of four gray lines says less than a menu of one live one. The exception is a verb this client cannot have: on a client that is not the server the actions draw disabled, because that is a statement about the session rather than about the row.
The footer names what is selected — the barcode, with the bundle prefix dim and the asset’s own name bright, the way the map name reads everywhere else. With nothing selected it reads Nothing selected. There is no button beside it: the double-click is the door, and a second spelling of it bought nothing.
List or grid, from one slider
Section titled “List or grid, from one slider”The footer’s right side carries a scale slider, where Unity’s Project window puts one, and it says one thing: how many pixels wide a thumbnail is drawn. Small is the list you have been reading — one row per asset, kind badge in the gutter, the name beside it. Push it up and the listing becomes a grid of square tiles with the name under each: a material shows its own thumbnail, a texture shows itself, a folder its tinted mark, anything else its kind badge.
The slider snaps to a ladder — 16, 24, 32, 48, 64, 96, 128 — rather than sliding through every pixel, because each distinct thumbnail size costs its own atlas page. The list ends and the grid begins at 24. How many tiles fit across follows the pane: drag the window wider, or drag the tree seam in, and the rows repack.
The size is client.editor.browserTileSize, so it survives a restart and can be set from the console. A value between rungs is snapped to the nearest one, so client.editor.browserTileSize 44 draws at 48.
A tile answers every gesture its row does — click to select, double-click to act, right-click for the same plate, and a pull off a material, texture, object or avatar tile starts the same drag. For a texture, acting is opening it in the picture window at full size, which is the one thing the tile itself cannot do; a single click still only selects, the way it does for every other kind.
A texture tile shows the texture. Not a TEX badge on a gray square, which said the kind and nothing else — the compiled texture itself, decoded once and reduced to the tile’s edge, over the transparency checker, so a cutout sign or a decal reads as the shape it is rather than as a black rectangle. It comes up the same three tiers a material’s ball does — atlas, then the disk store keyed by content, then one decode under the frame’s budget — so a folder of textures scrolls at the cost of a dictionary lookup, and a recompiled texture folds to a new key and simply redraws. Until the decode lands the tile keeps its kind badge: a muted plate the size of a tile reads as a picture that failed, and the badge is the more honest wait. The rounding is the tiles’ own, and the picture is rectangular rather than round — a texture is an image with edges, and the disc is reserved for the thing that is a rendered ball. List rows keep the badge at every size below the grid: a picture eight pixels across is not a picture.
A map tile shows the map. A map whose source marks a thumbnail camera ships a rendered shot of itself beside it, and the tile draws that instead of a kind badge — the same picture the main menu’s map list shows, so a place is recognized the same way in both. The shot is square by default and the tile is square, so an unsized thumbnail camera fills the tile with no letterbox at all — a map that authored a wide thumbnailSize is letterboxed rather than cropped, and a map that authored no camera keeps its badge, which is the honest answer rather than an empty plate pretending to be a photo. Picture and badge alike come from UiThumbnail.MapPlate, which the launch window’s rows call too, so neither list can drift into showing a map differently from the other.
Making a material
Section titled “Making a material”New ▸ Material, from a source folder’s plate or from the window header’s +, writes a blank .dh-mat into that folder under the first free name, selects it, and opens it in the inspector. There is no inherit picker and no other kind this stage: it is a blank material, and everything else is authored where it already was.
The new material is live at once, before anything is built. It inherits nothing, so it opens as the engine’s default surface — plain lit, mid gray, no textures — and every field the material inspector offers is yours to tune immediately. Bind it to a slot and the slot wears it; the world shows what you tuned, not a placeholder. What the browser hands the inspector is the barcode the file will compile to, so nothing has to be re-pointed at the next build: the build simply replaces the synthesized surface with the compiled one.
Save writes an ordinary non-inheriting material: the live declaration is a fact about the session, never a line on disk. Minting into a compiled-only pallet is refused, because there is nowhere to write.
The toolbox
Section titled “The toolbox”View → Tools raises the toolbox: groups of buttons, each group one question with exactly one answer, and exactly one button in each group lit.
It is a bar, not a window — no title bar, no tabs, no fold box, no ✕. The buttons name it better than a caption could, and View → Tools is what puts it away. Drag it by anywhere the buttons are not: the whole plate is the handle, and a press that lands on a button is a click on that button rather than the start of a drag. It has exactly two snaps, and dragging it near either shows the same indicator every dock drop gets: the top of the whole editor (the bar spans the display, and every pane lays out below it) or the top of the 3D view alone (the bar rides the game view’s pane, surrounded by whatever columns stand beside it — its home). Anywhere else it floats. The snap is remembered as client.editor.toolboxSnap; a floating toolbox keeps its rectangle in the layout line like everything else.
It holds two groups. The first answers the first question a scene tool has to answer — am I looking, or am I moving, turning, sizing:
Every button says its name and its key when you rest on it — Move (W), Rotate (E), Scale (R), View only (T) — on the same plate, after the same dwell, as the map name’s barcode. An icon-only control has to spell its words somewhere, and the tooltip is the only place in the editor a binding is announced. The keys are live outside the panel and act only while the right mouse button is up, since W is the camera’s forward while you are flying.
- Move (the arrow, W) is the mode every editor session starts in. Clicks in the world pick, the hover outline follows the cursor, and the translate gizmo can be grabbed.
- Rotate (the circular arrow, E) and Scale (the corner arrows, R) pick and select exactly as Move does. The mode chooses which manipulator the selection wears and nothing else: Rotate raises the rings, Scale raises the boxed handles. Picking is the rule and looking is the exception — every mode but View only picks.
- View only (the eye, T) puts the world down. Nothing in the world answers the pointer — no hover, no pick, no gizmo — while everything else keeps working: the camera still flies, pans and frames, and the windows are still yours. It is a stance for reading a map, or for flying through a dense scene without collecting a selection you did not mean to make.
The second group is the handle space — Global | Local, which frame a drag is stated in. It carries words rather than icons: there is no glyph in the icon set that says “world axes”, and a picture invented for it would be a picture nobody could read. The words are set in the editor’s own label size, on the same plates and in the same colors the mode buttons wear, and each chip says Handle space: Global (X) or Handle space: Local (X) when you rest on it. Unlike the mode, the space is remembered — client.editor.handleSpace opens the editor in the space you were last working in.
Moving between Move, Rotate and Scale keeps the selection — the mode only decides which manipulator the selection wears. Switching to View only clears it: the gizmo and the outline leave with it, so the eye looks at the map and at nothing else. What you were holding is not carried in a pocket — come back and pick again.
The lit button swaps its colors — the accent behind it, the panel’s own dark ink on top — rather than growing a border or a check. Pointing at one of the other buttons lights it faintly, which is a third reading, so “this is the mode” and “this is what you are about to pick” never look alike.
The bar adapts to its own shape. Wide — snapped across an edge — the groups run across, one after the other; drag it narrow while floating and both groups stack into a column. The flip is decided by the bar’s own content width against one threshold, and it happens while the row still fits rather than at the moment it starts to clip. The bar’s minimum size is derived from what is actually in it in each orientation — the widest control down a column, the tallest across a row — so neither shape can be dragged small enough to cut a control off.
The bar’s snap and floating rectangle are remembered with everything else (see below). The mode is not. Every entry into the editor starts in Move, on purpose: the layout is a workspace and the mode is a stance, and an editor opened days later whose world silently ignored the mouse would be an editor nobody could explain.
The game view
Section titled “The game view”The world is a window. gameView is an ordinary dockable window whose body is the rendered world, docked in the center by default with everything else splitting around it. It has no ✕ and cannot be closed — a layout without it is not a layout — but it tabs, splits, reorders and resizes exactly like the hierarchy or the inspector, because it is the same kind of thing.
The world is rendered at the pane’s own resolution and fills it. A narrow pane is a narrow picture, not a cropped one: the field of view stays vertical, so squeezing the pane horizontally shows less to the sides and never stretches what is there. Everything that reads a screen position reads the pane — the projection behind the gizmos, the ray a click picks with, the corner the frame stats sit in.
A divider drag re-renders the world at the new size every frame, so the picture is never a stretched one. What a drag would otherwise cost is the allocation — rebuilding the scene’s images waits for the device to go idle — so the two are separated: the scene targets are allocated client.editor.gameViewHeadroom larger than the pane, the world is rasterized into a sub-rectangle of them, and the picture samples exactly that sub-rectangle. Moving the sub-rectangle is a viewport, a scissor and a render area, which is free. A pane dragged past the headroom grows the images on the same frame, because there is nowhere else to put the pixels. Only giving memory back waits: the pane must sit client.editor.gameViewShrinkSlack inside its images for client.editor.gameViewSettle before they are rebuilt smaller. client.editor.gameViewQuantum is the pixel step the render size is rounded up to.
The picture is measured while the pane is laid out, not after it. The world’s rectangle is decided during the same pass that builds the pane, so what the world is rasterized into and what the picture samples are the same rectangle on the same frame. Deciding it afterward would make a drag read as a series of small jumps, and a pane dragged past its headroom would show a black band along the new edge for one frame — the picture would be sampling the sub-rectangle the previous frame wrote, inside an image that had since grown.
Selection outlines are drawn at the pane’s size too. The outline’s mask lives in the same oversized image the world does, so it is rasterized into the pane’s rectangle and the composite walks its neighborhood in that rectangle’s pixels. Stepping in the whole image’s pixels instead skipped rows of the mask, and a one-pixel ring around a selected volume came out as regular dashes — worst on edges running shallowly across the screen, and only ever in a docked view, since a full-screen world fills its image exactly.
Pausing dims the picture, not the editor. With the editor open the pause scrim covers the game view’s pane alone. A scrim says the thing behind this is not being played right now, and the panels around the world never were.
The picture is opaque, which moves a whole band. Everywhere else the world is the frame itself and the developer tooling paints first, on top of it. Here the world is drawn by a screen, so painting first would put the frame stats and the position readout underneath it — which is exactly what happened the first night the pane existed. While a game view is docked the tooling is spliced immediately past the picture and inside the scissor the pane cuts it to: over the world, and out of reach of the pane’s own tab strip, the dividers, anything docked beside the view, the toolbar, the tool rail, the hover plate and every floating window. The game view’s pane names that point by wrapping its image in a WorldSeam, whose element opens the clip, draws the image, marks, then closes it. The clip rather than the order is what holds — a pane the dock solver placed to the left is painted first, so nothing about an index could put the band beneath it, and an earlier arrangement that marked after all the panes duly painted dots on the tab strip. While the editor is animating in it runs under a transition layer, where no mark is legal, and the band falls back to sitting over the whole editor for those frames.
It tears out like anything else. Drag the game view’s tab out of the dock and it becomes a floating window, and the world goes with it: the picture, the projection, the pick ray and every corner-anchored readout are sized from the floating window’s body rather than from a hole left behind in the dock. The seam rule holds out there unchanged — the tooling still lands inside the scissor the window cuts, so a gizmo drawn past the edge of the picture cannot reach the window’s own strip or the panes it is floating over. Drop it back on a pane and it docks again like any other window. A torn-out game view still counts as the one the layout must name, so the arrangement survives a restart.
Click to look. The game view takes the pointer only when you give it one: click the picture and it holds the pointer, click any other pane — or press Escape — and it hands it back to the interface. Entering the editor starts with the interface holding it, because entering the editor is reaching for tools. This is a term of the one rule that decides cursor lock, never a capture asserted at a transition, and out in play the term is held true for good: there is no pane there, and the world is the window.
Outside the editor none of this exists: the world renders straight to the window at full size, exactly as it always has.
Leaving unbinds before it releases. The pane’s image is destroyed on the way back to play, and the interface’s slot has to stop naming it first — a texture slot whose image is gone falls back to a valid substitute, and the substitute reads as colored static. So the order is: wait for the device, unbind the slot, then free the image. A pane that is drawn on the frame in between draws nothing, which photographs black. Black for a frame is fine; garbage never is.
The dock layout
Section titled “The dock layout”The editor’s windows live in a dock tree: any pane can be split — left, right, top or bottom, to any depth — dividers between panes drag to resize, and several windows can share a pane as tabs that switch instantly. The game view is a window in that tree like every other, and the one the tree is never without; everything else splits around it.
The tree, its layout solver, the drop rules and the layout spelling below are Halcyon’s dock brain, which knows nothing about drawing. The editor paints the strips, dividers and indicators and hands in its client.editor.dock* numbers, and Studio is planned to dock with the same brain and its own controls.
Every window wears the same header: its tabs on the left and a blank strip beside them. A docked pane has no ×. A docked tab closes by middle-clicking it, or by right-clicking it and picking Close; both act on the tab you aimed at rather than the one in front, and the menu carries the window’s own commands (an inspector’s Lock and New Inspector) above Close. The game view’s Close row is disabled, since the layout is never without one. The View menu lists every window with a check and reopens any that was closed, and the layout is written on every change and read back at the next launch. Only a pane torn out into a floating window wears an ×, which fills red and lights its glyph under the pointer. Closing is one standard shared with Studio: the rules live in DockChrome and Halcyon.Docking, not in either host. Dragging a tab is how everything moves:
- Within its strip, the tab reorders live — the row rearranges under the drag.
- Pulled out of the strip, it tears off into a ghost that rides the pointer, and an indicator shows exactly what letting go would do: a wash over the half of a pane a split would take, over a strip a merge would join, or over the full band an edge dock would span.
- Dropped on a pane’s side, it splits that pane alone — an assets pane under just the 3D view, while the side columns keep their full height.
- Dropped at the display’s rim (within
client.editor.dockEdgeBandpixels of it), it takes the editor’s whole edge instead — the same assets pane across the entire bottom, under hierarchy, game view and inspector alike. The band is the whole of the difference between the two scopes. - Dropped on another pane’s strip or middle, it merges in as a tab.
- Dropped over nothing, it becomes a floating window — always on top within the editor, tabbed like everything else, resizable by its edges. Its header has two zones, and they answer differently on purpose: dragging its tab is the full redock flow, ghost and indicators included; dragging the blank strip beside the tab just moves the window and never offers to dock.
A docked window arrives at the size it had. The share a drop takes is derived from the window’s own width when it splits a column, its own height when it splits a row, measured against whatever it is splitting and clamped to between 15% and 50% of it — so a narrow inspector docks as a narrow column rather than claiming half the editor. Only the initial share is derived; the divider is yours from that point on.
A pane’s minimum width is a floor in logical units, never physical pixels, so it holds at every UI scale. DockLayoutSolver measures each docked window’s own floor — the inspector’s minimum, the material drawer header’s measured width, the tab strip’s own content width — in the same authored units the rest of the interface scales in, then enforces it against a viewport that has already been divided by client.ui.scale. A pane dragged to the floor at scale 1 and the same pane at scale 2 both measure at least the floor’s logical width, which is why the tab strip’s own width — the word plus its padding, summed from the font’s per-character advance — and the material drawer header’s chevron/name/chip row use that identical conversion rather than a pixel constant apiece: one number, read once, wins at every scale.
Resizing the game window reflows everything. The tree stores shares, not rectangles, so a display that changes size lays the same arrangement out into the new one on the next frame.
A dragged divider carries the pane’s content with the edge it moves. Pull the assets pane’s top edge down and the listing comes down with it — the first row stays directly under the header the whole way — instead of the pane sliding over row after row. Pull its bottom edge up and the top rows stand still while the bottom ones hide. Left and right on a vertical divider read the same way. One rule states both: a pane’s content is anchored to its own leading edge, so the edge that is not moving is the one the rows keep station with, and nothing in the pane, the solver or the list asks which divider was grabbed. It holds in the list view and the grid alike, because it belongs to the list rather than to whatever the rows are drawn as.
The layout is remembered
Section titled “The layout is remembered”The whole arrangement — splits, shares, tab order, floating windows — survives restarts as one line, client.editor.layout, written at the end of every gesture and readable by a person:
h[0.2:hierarchy,0.55:v[0.72:gameView,0.28:scene],0.25:inspector];560,220,340,300:lightingThree columns; the middle column is split again with another pane under it; a floating window follows the semicolon with its rectangle. Tab groups spell as inspector+lighting!1 — the tabs in order, the ! naming the one in front. Anything malformed reads as the default layout (hierarchy docked left, inspector right), and so does anything that names gameView twice or not at all — counted across the whole line, docked half and floating half together, because a torn-out game view is still a game view. A line the world cannot live in is a line to throw away, which is the only safe way to be wrong. A layout whose windows have all been torn out spells its empty dock as -, so the floating arrangement reads back rather than being mistaken for a blank line.
Whether each View window is open stays its own flag (client.editor.hierarchyOpen and siblings) — the View menu’s rows are those flags — and a window opened into a layout that does not hold it joins at its default spot. The Options and Maps questions and the picture viewers dock and tab like everything else while they are up, but are deliberately not written into the stored layout: a viewer’s picture dies with the process, and a question re-centers on every open.
Hover names
Section titled “Hover names”View → Hover Names names the thing under your cursor on a small plate beside the pointer. It is off by default, which is the whole argument for it being a preference at all: a label that follows the cursor is genuinely useful while learning an unfamiliar map and genuinely in the way while flying a familiar one, and neither person is wrong. The outline already says which thing is hovered; this says what it is called, which is a second question and gets a second switch.
The plate is the fly-speed chip at body size — same fill, same border, same radius, same transition — because both are a fact about the editor’s own state floating over the world, and two plates that differed would read as two kinds of thing. It follows the world hover only: a pointer resting on the toolbar, a window or a menu is not hovering the world, and the plate leaves with the name rather than lingering on the last one.
The switch is the client.editor.hoverNames preference, so the console reaches it too, and the View menu’s row is lit in the accent whenever the preference reads on — set it from either place and the other agrees on the next frame.
The camera
Section titled “The camera”Entering detaches a client-local free camera from your current eye position and view angles. It never touches the protocol, never runs on the tick clock, and never goes near the authoritative character mover — nobody else can see it, and leaving simply drops it and hands the view back to your body.
The control model is the one every 3D tool already taught you:
- The cursor is an ordinary cursor over an ordinary toolbar.
- Hold right mouse and the camera flies. Release and the camera holds still exactly where it was.
- While the right button is held: WASD to move, E up, Q down, Shift to sprint.
- The wheel trims the fly speed while the button is held, and a large card low on the screen reports the multiplier for about a second.
- Hold middle mouse and the world pans one-to-one under your hand. Shift pans four times as far.
- The wheel with the right button up dollies toward or away from what you are looking at. Alt+wheel aims that zoom at the cursor instead.
- Middle-click without dragging recenters the view on whatever is under the cursor.
- F frames the current selection.
E and Q rather than Space and Ctrl, because that is what every 3D tool a level author already uses puts under those fingers. Space and Ctrl are a body’s jump and crouch, and while the editor is open there is no body to jump; they do nothing here.
The right button alone is never enough: if a settings window, a chat line or a window drag has the mouse, a right-click into it does not fly the camera out from under it.
An open console does not stop you working. It is a panel you work beside, not a screen you go to, so what matters is where the pointer is rather than whether the console exists: press the right button on the bare view and the camera flies exactly as it does with the console closed, left-click the bare view and it selects. Put the pointer on the console and the console wins, like any other window. While the right button is held the flight takes the keyboard, so W walks the camera instead of typing a w into the prompt — the prompt keeps its line and its caret and starts taking keys again the instant you let go.
The cursor stays
Section titled “The cursor stays”Flying does not take the pointer away. The cursor stays on screen, turns into a grab hand, keeps moving with your hand, and wraps when it reaches an edge — leave the right side and it reappears on the left, so a long drag never runs out of desk. Release the button and the cursor is exactly where you can see it, which is the whole reason it was never hidden. The drag also owns the pointer until you let go: cross onto a panel beside the view and nothing there lights up, takes the wheel, or can be clicked — the motion is still the camera’s, and the wrap is still the window’s edge, never the pane’s.
The wrap is a pointer warp and the warp is excluded from the look delta: the real motion is banked first, then the pointer is moved and the motion tracker re-anchored where it was put. Crossing an edge therefore turns the camera by the distance your hand moved and by nothing else.
The fly model
Section titled “The fly model”The flight is modeled on Unity’s scene view, not on this engine’s noclip, because a tool camera and a body want opposite things — a body wants momentum and a viewport does not.
- A tap of a movement key flies at exactly
client.editor.flySpeed. That is where the ramp starts. - Keep holding and the speed compounds: it is multiplied by
client.editor.acceleration + 1for every further second held, capped at twenty times the cruise speed. - Let the keys go and the speed eases to a full stop over
client.editor.easingseconds — a brief deceleration, not a coast you have to wait out, and not a dead stop either. - Let the right button go and the camera stops dead, ramp and all. The pointer is a pointer again, and a viewport that keeps sliding while you reach for the toolbar is a viewport that moved without being asked to.
- Direction has no momentum at all. The camera flies where the keys point, the instant they point there; only the speed ramps.
The wheel’s trim multiplies the cruise speed, so it moves the whole model with it — where the ramp starts and where its cap is. It is logarithmic (one notch is the same proportional change at .05x as at 5x), clamped to .01x–10.00x, and it is deliberately not a preference: it is a flick of the wrist for the next minute of flying, and it resets when you leave the editor.
Looking around
Section titled “Looking around”Rotation is a flat rate: degrees per pixel of mouse movement, applied exactly as it arrives. It does not scale with your field of view, it does not scale with the size of the window, it is not smoothed, and it does not depend on your frame rate — the same drag turns the camera the same amount everywhere. That is the model Unity’s scene view uses, and everything missing from it is missing on purpose: a turn rate that changed when you zoomed or resized would break your hand’s memory of it, and a viewport that keeps turning after the mouse stops is a viewport that overshoots.
It is also completely separate from your aim sensitivity. client.sensitivity and client.mYaw move your gun; client.editor.lookSensitivity moves the camera, and tuning one never touches the other. Framing a shot and aiming one are different jobs.
Panning, dollying and framing
Section titled “Panning, dollying and framing”Flying is one of two ways to move, and it is the one you use when you know where you want to be. The other is the pointer, and it works the way it does in every 3D tool: everything below is measured against a focus distance — how far in front of the lens the thing you are working on sits. The point that distance lands on is the pivot, and it is never stored anywhere. It is always exactly eye + forward × distance, so nothing can drift out of step with it.
- Middle-drag pans, with no easing at all. The arithmetic underneath is Unity’s exactly — one-to-one with your hand, a world point under the cursor holding its pixel for the whole drag — and then
client.editor.panSpeedscales the result, shipping at0.85because a true one-to-one pan overshoots at the distances a map is actually worked at. Set it to1for the Unity-exact drag. Hold Shift and it coversclient.editor.panShiftMultipliertimes the ground, stacked on top of whatever the sensitivity is. The cursor becomes a grab hand and wraps at the window’s edges, exactly as flying does. The focus distance is untouched, so a pan never changes what the next wheel notch is worth. - The wheel dollies straight along the view axis whenever the right button is not held: push it away to move forward. Each notch is a fixed fraction of the distance remaining, so the move compounds — the last meter of an approach is as controllable as the first — and it is instant, with no animation to wait out.
- Alt+wheel dollies at the cursor instead of at the center: the world point under the pointer holds its pixel while the camera closes on it, which walks the pivot sideways as well as changing its depth.
- Middle-click — a press and release that never traveled more than
client.editor.recenterDragThreshold— casts the same ray a left click picks with, and flies the view so the point it hit is in the middle. The depth is unchanged: the camera neither approaches the point nor backs away from it. Click on nothing and nothing happens. - F frames the selection: the union box of everything selected flies to the center, at the distance that fits it in your vertical field of view. Nodes contribute their real bounds; a logic entity contributes the box it draws, or — for the kinds with no drawn shape — the nominal
client.editor.focusEntityExtentcube a point on the wire gets. A selection with no size at all is framed at ten meters rather than flown into. Nothing selected does nothing. With the cursor over the Hierarchy window, F belongs to the hierarchy instead: the list scrolls the active row into view, unfolding whatever it was filed under, and the camera does not move — the key reveals the selection in whichever surface you are pointing at.
Camera gestures belong to the world, not the interface: a right-drag that begins over an editor window never starts a look, and the press is swallowed rather than passed through to the camera when it ends. The same boundary is what F reads.
A framing never turns your head. Rotation is left exactly as it was, by both F and the middle-click recenter — the view slides and the distance changes, and that is all. It is the one property of Unity’s F that people notice the moment it is missing.
The flight itself is a quartic ease-out over client.editor.focusSpeed of the journey per second: it leaves quickly and arrives gently, and the eye is derived from the eased pivot and distance every frame rather than being interpolated itself. Pressing F again mid-flight restarts from wherever the camera has got to, so a second press glides on instead of snapping back. Any input that moves the camera yourself — a pan, a wheel notch, a movement key, a turn — drops the flight where it is. Setting the speed to 0 makes framing an instant cut.
Whichever button you press first wins. Middle-dragging while the right button is down is ignored, and a right-click during a pan does not start flying; the other button is dead for the length of the gesture. The wheel keeps its fly-speed meaning while the right button is held, and Ctrl+wheel stays the interface scale’s everywhere. F is dead while the right button is down — Unity’s rule, and for the same reason: that hand is flying — but live while the pointer is free, and live while you are flying on the keyboard.
Your own body
Section titled “Your own body”Your frozen body stays in the world and is drawn while the camera is detached. To keep the camera out of your own head it fades with distance: fully invisible inside client.editor.bodyFadeNear, ramping to fully opaque at client.editor.bodyFadeFar. The fade is dithered — a screen door in the shader, matching the engine’s existing transparency idiom, so there is no alpha blending and no sorting to get wrong.
This is not the only place the local avatar is drawn: client.render.firstPersonBody draws it in ordinary first person too. The two paths are separate on purpose. The detached camera keeps the whole body — head included, standing where you are — and the distance fade, because it is looking at you from outside; first person stands the body behind the lens and folds its head away, because it is looking out of you. One predicate, PawnBody.DrawsLocalPawn, decides both, and the editor’s answer does not depend on the first-person setting.
The eye that plane is measured from is the aim eye, not the camera. First person hangs the view on a neck that swings it as you look down, and the cut deliberately does not follow it — the head is at a fixed height on the body whatever the lens is doing. The detached camera has no neck at all: its aim and its view are one transform, so nothing here changes for it.
Both radii ship close to the body on purpose: the point of flying in on your own avatar is to inspect it, so the ordinary working distance is fully opaque and only the last stretch — roughly arm’s length from the surface — is where the ramp lives at all. Retune either preference from the console or the Options window if that stretch should read differently.
The flying client draws its own body from its camera, not from the wire. Everyone else sees the pawn the server replicates; you see it exactly where your camera is, every frame, position and yaw. The claim still goes to the server and the server still owns the pawn — but the sender already knows the answer, so waiting for it to come back through the snapshot buffer is nothing but latency, and latency in a body pinned under the camera reads as a body that chases you and swings as it arrives. The override applies in follow only; Body > Left Behind and Body > Brought Along On Exit mean a body left standing somewhere, and pinning it under the camera would make the picture contradict the mode.
A consequence worth stating: in follow your body is at the camera, so it is inside the fade’s dead zone at every distance and never resolves. You do not see your own body in follow mode — the fade is what you actually see in the other two, where you fly away from a body and back into it.
What a click selects
Section titled “What a click selects”The cursor picks what the geometry actually is, not the box around it. A ray goes from your eye through the pointer and takes the nearest thing it genuinely touches — for map geometry, the nearest triangle of the node, not the nearest face of an axis-aligned box.
This matters far more than “a bit more precise” suggests, because a box does not merely blur the answer on a big map, it inverts it. A skybox is an inverted box you are standing inside, and a ray that starts inside a box enters it at a distance of zero — so on any map with a sky, the sky won every pick, and nothing at all could be selected. Against triangles the same ray meets the skybox’s far wall, hundreds of meters away, which loses to anything real in front of you. Aim at the actual sky and you still select the sky.
- Both sides of a surface count. The inside of a skybox, the far side of an open shell and a mirror-scaled prop are all pickable. A pick asks what your ray touched; which way a face is turned is a question about lighting.
- A node with no triangles keeps its box. Procedural geometry, box-meshed maps and nodes that carry no drawable primitive fall back rather than becoming unselectable.
- Brushes were always exact and are unchanged — a brush’s box is its shape, and it is tested turned with the brush.
- It costs nothing you can feel. On a map with hundreds of nodes and over a hundred thousand triangles, one pick is under ten microseconds against a frame of several thousand. Hovering and clicking run the same test, so what lights up is always what a click would take.
A plain click replaces the selection; ctrl or shift toggles what you clicked in or out of it. The two modifiers say the same thing here on purpose — that is the scene-view convention every 3D tool a level author has already used follows, and a person reaching for either one means “and this as well”. Clicking empty space clears the selection, unless you were holding a modifier, in which case it leaves it alone: a modifier-click that missed is a miss, not a request to throw away the six things you had gathered.
Set client.editor.pickBounds to true to go back to picking by bounding box, or throw Bounding Box Picking in Editor → Options…, which is the same setting.
Clicking again reaches what is behind
Section titled “Clicking again reaches what is behind”Click the same spot twice and the selection goes one layer deeper — the same gesture as Unity’s scene view. A third click goes deeper still, and past the last thing under your cursor it wraps around to the front again, so a stack of three is a three-way toggle rather than a gesture that quietly stops responding.
It is deliberately hard to confuse. A click only counts as “again” when all three of these are true: the pointer has barely moved (client.editor.cycleThreshold pixels, four by default), the ray met exactly the same things it met last time, and the selection is still the one your last click made. Anything else starts over at the front — so nudging the mouse, flying the camera, selecting a row in the Hierarchy, clicking empty space or pressing Escape all reset the depth without you having to think about it.
The yellow outline shows you where the next click will land, not where the last one did. Once you are walking a stack, the highlight walks with you a step ahead — click a wall, and the outline is already around the thing behind it, so you can see what you are about to reach before you reach it. Anything that resets the depth resets the preview with it.
Two things deliberately do not cycle. A modifier-click — ctrl or shift — adds or removes the front-most thing, because the toggle modifier is about what is in the set and not about how deep you are in it. And a press that turns into a drag is not a click at all — dragging the gizmo, or dragging across the world, neither selects nor advances.
The right-click menu
Section titled “The right-click menu”Right-click in the viewport and a menu opens where you clicked. A right drag is still the fly camera and always will be; the two are told apart the way every other gesture here is. A right press and release with less than client.editor.contextClickDeadZone pixels of travel between them is a click, and anything further was the look-around that was already under your hand. The dead zone is deliberately tighter than the drag thresholds around it — those decide whether a gesture did something, while this one only decides whether a menu appears, and a menu that turns up at the end of a look-around is far more annoying than one that took a second, steadier click.
It is a menu about the thing you clicked, picked with the same ray a left click uses — the same triangle test, through the same tree, at the same pixel.
- A click that hit something opens with Select, or Deselect when that object is already in the selection. It is the plain one-object toggle rather than a replace: right-clicking a fifth thing while four are selected adds it to the four.
- Delete is under it, and it is there either way: a click on empty space still has a selection to act on. It is the Edit menu’s own row rather than a second copy of a destructive verb, and it goes dead where the selection holds nothing deletable.
- A click on empty space has no Select row, so the menu opens on Delete instead. The rule below it stays — there is something above it to keep apart from Add.
- Add ▸ is last either way, and it is the toolbar’s own Add list, not a second copy of it. One list, in one place, reached from two.
Delete from a right-click acts on what you clicked. Right-click something that is not in the selection and the row takes it first, then deletes it — pointing at a thing and choosing a verb is a sentence about that thing, whatever was highlighted before. Right-click something that already is in the selection and nothing is re-picked: the whole selection goes. The taking happens when the row is picked rather than when the menu opened, so the menu’s first row still honestly reads Select for an object that is not selected yet.
What Add makes from here lands where you clicked, not out in front of the camera. If the ray hit something, the object goes at the hit point — on the floor you were pointing at, against the wall you were aiming for. If it hit nothing, it goes client.editor.contextAddDistance meters along that same ray, clamped inside the map’s world bounds when the map has any, so a click at the sky puts the thing at the edge of the world rather than a kilometer past it.
And the clicked point is deliberately ungridded. Add on the toolbar rounds onto client.editor.addGrid because “in front of the camera” is a direction and not a place, and the grid is what makes an arbitrary distance reproducible. A right-click already is a place — you aimed at it — and snapping it would slide the new object off the surface you pointed at, which was the whole reason you pointed at it.
The Add rows are drawn dead when this client cannot create, reading the same liveness the bar’s Add reads through the same wiring, because they are one verb reachable from two surfaces. Creation goes to the server over editor.create like every other scene mutation. The menu closes on a pick, on Escape, and on a press anywhere outside it.
The selection outline
Section titled “The selection outline”Selected map geometry is drawn with a two-ring silhouette rather than a box: a white inner ring hugging the shape’s own outline, and a black outer ring immediately outside it. The two rings are what make a selection readable on any backdrop — white alone disappears against a white wall, black alone disappears against a shadow, and the pair survives both.
- It hugs the geometry, not a box. The outline is a render pass over the selected node’s own triangles, so a curved or L-shaped node reads as a curve or an L. The axis-aligned box that stood in for it before is gone from a selection (see below).
- Occluded parts are dimmed, never cut. Where the selected surface is behind other geometry the ring is drawn at
client.editor.outline.occludedDimof its strength instead of disappearing. A selection crossed by a pillar stays one shape rather than becoming two unrelated fragments, and something you selected and then flew behind a wall is still findable. - The active member wears a thicker inner ring. In a multi-selection the member a tool would act on gets
client.editor.outline.activeInnerDeltaextra pixels of white — not a second color, because a different hue would say “a different kind of thing” when the truth is “the same thing, and this is the one that is next”. - The ring widths are in interface pixels, multiplied by your interface scale, so a selection is the same apparent weight at 100% and at 200%.
- Hover wears the same ring in
client.editor.hoverColor. The shape under the cursor is traced exactly as a selection is — same two rings, same widths — and only the inner ring’s hue tells them apart, because hover and selection are the same statement at two strengths. Selected entities are still ring markers — a replicated entity has no map geometry to trace. - A light, a spawn point or a pawn wears the same mark in a different shape. Nothing there has a silhouette the pass can hug, so the editor draws a ring or a proxy box for it — but in the same hue, at the same stroke width, over the same dark band, all read from the
client.editor.outline.*widths above. Retuning the ring a selected wall wears retunes the ring a selected light wears, and the active member of a multi-selection is drawn heavier there too. Only the shape of the two marks differs. - The selection outline never moves for the cursor. Hover and selection are measured from separate masks, so what you are pointing at can never subtract from what you have selected: hover something that stands completely in front of the selection and both rings are in the frame, and where the two cross, the selection is the one you see. Only the hovered object’s own ring answers the pointer.
- Brushes and players wear boxes rather than silhouettes. The outline pass traces a map node’s triangles, and neither of those is one: an authored
boxBrushis meshed at load time, and a player is an avatar. A brush’s box is turned with the brush, so a slab rotated 45° is outlined as the slab and not as the larger cage around it; a player’s is the character hull they are picked by, drawn beside the ring at their feet.
| Preference | Default | What it does |
|---|---|---|
client.editor.outline.enabled | true | Whether the outline is drawn at all. Off, the selection still exists and still drives every tool. |
client.editor.outline.innerPx | 2 | Width of the white inner ring, in interface pixels. |
client.editor.outline.outerPx | 1.5 | Width of the black outer ring, measured outward from where the white one ends. |
client.editor.outline.occludedDim | 0.3 | How much of the ring survives where the surface is hidden, over 0–1. 1 ignores occlusion; 0 clips the hidden part away. |
client.editor.outline.activeInnerDelta | 1 | Extra white-ring width the active member of a multi-selection gets. |
client.editor.outline.reuseDepth | true | Fills the outline’s depth buffer by copying the frame’s scene depth instead of drawing the whole map a second time. Off, the pass renders its own depth prepass — which is what shipped first, and is what the engine falls back to on its own under temporal antialiasing, or under MSAA when nothing else resolved a single-sample depth. |
client.editor.outline.reuseDepthBias | 256 | Constant depth bias, in depth units of last place, applied to the outline’s visibility test while it runs against borrowed depth. Borrowed depth is not what this pass would have rasterized — a different vertex stage wrote it, and an MSAA resolve takes it at a sample position rather than the pixel center — so without a bias a marked surface fails its own depth test and the whole outline reads as occluded. Ignored on the prepass path. |
client.editor.outline.reuseDepthSlopeBias | 0.5 | Slope-scaled half of the same bias, in pixels of depth gradient. This is the half that matters on a sloped surface, where the resolve’s sample offset costs up to half a pixel of gradient — so half a pixel is the exact bound, and larger values start admitting grazing seams. |
The same ring outside the editor
Section titled “The same ring outside the editor”The interaction highlight — the ring around the thing your Use key would act on right now — is not a second outline. It is this pass, submitting the interactable’s silhouette into the same white selection lane a selected map node uses: same two rings, same widths, same occlusion dim, no new hue and no new shader. If the ring around a selected wall looks right, the ring around a reachable button already looks right, because retuning one retunes both.
It is on by default and switched by Options → Interface → Gameplay → Highlight interactive objects (client.highlightInteractive). Two things follow from sharing the pass:
- It does not need the editor. The outline pass runs whether or not you are editing, so the highlight is a gameplay feature on every host, desktop and iOS alike.
client.editor.outline.enabledstill turns the pass off, and turning the pass off takes the gameplay highlight with it. That is a real coupling rather than an oversight: there is one pass, and a switch that disables it disables everything it draws.
The editor’s own selection always wins where both could exist, because the gameplay target is only computed while gameplay input is live — open the editor and the highlight stops being produced at all, rather than competing with the selection for the lane.
Only entities the server marked interactable are candidates, so the ring is a promise: if it is drawn, the press will be honored. See Use and interact for the key, the pad button and the touch button that act on it.
dh render photographs it. On a map that authors an interactable in front of one of its cameras, the run writes <map>_<camera>_interactHighlight.png beside the editor’s own outline frames — submitted through the same seam a live host submits it through, so the picture is evidence about the shipping highlight rather than about a capture-only imitation of it. A button with no box of its own, such as an imported Source func_button, rings the map parts it is drawn as, or its collider’s box when it has none (a brush that draws nothing, or a door that is also pressable).
The manipulators
Section titled “The manipulators”There are three, one per interaction mode, and they stand on one chassis. Constant apparent size, the camera-facing quadrant, the offered-axis set, the dim a drag spends on the handles you did not grab — all of it is decided once and shared, so client.editor.gizmo.sizePx sizes every widget and there is no way for two of them to disagree about how big a gizmo is or where its pivot sits.
They also share the rules a grab obeys:
- A grab never jumps. Grabbing a handle and not moving the mouse changes nothing: the gesture records where you took hold and applies the difference from there.
- A grab beats a click. A press that lands on a handle starts a drag instead of picking, and releasing after a drag does not change what is selected. The world hover steps aside while a handle is hot, so the wall behind the widget does not flicker under it.
- The handle under the cursor lights up, and while you are dragging the grabbed handle stays lit while the others dim to
client.editor.gizmo.dragDim. - A mode key pressed mid-drag cannot change what the stroke means. The gesture is anchored on the frame it began, and the widget it began on is the widget it finishes as.
- The manipulator replaces the pivot gizmo while the editor is up — both stood on the same point, and two widgets sharing an origin read as one widget with a rendering fault. The manipulator says everything the arms said and is grabbable besides.
- One edit per member, written down separately. Every member is placed at its own starting pose transformed by the gesture rather than integrated frame by frame, and each is committed when you let go.
Axes that point at you are taken away
Section titled “Axes that point at you are taken away”An axis lying within client.editor.gizmo.cullDegrees of the line from the pivot to your eye — 10° by default, the number s&box uses — is dropped from the widget. A culled axis is absent from the picture and from the pick set, one answer rather than two that could disagree: what a nearly head-on arrow offers is not a bad handle but a few pixels of geometry sitting on top of the two axes that are still usable, stealing the presses meant for them. Set it to 0 to turn the cull off.
It applies to the arrows — translate and scale — and to nothing else. A plane panel seen edge on is already a line the hit test refuses, and a ring whose axis points at you is the ring seen as a full circle, which is the best handle the rotate widget has.
Global and local
Section titled “Global and local”One toggle in the Scene panel, directly after the four mode buttons, decides which frame the handles are stated in, Global or Local, and X cycles it — a bare letter beside the mode keys, live only while the right mouse button is up, like them. It is remembered in client.editor.handleSpace, so the editor opens in the space you were last working in.
- Global draws the move arrows and the rotate rings on the world’s axes. Dragging the X ring turns the object about world X whatever it is already doing, which is what “rotate it by an axis and it spins the way I expect” means for something lying at an angle.
- Local turns both widgets by the object’s own rotation. The arrows point along the object’s forward, up and right, and a ring turns the object about the axis it is drawn on — its own — so one drag moves one of the object’s three numbers and leaves the other two alone. That is the space to be in when you want to pitch a camera without rolling it.
At rest — an object nothing has turned — the two spaces are the same three arrows and the same three rings, so the toggle only starts saying anything once something is turned.
Scale is always local, whichever way the toggle is set. A scale is three numbers multiplied into the object’s own frame; “scale a turned object along world X” describes a shear, which is not a thing a transform holding a position, a rotation and three scale numbers can say. Rather than draw a handle that would lie about what it does, the scale widget stands in the object’s frame and the toggle leaves it alone.
A multi-selection uses the active member’s rotation — the one the widget is visibly standing on — which is the same member a group already turns and grows around. A frame averaged over a selection would be a frame no member is actually in.
The translate gizmo
Section titled “The translate gizmo”Select a map node or an authored boxBrush and a translate manipulator stands on it: three colored arrows — X red, Y green, Z blue, cone-tipped — and three flat panels near the origin, one per world plane, tinted by the two axes each spans. Drag an arrow to move along that axis; drag a panel to move in that plane. It is Unity’s translate gizmo, and it behaves like it.
-
It is a constant size on screen. The widget is measured in interface pixels and converted to meters every frame, so a node across the map is grabbable at the same size as one at your feet. The size follows your interface scale.
-
The panels turn to face you; the arrows never do. Each panel flips into the quadrant your camera is actually in, so all three stay usable from any angle. An arrow always points along the positive axis — an arrow that flipped would be telling you about the camera rather than about the world.
-
It moves everything you selected. The arrows stand on the active member, and the drag applies that one displacement to every selected object, so a group keeps its shape.
-
A grazing view refuses rather than flings. Swing the camera until your ray runs nearly along the arrow you are holding and the drag simply holds still for those frames instead of throwing the node across the map.
-
The first drag of a piece of the map costs one node, not the map. Map geometry is drawn as one merged batch per material, so moving a single node means taking its triangles out of that batch and drawing them under a matrix of their own. That extraction happens once per node, ever — every later frame of the drag writes one matrix and touches the device not at all — and hiding the node from the batch it came out of is a write over its own run of the index buffer rather than a rebuild of every index in the map. On a city-scale map the difference is the whole gesture: the drag starts on the frame you grabbed the arrow instead of after a visible hitch.
client.editor.liftReportMslogs a stage breakdown for a lift that is slower than it should be. -
A node you put back rejoins the batch. Leave a piece standing at exactly the pose the map authored — drag it home, or take the move back with Ctrl+Z — and after
client.editor.liftReleaseSecondsit is folded back into the merged batch and stops costing a draw of its own. A session spent nudging things is not a session that slowly collects hundreds of extra draw calls.
The panels are drawn as filled convex polygons rather than as a fan of parallel strokes, which is what a flat translucent quad at an arbitrary angle needs to hold one even alpha — see ConvexPoly.
The rotate gizmo
Section titled “The rotate gizmo”Switch to Rotate and the same selection wears four rings: one per world axis in the same red/green/blue, and a neutral view ring a little outside them, which turns about the line from the pivot to your eye. That last one is a world ring square to the line it is actually seen along, not a screen-space circle — it is taken from the pivot rather than from the camera’s forward so it stays honest at the edge of a wide frame.
- At rest each ring shows only its near half, the 180° facing you, and the hit test accepts exactly that same half. Four whole circles read as a ball of wire; four arcs read as four handles lying over the object. A ring seen nearly face-on is drawn whole, because at that angle there is no near half to speak of — and the ring you are dragging is drawn whole for as long as the drag lasts, so you can see where it has got to.
- The turn is measured along the ring, in pixels. The gesture freezes the ring, the point you grabbed it at and the direction that point was traveling on screen, and then reads your pointer’s travel along that direction. Nothing wraps, clamps or takes an arctangent — which is what lets the gesture pass 180° and keep going instead of snapping to the short way round.
client.editor.rotateGainis the feel: 30° per gizmo-radius of pointer travel by default, which is Unity’s. It lives outside theclient.editor.gizmo.*family on purpose — every number there is a dimension of the drawn widget, and this one is the response of a gesture.- The cursor wraps at the edge of the screen and the count does not. Travel is accumulated from raw mouse motion rather than from where the pointer is, so a long turn is not rationed by how much desk the window has left.
- Turns are counted and shown. A translucent wedge fills behind the ring as you go, and a whole further disc stacks on for each completed revolution —
client.editor.gizmo.rotatePieAlpha, 0.2 per turn, so three turns read visibly deeper than one. The readout is drawn under the rings. - There is no angle snap. Type the number in the Inspector when you want an exact one.
The scale gizmo
Section titled “The scale gizmo”Scale raises the same axis-handle widget the translate mode does, wearing its other head: three arrows ending in boxes instead of cones, a small uniform box at the pivot, and no plane panels. A box’s information is that it has no point at all — it is a multiplier, not a direction.
- Drag an arrow and only that axis scales; the two the gesture did not name stay at exactly one.
- Drag the center box and all three scale together. The travel is read in a screen-facing plane frozen at the grab, signed up-and-right to grow and down-and-left to shrink, so orbiting the camera mid-gesture cannot resize anything on its own. The center is hit-tested as a sphere about the pivot, because a box that always faces you has no world extent to test against.
client.editor.scaleGainis the feel:1means dragging a box head out by the arrow’s own length doubles that axis.- A scale cannot pass through zero. The factor floors at
0.01. A negative scale is a mirror, which is a thing somebody should ask for rather than discover by dragging one handle slightly too far — and deliberately not a preference. - The two-axis squares that scale a pair together are a follow-up, not an omission.
What a group turns and grows around
Section titled “What a group turns and grows around”A rotate or a scale of a multi-selection happens about the active member’s pivot — the point the widget is visibly standing on — frozen at the grab. Never the selection’s bounding-box center, and never each member’s own pivot, both of which would take a group apart. This is Unity’s behavior, and the pivot-mode toggle that would offer the alternative is a deliberate non-feature today.
A selected parent brings its whole subtree along, expanded into ordinary per-node edits — see A parent carries its subtree.
One caveat worth knowing: a rotating object’s bounding box is stale until you let go. The box is what picking and culling use, so mid-turn a click can land slightly off the shape you can see. It resolves itself on release, and nothing that is drawn is affected.
A node stands on the pivot the compiler baked for it; a brush stands on its authored center, the same point the Inspector prints. Everything that reads a position follows the move on the same frame: the outline, the hover box, the Inspector, framing with F, and picking — a moved wall is clicked where it now is, and a moved brush stays turned as it goes, outline and all.
The drag previews; the release commits. While the pointer is down the transform is yours alone, so the widget answers at frame rate rather than at tick rate. When you let go, each member’s whole transform goes to the server as a scene.transform line — the whole one, not a fresh offset, or a rotate would send its own aim back to zero the instant the button came up. The server checks authority and the per-operation table, stores it, and replicates the table to everyone; a client that joins afterward is handed it with the rest of the edit set and builds the same world.
Collision follows. A moved or turned object’s body is re-posed — the ray follows it there, so a wall you slide is clicked and walked into where it now is. Only a new size recooks the shape, which is the expensive half and is why it is the only one that pays for it.
| Preference | Default | What it does |
|---|---|---|
client.editor.gizmo.enabled | true | Whether the manipulator is drawn and grabbable at all. Off, selection and everything downstream of it still work. |
client.editor.gizmo.sizePx | 90 | Arrow length in interface pixels — the widget’s whole size, shared by all three manipulators. Clamped to 16–400. |
client.editor.gizmo.thicknessPx | 2.5 | Stroke width of the arrows, panel edges and rings, in interface pixels. Clamped to 0.5–12. |
client.editor.gizmo.grabPx | 9 | How far off a handle the cursor may sit and still take hold, in interface pixels. Clamped to 1–40. |
client.editor.gizmo.cullDegrees | 10 | How near the line to your eye an arrow may lie and still be offered, in degrees. Clamped to 0–45; 0 offers every axis. Does not apply to panels or rings. |
client.editor.gizmo.planeAlpha | 0.22 | Opacity of a plane panel’s fill, over 0–1. The edge is drawn solid regardless. |
client.editor.gizmo.rotatePieAlpha | 0.2 | Opacity of one turn of the rotate readout’s fill, over 0–1. Discs stack, so the fill deepens with each revolution. |
client.editor.gizmo.highlightColor | 255, 235, 130, 255 | The color a hovered or grabbed handle brightens toward, as RGBA 0–255. |
client.editor.gizmo.dragDim | 0.6 | How much of their color the handles you did not grab keep during a drag, over 0–1. |
client.editor.rotateGain | 30 | Degrees a rotate drag turns per gizmo-radius of pointer travel. Clamped to 1–360. |
client.editor.scaleGain | 1 | Multiplier one gizmo-length of scale-drag travel is worth. Clamped to 0.05–20. |
client.editor.handleSpace | global | The frame the move and rotate handles are drawn and applied in: global (world axes) or local (the object’s own). Anything else reads as global. Scale ignores it. |
The two gain preferences sit outside the client.editor.gizmo.* family deliberately: everything under that prefix is a dimension of the drawn widget, and a gain is the response of a gesture. The axis hues are not preferences at all — they are the same red/green/blue the axis gizmo and the position readout already use, and three widgets disagreeing about which color X is would be worse than any of them being tunable.
Moving a spawned asset
Section titled “Moving a spawned asset”A spawned asset moves, turns and scales like anything else in the map. Select one — an avatar, a
dropped .dh-obj — and all three handles are there, and the Inspector’s Position, Rotation and
Scale rows are live. Nothing authored it, so there is no compiled pose an edit could contradict:
it is the one replicated kind that admits all three operations, where a light moves and aims but has no
size to pull on.
The gesture is the one every other transform uses, and the release goes out as a single
spawn.transform line naming the entity by its
wire id. That id is the whole reason the verb exists beside scene.transform*: those name a map
object by its compiled index, and a spawn has none. While the pointer is down the placement is this
client’s prediction so the picture keeps up with the pointer, and the server’s broadcast overwrites the
prediction wholesale on release — an edit the server refuses corrects itself rather than sticking, the
same contract posing a bone has.
Placement replicates through the snapshot, so everybody in the session sees it, and it is
session-only: nothing writes a spawn’s placement into the map source. A scaled spawn keeps its size
across a spawn.reload and wears it on the placeholder cube too, so an entity somebody resized does
not snap back to a default box while its model is still uploading.
The bones of a spawned rig
Section titled “The bones of a spawned rig”Select a spawned asset that carries a skeleton — an avatar, a rigged prop — and every bone of it is drawn and selectable, with a name beside the few that are worth reading. A bone wears the same marker a light entity wears, with the same name plate beside it, through the same pass: one gizmo overlay draws every marker in the frame, so retuning what a marker looks like retunes them all. Only the hue differs, and it is client.editor.boneMarkerColor so a bone and the avatar wearing it are told apart without reading a single label.
Bones announce themselves only once their entity is in hand. An unselected avatar draws no bones at any setting, and that is not a performance dodge: a humanoid rig is well over a hundred labels, and drawing them for every spawned asset in the room would bury the room. Selection is also what makes them clickable — a bone is offered to the picker exactly where a marker stands.
- Bones are not hover or default-pick candidates. A hundred markers inside one avatar would make the avatar itself unclickable, so a plain click on an unselected rig takes the rig. Once its markers are up, a bone marker wins the click it is under, the way a manipulator handle wins the pixels it covers — the alternative is a bone reachable from the Hierarchy and never from the world.
- A bone is picked in pixels, not in meters. A bone is a candidate only when the cursor is within
client.editor.bonePickRadiusPxof its dot on screen, and among the candidates the one nearest the cursor in pixels wins, with depth breaking an exact tie and nothing else ordering them. That is the whole rule: a dot the cursor is not on is never picked, and the dot the cursor is on cannot be stolen by a neighbor or by the mesh behind it, because the bone pick runs before the entity pick. - The hovered dot says so. It takes
client.editor.boneHoverColorand grows byclient.editor.boneHoverScale, label included, through the same marker the selection draws — there is no second gizmo path. - A picked dot wears the theme, and every picked dot does. A selected bone takes
client.editor.boneSelectColor, which unset follows the editor’s ownclient.editor.selectionColor, which unset follows the interface accent — the same hue behind a selected hierarchy row, one token rather than a color of its own that can drift from the theme. Retune the theme and every picked dot moves with it; selecting four bones lights four dots, exactly as it lights four rows. Where a bone is both picked and under the cursor, the selection wins the color and the cursor keeps the grow: the ink says what the bone is, the size says what a click would take. - The dots ride the live pose. Marker positions are read from the same posed skeleton the skinning palette is built from, after animation and after the spring solver, so a tail the springs are swinging carries its dots with it and is picked where it actually is.
- The object root is never a bone. Bone 0 stands exactly where the entity does and is what the entity row already names; a marker there would say the same thing twice.
- Selecting several bones of one rig is still one rig’s worth of markers. The drawing pass and the pick read the same set of skeletons, so a marker is never clickable where none is drawn.
Which bones are named
Section titled “Which bones are named”Every bone has a dot; only a few have a name. A humanoid head is a dozen bones inside a few centimeters, and their names drawn all at once are a block of overlapping text that hides both the head and each other. So the markers are all there — visible, clickable, unchanged — and the labels are thinned to the ones a person is actually reading:
- The selected bone and its parent chain to the root. The neighborhood a pose edit is about. A sibling limb is a different part of the body and its name is only in the way, and so — now — are the selected bone’s own children: a hand names the arm it hangs off, and the fingers are read one at a time with the cursor.
- The hovered bone. This is how any single dot in the crowd is read: point at it. It is the same dot the click would take, on the same
client.editor.bonePickRadiusPx— a label that appears where a click would land somewhere else is a label that lies about the picker. - Or nothing, on request.
client.editor.selectedLabelsturns the selected chain’s names off and leaves the hovered one alone, for judging a pose with the dots still saying what is in hand. - With nothing but the entity in hand, no labels at all. A fresh selection shows dots and only dots.
client.editor.boneLabelsAll brings back the whole wall for someone who wants to read every name at once. It changes no marker either way.
Bones in the Hierarchy
Section titled “Bones in the Hierarchy”A rigged replica’s row opens onto its skeleton: one row per bone, nested by the bone’s own parent, in the skeleton’s pre-order. Those rows select exactly what the markers do, so the list and the world are two ways into one selection rather than two selections that can disagree. A bone is named by the asset, not by the map — there is no rename path, and the row’s label is the same string the gizmo prints beside the marker.
Posing a bone
Section titled “Posing a bone”A selected bone is driven by the tools that already move everything else. Move, rotate and scale raise the same manipulators on a bone that they raise on a wall, standing on the bone and acting in its parent’s frame — which is what a bone delta means. The Inspector shows the bone’s Transform rows in the same widgets, and the per-row deviation gutter marks a row that has moved.
Rest is the deviation baseline. A bone has no authored override anywhere to compare against, so the rig’s own pose is the zero: a row is deviated when the bone is off the pose the asset was authored in, and clearing it puts the bone back there.
The drag previews; the release commits. While the pointer is down the pose is this client’s prediction, so the picture follows the pointer at pointer speed. On release the whole nine-number delta goes to the server as a spawn.pose line — absolute rather than incremental, exactly as scene.transform is — and the server’s broadcast overwrites the prediction wholesale, so an edit the server refuses corrects itself instead of sticking.
A pose is server-owned session state, replicated to everyone. It is not written into the map and it does not outlive the session. What it does outlive is a spawn.reload: bones are named by path, so a recompiled avatar that still has the bone keeps its pose, and one that renamed it drops that bone alone with a diagnostic.
| Preference | Default | What it does |
|---|---|---|
client.editor.boneGizmos | true | Whether a selected spawned asset draws a marker per bone. This is what the card’s Show bones box holds, so the box and the console line are one setting rather than two. Off, the bones are still in the Hierarchy and still selectable from it — but nothing in the world is clickable, because the pick follows the marker. |
client.editor.boneMarkerColor | 255, 190, 120, 255 | The bone marker and label color, as RGBA 0–255. |
client.editor.boneHoverColor | 224, 120, 32, 255 | The color the hovered bone’s dot and label take, as RGBA 0–255. A darker orange than the resting marker, so a hover reads as the same family of thing gone warm rather than as a different kind of dot. |
client.editor.boneSelectColor | unset | The color a selected bone’s dot and label take, as RGBA 0–255. Unset follows client.editor.selectionColor, so a picked bone and a picked hierarchy row read as the same hue unless retuned apart. |
client.editor.selectedLabels | true | Whether the selected bones name themselves. Off, the spine of names over a picked bone goes away while the dots still say what is picked; the hovered bone is named either way. |
client.editor.boneHoverScale | 1.5 | How much larger the hovered bone’s dot is drawn. |
client.editor.bonePickRadiusPx | 8 | How far from a bone’s dot, in pixels on screen, the cursor may sit and still pick or name it. |
client.editor.boneLabelsAll | false | Name every bone instead of only the selected chain and the hovered one. Markers are unaffected at either setting. |
Staging an object
Section titled “Staging an object”prefab.open <barcode> stands an object or avatar up for editing rather than for play. What appears is the same spawned asset spawn makes — same replication, same visual, same inspector — marked with one extra bit that says it is somebody’s open document and not part of the level.
It lands client.editor.stageOffset meters in front of whoever asked, or on the map’s spawn point when the line came from a console nobody is standing at. Opening a prefab on an empty map is the ordinary case rather than a mistake, so it never refuses the way spawn does.
A staged object is left out of Select All and its siblings: a wholesale selection that swept it up would put your editing subject into every box-move and every Delete aimed at the level. Clicking it still selects it — this narrows what “everything” means, never what can be picked.
prefab.close <entity> takes it away again, and deliberately refuses an entity that is not staged. spawn.remove is the verb for a placed object; a close that could delete scenery would be a tab closing somebody’s level.
The staged fact rides a payload bit rather than a channel, so a client that predates it reads the payload as an ordinary spawn and draws the object. That is the right degradation: the object really is there.
The prefab tab
Section titled “The prefab tab”An object opened with prefab.open is a document, and the editor gives it a tab: a dock tab whose id is prefab:<barcode>, standing on the viewport pane beside the viewport itself. There is no second strip of tabs inside the viewport — the dock already draws tabs, and a second row of them for the same gesture would be a second thing to learn.
Opening the same object twice focuses the tab that is already open rather than adding another: one object is one document.
While a prefab tab is in front, the hierarchy is context-locked to the staged object — it lists that object’s rows rather than the map’s. Nothing leaves the world; what changes is what the hierarchy is a hierarchy of, which is why switching back to the viewport brings the map straight back instead of the editor having to leave a mode.
The tab wears an asterisk while its layer states anything, and loses it the moment the layer is emptied. It is the same mark the bar puts beside a dirty map’s name and the badge carries out into play, rather than a second spelling of one idea. The mark rides the tab’s title because a tab is a title and a key and nothing else, and Halcyon has no absolute positioning to hang a marker beside one.
Closing a marked tab asks first, through the same confirm dialog every other destructive question uses: Close and discard, or Keep editing, and a click on the backdrop keeps editing. Quitting the host with any dirty tab open asks once. Switching tabs never asks — nothing has been lost and the document is still open, and a prompt at every switch would train you to dismiss the one prompt that matters.
Switching is a frame, and you never leave the editor. The stage is a world of its own, so clicking its tab moves your body into it — but both worlds stay open, both scenes stay built, and the switch only changes which one the viewport draws and which one you are standing in. No loading box, no state change, no cursor coming back: the dock layout and each tab’s own selection are where you left them, and clicking back to the scene tab is the same swap in the other direction. The residency this costs is two scenes at most, held by client.cache.residentScenes (default 2, floor 1) — because the stage’s room is built from the same slab of code on both ends and is the same room whichever object is on it — ten open documents share one scene, and the objects in them are held in the shared object cache under client.cache.objectBudgetMb like every other spawned thing. A shell tight on memory can set the preference to its floor of one, which turns a switch back into a build that disposes the scene it leaves. A scene is only freed when its last tab closes or the session ends.
A prefab tab paints the world, exactly as the game view does, under its breadcrumb. That rectangle is more than the image: the pick ray, the move gizmo and every projected readout are measured against it, so a front tab with no world claimed would be a tab with nothing to click in — which is the one state the view position cannot be edited in.
Prefab tabs are left out of the saved layout. A restart that resurrected the tab would show a document whose staged entity died with the session.
The layer as cards
Section titled “The layer as cards”A staged object’s open document is one layer: the text of a component-shaped object file that states only what departs from the source — root components, part overrides in children, records in objects — edited through the same writer a save uses, and sent to every client as that text. Each thing it states lands on a card the inspector already draws:
| Entry | Where it shows |
|---|---|
dh.skeleton | The Rig card — rig path, mapped bone count, missing bones as a warning row, view position as a vector row |
dh.armatureLink | The Armature Link card |
dh.springBone | The Spring Bones card |
dh.renderer | Folded into the Mesh Renderer slot row it binds |
a part’s or record’s parent, position, rotation, scale | Folded into the Transform card’s deviation gutter |
a record’s source | The linked child subtree it creates — a subtree of rows, not a card |
Every other component the staged root resolves to under the layer — its eyes, its reactions, its spring colliders — is a generated card, ghosted against what the source states, read by the same card builder the Halcyon Studio’s object editor uses. The skeleton has no generated card: the Rig card is its card.
The staged root also gains one folded Overrides card listing everything the layer states in the order it states it, each row naming its component (or member) and the part or record it is on. Clicking a row folds to its card.
Setting the view position
Section titled “Setting the view position”prefab.viewPosition <entity> <x> <y> <z> moves a staged rig’s eye. It is server-side and refuses an entity that is placed rather than staged, the same way prefab.close does.
The write lands in the staged object’s layer as the root dh.skeleton’s viewPosition, and states that member alone: the rig it corrects is the source’s, which the layer inherits. A second move rewrites the same member rather than adding one, so however many times you drag, the document holds exactly one entry. The numbers are spelled in invariant culture, like every other console line — a machine whose decimal separator is a comma would otherwise turn three numbers into six arguments.
The frame is the Unity convention the .dh-rig would author, so the number shown is the number that would be written. See viewPosition.
Closing the document takes its layer with it.
The card draws the eye as an editable vector row, so typing three numbers and dragging the marker are the same edit written two ways.
Set View Position is the card’s foot toggle, and it is the reflection probe’s Edit Capture Point verb in the same shape: press it and it reads Done while it is on. While it is on, a marker stands at the rig’s view position wearing the ordinary move gizmo — rotate and scale show nothing, because an eye has no facing and no size. Dragging it submits the console line above, so the drag is server-side, replicated to everyone watching, and one entry on the undo stack.
Save sits beside it and is disabled, with a tooltip that says why: Save arrives with the destination chooser. A staged document can be moved but not yet written to disk; a verb that silently did nothing would be worse than one that says what it is waiting for.
The pair is photographed as editorPrefabTab and editorPrefabViewPosition in the UI capture timeline — the same card with the switch out and in, so the frames are evidence about the switch rather than about the card.
Cameras
Section titled “Cameras”A map’s objects carrying a dh.camera are first-class editor objects. They are listed under their own Cameras heading, they answer a click in the viewport, they take the translate and rotate gizmos, and they carry a Camera card that says what the lens is doing. What they do not take is scale: a camera has no extents, and the unit marker its transform describes is a target to click rather than a size — which is why the Transform card offers it position and rotation and nothing else.
A camera shows which way it looks. Selected or not, an authored camera draws a cone in the Tooling band: four rays from the eye and the rectangle they pierce, opening vertically by the camera’s own fov and horizontally by the camera’s own resolved aspect — the thumbnail’s proportions under that role, else the authored w:h pair, else the shape of the window — out to client.editor.cameraConeMeters. There is no preference for the cone’s width: one that could disagree with the map would be a second answer to a question the map already answers, and the cone has to bracket what a render will actually write. A viewpoint drawn as a dot with a name beside it tells you a camera is there and nothing about the shot; the cone is what makes “is this the one pointed at the plaza” a question the picture answers.
Match Viewport is how a shot is actually framed. Fly the editor camera until the view is the one you want, then press it on the card: the authored camera stands where you are standing, aimed the same way. It is the ordinary entity transform underneath — replicated, saved on the next Save, undone by one Ctrl+Z — so framing a shot by eye and dragging one by its arrows are the same edit written two ways.
And Preview is what the camera actually sees. A collapsible section at the foot of the Camera card, open by default and remembered: folding it writes client.editor.cameraPreviewOpen (default true), so the choice outlives the selection and the session. Open it with exactly one camera selected and the tile is that camera’s frame, re-rendered every frame: it is the same scene renderer the game view is, so the sky, the lighting, the water and the skinning in it are the ones a render will write. What is not in it is everything the editor adds — no gizmos, no selection outline, no crosshair, no interface — because the picture is meant to answer “is this the shot”, and a shot has no arrows in it. Its width is the card’s content width and its height follows the camera’s own resolved aspect, the same reading the cone opens by, so the tile and the cone cannot disagree about the frame.
It renders only while it is open and the selection is exactly one camera. Collapse it, or select a second thing, and the offscreen image is released rather than merely hidden — a fold you are not looking at costs nothing. While it is open the frame carries a second world view, which on a mid-range card at 1600x900 measures about 0.7 ms with the default 320-pixel render width; it is not proportional to the tile’s pixel count, because the shared full-size targets are still cleared and the sky and shadow cascades still run. client.editor.cameraPreviewWidth (default 320, clamped 32–2048) is what that width is, and --gpuTimings reports the view as its own camPreview pass.
Teleport Self Here versus leaving
Section titled “Teleport Self Here versus leaving”Leaving and moving your body are deliberately separate verbs rather than one convenience button:
- ✕ (also F2 /
editor) — the camera is discarded and you resume where your body stands. The only way out. The ✕ asks about unsaved work on the way; F2 does not, because it keeps the map and the pending set and the same key brings both back. - Player → Teleport Self Here (also
editor.teleportSelf) — your frozen body relocates to the camera and you stay in the editor. The camera does not move.
“Resume where I flew to” is therefore Teleport Self Here followed by ✕: two clicks, no hidden side effects, and each half is worth having on its own. editor.teleportSelf submits the ordinary teleport command targeting yourself — gated cheats-or-admin, like the editor’s own verbs, so an operator editing a server with cheats off can still use it — and it is validated by the server on exactly the same terms as any other teleport. It does nothing (with a reason) when the camera is not detached.
Player → Body ▸ Follows Camera / Brought Along On Exit / Left Behind is the standing choice about what your body does while you fly, remembered as client.editor.exitMode. The three rows are one checked group in their own submenu beside Body, under Teleport Self Here — a setting rather than an act, and the submenu is what says so: a standing choice sitting flush against a one-shot command reads as three more commands.
- Follows Camera (
follow, the default) — your frozen body rides along under the camera, tick by tick, and turns with it. Leaving the editor then moves you nowhere at all, because you were already there. Other players watch the flight happen rather than watching you blink across the map on exit, and the pawn carries a replicated flying flag for an animation to read later. You yourself draw it from the camera rather than from the wire, so it never trails or spins behind you — and is therefore always inside the fade’s dead zone, invisible to you while you fly. - Brought Along On Exit (
teleportOnExit) — the body stands where you left it and the exit runs exactly the teleport the menu row runs, at the moment the server confirms the exit and before the camera returns to the body, then turns your body to the direction the camera was looking. - Left Behind (
stay) — nothing follows and nothing is brought; exiting is the way back to where you were standing.
Following is server-authoritative, like every other way a pawn moves. The client claims a place for its feet each tick (the same eye-anchored feet Teleport Self Here submits, by the same code) and the server writes the position — gated on your pawn actually being in the editor, so a claim from a player who is not editing moves nothing. Stop claiming and the body parks itself where it was after a short grace, so a crash or a quit never leaves a body pinned in the air.
The editor is the same editor under a finger. Nothing is forked for touch: the pointer arrives through Halcyon’s touch route, the widgets are the desktop widgets, and a trackpad or a mouse behaves exactly as it did.
Two things change while the active pointer is a touch pointer.
Hit rectangles widen without moving. Every hit test asks one helper for its slop, so a dock divider drawn five pixels wide, a tab, a close button, a disclosure triangle, an inspector field handle and a gizmo handle all answer to a finger that lands up to client.ui.touchTargetMin away from them. The drawn rectangle never moves — the picture is the picture it always was, and a widened window never reaches across a neighbor’s own square.
A hold is a right-click. Resting a finger still for client.ui.longPress raises exactly the menu the right button raises, on the row under it. Travel past the drag threshold, or start scrolling, and nothing is raised — a scroll is not a hold.
The gestures
Section titled “The gestures”| Gesture | What it does |
|---|---|
| One finger on the viewport | Looks around, the touch arm of the editor’s free-look — the same look the held right button gives a mouse. |
| Two fingers on the viewport | Pans, the same gesture the middle drag is. |
| Pinch on the viewport | Dollies. The spread is converted to wheel notches by client.editor.pinchNotchPx, so a finger and a wheel reach the same step through the same code. |
| One finger on a gizmo handle | Drags the handle. It wins over the look outright: while a handle is held the camera spends nothing, and the pixels that moved the handle are never owed back to it when the handle is let go. |
| One finger on a list | Scrolls the hierarchy, the inspector or the browser. |
| Hold a row still | Opens that row’s context menu. |
A finger that came down on a widget is the widget’s for the life of that contact — the camera never hears about it, so a drag that starts on a dock divider resizes the dock and does not look around. Lifting one finger of a pinch resumes the look from where the remaining finger is, rather than flinging the camera by the distance between them.
Nothing in the editor waits on a hovering cursor. A finger reports hover the moment it touches down, so hover tints and hover-driven affordances already work; the one delay that did not survive a finger — the tooltip dwell, which exists to keep a traveling cursor from flashing plates — is spent on contact instead, and the material preview’s reset, which only a hovered R could reach, is also a double-tap on the ball.
Touch preferences
Section titled “Touch preferences”| Preference | Default | What it does |
|---|---|---|
client.ui.touchTargetMin | 44 | The smallest square a finger may aim at, in interface units at scale 1 — Apple’s number. Applied as slop around the drawn rectangle while the active pointer is touch, and as a grab radius for the gizmo, whose tolerance is a distance from a ray rather than a rectangle. 0 turns the widening off entirely. Clamped to 0–200. |
client.ui.longPress | 0.5 | Seconds a finger must rest before it raises the row’s context menu. Clamped to 0.05–5. |
client.editor.pinchNotchPx | 80 | How far a pinch must spread to equal one wheel notch of dolly, in pixels. Clamped to 4–1000. |
Preferences
Section titled “Preferences”Every editor knob is a live-tunable Preference<T> under client.editor.* — client-scoped, never replicated, because none of them moves anything but this client’s own camera and picture. Set one from the console and it applies on the very next frame.
| Preference | Default | What it does |
|---|---|---|
client.editor.flySpeed | 12 | Cruise speed in m/s at full stick, before sprint. Mirrors world.noclipSpeed. |
client.editor.sprintMultiplier | 2.5 | Multiplier on the whole wish speed while Shift is held — climb as much as forward flight. |
client.editor.acceleration | 1.1 | Extra multiplier per second of held movement. The ramp compounds by this + 1 every second; 0 is a flat cruise with no ramp at all. |
client.editor.easing | 0.4 | Seconds the camera takes to ease to a full stop after the movement keys are released. |
client.editor.lookSensitivity | 0.172 | Camera rotation in degrees per pixel of mouse travel, applied raw. Unity’s scene-view rate exactly, and about 2.6x the gameplay chain’s effective 0.066. The Options window’s slider is this same preference over 0.05–0.5; the console reaches 0.01–2. |
client.editor.scrollSpeedStep | 0.01 | How much of the fly-speed multiplier’s range one wheel notch covers. |
client.editor.speedLinger | 1 | Seconds the fly-speed chip stays up after the last wheel notch. |
client.editor.panSpeed | 0.85 | Scale on the middle-drag pan. 1 is the Unity-exact one-to-one drag; the shipped default is a little under it because a true one-to-one pan runs away at working distances. The scale is applied to the pan, never folded into the pan formula. |
client.editor.panShiftMultiplier | 4 | How much further Shift pans. A flat scale on the whole gesture, and deliberately its own number rather than the fly sprint — a viewport drag should not be tied to how fast the camera flies. |
client.editor.pinchNotchPx | 80 | How far a two-finger pinch must spread to equal one wheel notch of dolly, in pixels. Clamped to 4–1000. See Touch. |
client.editor.dollyStep | 0.15 | Fraction of the focus distance one wheel notch covers. Multiplicative, so it compounds. Raised 10x from Unity’s own 0.015, which read as no motion at all on a city-scale map before dozens of notches. The Options window’s slider is this same preference over 0.02–0.35; the console reaches 0.0001–0.5. |
client.editor.focusSpeed | 2 | How much of a focus flight one second covers. The default puts a framing at about half a second; 0 is an instant cut. |
client.editor.recenterDragThreshold | 6 | How far, in interface pixels, a middle press may travel and still count as a click rather than a pan. |
client.editor.focusEntityExtent | 0.5 | Half-size in meters of the nominal box an entity contributes to a framing. Entities are points on the wire and carry no size of their own. |
client.editor.exitMode | follow | What your body does while you edit: follow rides the camera, teleportOnExit brings the body up on the way out, stay leaves it behind. The Player menu’s three-row group is this same preference, and anything unrecognized folds onto follow. It lives in the engine rather than in the editor module, because a preference declared in a reloadable assembly would pin its load context forever. |
client.editor.hoverNames | false | Whether the thing under the cursor names itself on a plate beside the pointer. The View → Hover Names row is this same preference. |
client.editor.undoDepth | 200 | How many acts the history keeps. Past this the oldest entry is dropped, so the depth is a floor on how far back you can reach rather than a budget you can exhaust. Clamped to 10–1000, which is also the Options window’s Undo History Depth slider. An entry holds console lines rather than a picture of the map, so the number is about how far back a person expects to reach and not about memory. Lowering it below what is already recorded trims on the next act rather than immediately — the only moment the list is being written anyway. |
client.editor.undoSelection | true | Whether a change of selection is an act the history remembers. On, clicking one thing and then another leaves two entries and Ctrl+Z hands the first one back, and the material the Inspector is pinned to travels in the same entry. Off, the history holds edits alone and Ctrl+Z walks straight past every click. Either way the selection an edit carried stays inside that edit’s entry, so an undone delete puts back what it deleted and selects it. The Options window’s Undo Selection Changes switch is this same preference. |
client.editor.liftReportMs | 4 | Milliseconds a live-transform lift may take before its stage breakdown is logged. The first drag of a node is the only edit that touches the device at all, so it is the only one that can stall a frame, and a total with no breakdown cannot say which stage did it. Four is a frame’s slack at 60 Hz rather than a frame. |
client.editor.liftReleaseSeconds | 0.5 | How long a lifted node or brush may rest at its authored pose before the lift is retired and the merged batch draws it again. 0 releases on the first frame it is home, which costs a device idle every time a drag crosses the authored pose; the delay is what keeps a drag from thrashing. |
client.editor.pickBounds | false | Pick map nodes by their bounding box instead of by their triangles. The default is the accurate test; this is the way back out of it, which is why it is phrased as the opt-out rather than as a switch you have to find and turn on. The Options window’s Bounding Box Picking switch is this same preference. |
client.editor.cycleThreshold | 4 | How far, in interface pixels, the pointer may move between two clicks and still count as the same click for the purpose of reaching one layer deeper. Clamped to 0–64: 0 demands a perfectly still mouse, and the top of the range is about as much slop as a click can carry before it stops being the same click. |
client.editor.contextClickDeadZone | 2 | How far, in interface pixels, a right press may travel between press and release and still raise the viewport’s context menu rather than counting as the look-and-fly drag. Clamped to 0–64, and scaled by the interface scale at the point of use, so it is the same physical slip at 200% as at 100%. |
client.editor.contextAddDistance | 10 | How far along a right-click that hit nothing a new object is placed, in meters. Clamped to 0.5–200. Longer than client.editor.addDistance because a click into open air is usually aimed across a room rather than at arm’s length; a click that hit something ignores this entirely and places at the hit point. |
client.editor.dropMarkerSize | 1 | The edge-to-edge size, in meters, of the wireframe box marking where an asset dragged out of the browser would spawn. Clamped to 0.05–32. Deliberately not the carried thing’s own bounds: nothing is loaded on your side until the server grants the spawn, so the mark states a place and claims nothing about the shape that will fill it. |
client.editor.stageOffset | 3.5 | How far in front of you, in meters, prefab.open stands a staged object. Clamped to 0.5–100. Its own number rather than world.spawnDistance: a thing you are about to edit wants to be closer than a thing you are placing in a room. |
client.editor.bodyFadeNear | 2.5 | Radius in meters inside which your own body is completely invisible. |
client.editor.bodyFadeFar | 3.5 | Distance in meters at which your own body is fully drawn again. |
client.editor.pendingColor | [253, 181, 57, 255] | The ink of the unsaved-changes asterisk beside the map’s name, as an RGBA array with each channel 0–255. It lives in the engine rather than in the editor module, because a preference declared in a reloadable assembly would pin its load context forever. |
client.editor.pendingMarkSize | 32 | The asterisk’s size in authored pixels, far above the name it marks: an asterisk sits high and thin in its line box and reads as dust at the text’s own size. It may exceed the bar’s own height — the mark is laid out in a box the size of the name’s line and allowed to overhang it, so this changes how big the star is and never where the row sits. Sizes off the baked ladder (11, 12, 14, 15, 22, 32, 44) are stretched rather than refused, at the cost of sharpness. |
client.editor.barFontSize | 15 | The map’s name in authored pixels — that text and nothing else. The menu headings are Halcyon’s own bar widgets dressed to match the developer overlay’s bar, and a size that moved one bar and not the other would break the parity the toolbar is built on. The name is set in the bold cut, which is baked at 11, 12, 14, 15 and 22, so the clamp stops at 22. |
client.editor.pendingGlowColor | [198, 116, 0, 255] | The wash’s ink, an RGBA array — #C67400. The alpha channel here is the color’s own; what the wash actually reaches is the peak below. |
client.editor.pendingGlowAlpha | 0.076 | The alpha at the middle of the bloom, 0–1. Halcyon blends in linear light while the picture this reproduces was authored in a gamma-space tool at 30%: the authored image measures sRGB(57, 33, 0) over the bar’s black, and reaching that encoded value through a linear blend takes this. 0.30 here would land near sRGB(115, 62, 0) — bright enough to cost the white text over it its contrast. |
client.editor.pendingGlowRadius | 0.28 | The circle’s radius at full bloom, as a fraction of the bar’s width, so the reach follows the display rather than a pixel count. |
client.editor.pendingGlowFalloff | 0.55 | How the ramp gets from the peak to nothing. 1 is a straight line in alpha; above one holds the middle brighter and drops off later, below one leaves the center quickly and trails. Under one here because a ramp that is straight in alpha comes back convex through a linear blend and an sRGB encode — bright well past where the authored picture has gone dim. |
client.editor.pendingGlowSeconds | 0.3 | How long the bloom takes to reach its radius, and the contract to come back. Clamped to at least 0.01. |
client.editor.mapPrefixColor | [120, 120, 128, 255] | The bundle prefix and its colon, in the top-center barcode. The fainter of the two. |
client.editor.mapNameColor | [255, 255, 255, 255] | The map’s own name — the part you would say out loud, and the part the readout exists to show. |
client.editor.tooltipSeconds | 0.5 | How long the pointer rests on anything with a tooltip — the map’s name, a Scene panel button — before its plate appears. One number for all of them, because the delay belongs to your hand rather than to the thing under it. 0 shows the plate the moment the pointer arrives, which the middle of a menu bar is the wrong place for. |
client.editor.tooltipOpacity | 1 | How much of a tooltip plate’s fill survives, 0–1. Opaque, and the number is honest rather than corrected: Halcyon blends in linear light, so an alpha authored against a gamma-space tool lands far lighter here — at 0.94 the inspector row under a plate was still legible through it, and a surface that has to be read through what it covers has not said anything. |
client.editor.tooltipFontSize | 15 | The size a tooltip’s words are set at, in authored pixels, over 8–24. Its own size rather than the size of whatever raised it: a tooltip is prose somebody stopped to read, and the inspector’s plates were set at the small size a value chip wears — smaller than the row that raised them. |
client.editor.tooltipWidth | 280 | How wide a tooltip’s words may run before they wrap, in authored pixels, over 80–720. A sentence with nothing to wrap at is one line however long it is, which ran off the side of the window and past the edge of the screen. The bound is on the words; the plate is that plus its own padding. Authored pixels, so the wrap holds the same number of words at every interface scale. |
client.editor.inspectorControlHeight | 20 | How tall every value control in the Inspector stands, in authored pixels, over 12–64. One number for the numeric fields, the text fields, the segmented chips, the dropdown buttons and the material inspector’s shader picker, so a row of words and a row of digits stand the same height whatever each would have measured alone. |
client.editor.consoleInfoColor | [205, 210, 220, 255] | The console badge’s info counter once it has anything to count — mark and number both. A near-white neutral rather than a hue: ordinary output is not a classification, it is just the pile everything else is measured against. |
client.editor.consoleWarnColor | [253, 181, 57, 255] | The badge’s warning counter when lit. The same amber the unsaved asterisk uses, deliberately: both mean “look at this before you ship it”. |
client.editor.consoleErrorColor | [240, 96, 84, 255] | The badge’s error counter when lit. At zero all three sit in the bar’s own faint gray instead, which is not a preference — a resting badge is furniture, and furniture matches the bar it is bolted to. |
client.editor.hierarchyTextColor | [200, 200, 200, 255] | A hierarchy row’s resting ink — a bright neutral gray rather than white, so the active row has somewhere brighter to go. All of the hierarchy colors are RGBA arrays, applied on the next frame from the console. |
client.editor.hierarchyActiveColor | [255, 255, 255, 255] | The active row’s ink. White and untinted: the row is being pointed out, not classified. |
client.editor.hierarchyDisabledColor | [120, 120, 120, 255] | The ink of a row whose object is switched off — dim enough to read as absent at a glance, on the terms every editor dims a disabled object. |
client.editor.selectionColor | unset | The editor’s one selection hue, read by the hierarchy row bar and by a selected bone’s dot alike, as RGBA 0–255. Unset follows the live interface accent — a green theme gets a green selection with nothing configured — so retuning the theme, or this preference directly, moves every surface that says “this is in hand” together. |
client.editor.linkedColor | unset | The ink a linked row’s kind mark takes, as RGBA 0–255. Unset follows the interface theme SEED rather than the accent, so the mark that says “this instance inherits a definition” can never be mistaken for the one that says “this is selected” — the two move apart when the theme moves. |
client.editor.hierarchySelectionOpacity | 0.188 | How strongly the bar behind a selected row is washed with that hue — a low-alpha glass rather than a solid fill. Halcyon blends in linear light, so the same alpha lands lighter here than it would in a gamma-space toolkit; the authored number is the honest one. |
client.editor.texturePreviewSize | 256 | The edge in pixels a texture opened in the picture window is decoded at, over 16–512. Viewers share one atlas per size, and an atlas is capped at 512 pixels on a side — so 512 leaves room for exactly one picture and two open viewers would evict each other every frame. |
client.editor.imageViewerZoomMin | 0.25 | How far a picture in the picture window may be shrunk below its fitted size. Clamped to 0.01–1. |
client.editor.imageViewerZoomMax | 64 | How far that picture may be magnified past its fitted size — well past the material preview’s own ceiling, because 1:1 on a large texture in a small window is a large number. Clamped to 1–512. |
client.editor.imageViewerCheckerCell | 8 | The edge in pixels of the transparency checker behind that picture at fit. It scales with the magnification, and coarsens by doubling rather than drawing an unbounded number of squares. Clamped to 2–128. |
client.editor.lightmapPreviewExposure | 1 | Exposure applied to a baked atlas before the Lighting window’s viewer tone-maps it, over 0.01–64. It changes the picture and nothing the world is shaded with — a dark bake can be opened up to be read without touching what shipped. |
client.editor.hierarchyRowFontSize | 12 | A hierarchy row’s text size in authored pixels. 11, 12, 14, 15, 22, 32 and 44 are the baked rungs; a size between two rungs is stretched and costs sharpness. |
client.editor.hierarchyRowHeight | 16 | The whole height of a hierarchy row in authored pixels, over 10–48. The band is stated outright rather than derived from the text size and a padding, so a row cannot end up a height neither number asked for; the mark and the name are centered in whatever it is set to. |
client.editor.hierarchyRowPadding | 4 | A hierarchy row’s inset on each side, in authored pixels, over 0–16. Horizontal only — the height above is the vertical answer. |
client.editor.hierarchyChipMax | 4 | How many component marks a hierarchy row’s trailing strip may draw, over 0–12, before the rest collapse into a +k. 0 turns the strip off entirely and leaves the count. |
client.editor.hierarchyChevronHitWidth | 18 | The width of a hierarchy row’s fold column in authored pixels — which is to say, the click target of its chevron. Clamped to 8–48. Wider than the glyph on purpose: the chevron is the only way to fold a branch, so it has to be easy to hit. |
client.editor.inspectorLabelFraction | 0.4 | The share of an Inspector row its label takes, over 0.1–0.9. One split across every card in the window, so the names line up down the whole panel. |
client.editor.inspectorLabelMinWidth | 62 | The floor under that share, in authored pixels, over 16–240. It keeps a narrow pane’s names legible. |
client.editor.materialDrawerSnap | 40 | How far past its floor a drag has to carry the material preview drawer before it folds shut — and how far back up before it opens again — in interface pixels. Clamped to 0–400; 0 turns the snap off and leaves the chevron the only fold. |
client.editor.materialPreviewGroupGap | 12 | Visible spacing between the preview drawer’s object picker and its lighting picker, in interface pixels. Clamped to 0–64. |
client.editor.inspectorLabelMaxFraction | 0.5 | The ceiling on it, over 0.1–0.9, and applied after the floor so it wins: a pane too narrow for both keeps its value column, because a row with a name and no number is not a row. |
client.editor.browserRefreshMs | 250 | How long the workspace must go quiet, in milliseconds, before the asset browser answers the changes its watcher collected. A build writes hundreds of files in a burst, and re-walking on each one would be a re-walk per file. |
client.editor.browserRefreshMaxMs | 2000 | The longest one burst may hold that walk off, in milliseconds. Without a cap, a write that runs for twenty seconds pushes the quiet window forward on every file and the listing never refreshes at all — which is the one moment somebody is most likely to be watching it. |
client.editor.browserTreeWidth | 180 | How wide the asset browser’s pallet tree is, in interface pixels. Written by the seam between the tree and the listing, which is draggable the way a dock divider is, so where it stands survives the session. |
client.editor.browserTreeMinWidth | 120 | The narrowest that seam may be dragged to, over 40–1000. A tree squeezed past its names is a column of ellipses. |
client.editor.browserTreeMaxFraction | 0.5 | The largest share of the browser’s pane the tree may be dragged to take, over 0.15–0.9. Applied after the floor, as the inspector’s label ceiling is: a pane too narrow for both keeps the minimum. |
client.editor.browserTileSize | 16 | How wide the asset browser draws one asset’s thumbnail, in authored pixels, over 16–128. It is what the footer’s scale slider writes. Below 24 the listing is a list of rows; from 24 up it is a grid of tiles. A value between rungs is snapped to the nearest of 16, 24, 32, 48, 64, 96, 128, because each distinct size costs its own thumbnail atlas page. |
client.editor.browserSearchDepth | 4 | How many folders below the workspace source the browser looks for a pallet.dh in, over 1–16. A workspace lays a pallet out as <vendor>/<category>/<name>, three folders down, and the default carries a level of slack for a person who grouped theirs further. Deeper than that and the hunt is scanning the asset tree itself, which is exactly what the lazy listing exists to avoid. |
The dock layout and its feel numbers are ordinary preferences, written by the interface at gesture ends and reachable from the console like any other.
| Preference | Default | What it holds |
|---|---|---|
client.editor.hierarchyOpen / client.editor.inspectorOpen / client.editor.toolboxOpen / client.editor.browserOpen | false | Whether the window is raised. The View menu’s row is this same flag. |
client.editor.layout | (empty) | The whole dock arrangement as one spelling — splits, shares, tab groups, floating rectangles. Empty or malformed reads as the default layout, and so does any line that does not name gameView exactly once, counting the floating half as well as the docked one. |
client.editor.gameViewSettle | 0.12 | How long the game view’s scene targets may sit oversized, in seconds, before they are rebuilt smaller. Never defers the picture, which follows the pane on the next frame — only the memory. 0 gives it back the instant the slack appears, which is honest and expensive. |
client.editor.gameViewQuantum | 8 | The step the game view’s render size is rounded up to, in pixels. Bigger steps mean a slow drag crosses fewer distinct sizes. |
client.editor.gameViewHeadroom | 0.25 | Spare scene target allocated past the rendered rectangle, as a fraction of it. The whole drag budget: a pane grown by less than this costs no rebuild at all. The memory cost is quadratic in this number. Clamped to 0–2. |
client.editor.gameViewShrinkSlack | 0.35 | How much smaller than its images the pane must be before the memory is given back, as a fraction. Never tighter than the headroom, or a shrink would rebuild straight back. Clamped to 0–4. |
client.editor.toolboxSnap | viewTop | Where the toolbox bar is snapped: editorTop (across the whole editor), viewTop (across the top of the 3D view alone) or float. |
client.editor.dockEdgeBand | 16 | How close to the display’s rim a dragged tab must be to offer the editor’s full edge rather than the pane it is over, in pixels. |
client.editor.dockPaneSplit | 0.5 | The share of a pane a dropped window takes when it splits one, for a window whose own size says nothing. |
client.editor.dockRootSplit | 0.25 | The share of the editor a dropped window takes when it docks to a display edge, for a window whose own size says nothing. |
client.editor.dockIndicatorAlpha | 0.35 | Opacity of the drop indicator’s wash. Authored against linear blending, like every alpha in this interface. |
client.editor.dockTearThreshold | 4 | How far a pressed tab must travel from where it was pressed, once the pointer is outside its strip, before it tears off into a drag ghost, in pixels. Inside the strip a drag reorders instead. |
Only flySpeed still mirrors a movement var (world.noclipSpeed), so entering the editor does not change how fast the world goes by. The ramp and the ease-out have no counterpart in the mover at all — they are the tool camera’s own feel. Every default is a starting point rather than a binding: retuning the editor camera must never retune a pawn, and vice versa.
acceleration is stated as “extra per second” rather than as the raw compounding base on purpose: it is the number a person reasons about, and it makes the broken half of the range unreachable — the camera adds 1 before using it, so no legal value can shrink the speed while a key is held.
bodyFadeNear is a safety radius, not a taste setting — 2.5 m clears a 1.83 m pawn from any angle, including straight overhead, where the feet are already about 1.8 m from the eye the distance is measured to. bodyFadeFar is a taste setting: a one-meter ramp past the dead zone, so the body hands itself back almost as soon as you fly away from it.
Loading the module
Section titled “Loading the module”DigitalHeaven.Editor is a leaf assembly the host references and nothing else does. It is composed lazily, not loaded at run time: the first successful editor constructs the module and registers its commands.
- A plain play session never constructs it. A player who never types
editorpays nothing — no module, no registration, no allocation beyond the tiny always-present command object. - A dedicated server never has it at all. The editor’s commands are registered on the client console, which a
--headlesscomposition does not construct. - The toolbar is read fresh every frame from the registry the module publishes it to, so the client never names a type the module owns.
Where the editor’s command lands is worth knowing: editor and editor.teleportSelf run on the client console, while pawn.editing and teleport are server commands the client forwards. The client console always speaks with authority over itself, so the gate that decides whether anyone may edit is the server’s, applied to the request — not the local one.