Scene Manipulation
A compiled map ships a part table in its world instance: one deterministic id/name/path entry per node — mesh-bearing or structural — with the pivot the compiler baked, and the bounds where there is geometry to bound. The scene.* commands act on that manifest at runtime, so a node can be turned off, hidden or removed while the world is running — no recompile, no relaunch.
Every one of these commands is server-authoritative and gated on world authority (the server console, the embedded host, or an admin). A plain remote client is rejected. Mutations flow through the world’s scene editor, which destroys or rebuilds the node’s baked physics colliders and replicates the new full state to every client.
Commands
Section titled “Commands”| Command | What it does |
|---|---|
scene.hierarchy [depth] | Lists the manifest down to depth path levels: object index, path, effective state, and live collider count (so a visual-only node is visible as such). |
scene.edits | Lists every object not in its default state, by object index. |
scene.select [target] | Selects a node, so clients draw the pivot gizmo on it. No target clears the selection. |
scene.disable <target> | Hides the node or box and makes it non-solid — its colliders are destroyed. A box is named by its object index. |
scene.enable <target> | The reverse: shows the node or box and rebuilds its colliders. Also overrides an authored removal (a part override’s "removed": true) for the session. |
scene.hide <target> | Hides the node visually only. Its colliders stay. The node form of scene.renderer … 0. |
scene.show <target> | Reverses a visual hide. |
scene.delete <target> | Hides the node and destroys its colliders, marking it as a node the map source should lose rather than switch off. scene.enable takes it back — what makes a delete permanent is promoting it, or the editor’s Save, into a dh.removeObject. |
scene.transform <target> <x> <y> <z> [rx ry rz] [sx sy sz] | Sets the node’s whole transform delta from its compiled pose: offset in meters, euler rotation in degrees (Z·Y·X about the compiled pivot), scale multiplier. Omitted rotation reads as zero and omitted scale as one, so scene.transform 5 0 2 0 is a plain move. Colliders follow: offset and rotation re-pose the live bodies in place, a scale change rebuilds the shapes (a body transform carries position and rotation only). A box is transformed the same way, about its own center, and its bodies are always rebuilt. |
scene.removeEntity <objectIndex> | Turns the logic entity off and marks it as one the map source should lose rather than switch off — the entity twin of scene.delete. scene.enableEntity takes it back. See Removing a logic entity. |
scene.transformEntity <objectIndex> <x> <y> <z> [rx ry rz] [sx sy sz] | The same for a logic entity, refused per operation for a kind that does not admit it — see below. |
scene.spacing <objectIndex> <meters> | Sets a light probe volume’s grid spacing, clamped to the compiler’s range. Refused for every other entity kind. See Respacing a light probe volume. |
scene.inset <objectIndex> <bool> | Lays a light probe volume’s probes at cell centers, or back on its faces. Refused for every other entity kind. |
logic.call <objectIndex> <input> | Calls an input on a logic entity’s node the way a wire’s call does: logic.call 12 mover.open, mover.close or mover.toggle on a door (a qualified name reaches only that component; a plain one reaches every component of the object that declares it). The real node runs, so the picture, the collision, the replication and the outputs it fires are the ones a wired call gives. It is not an edit: nothing is saved, undone or pending. The inspector’s Open, Close and Toggle row sends it. Needs world authority. |
scene.save | Writes this map’s edits to the engine’s saved-delta store. See Persistence. |
scene.forget | Discards the saved delta, leaving the live session alone. |
scene.discard | Throws the live session’s edits away: the map goes back to what it authors with its saved delta replayed, what the session created and spawned leaves it, and every collider follows. The map stays installed and every replica stays; the editor’s Discard Changes and Reload and discard are this verb. |
scene.promote [path.dh-map] | Turns the session’s deleted nodes into authored "removed": true overrides. See Promotion. |
A <target> is an object index (5), an exact path (buildings/block03/wall_north), or a name (wall_north). A name shared by several nodes is ambiguous and is rejected with the candidates listed — use the path.
An object index is the object’s place in the map’s MapObjectTable: the map’s entities in authored order, folders and boxes included, then its manifest nodes ordered by id, then whatever the session created. Every verb on this page, the scene-state message and every snapshot record name an object by that one number, which server, client, editor and mobile session all read off the same table. It is a session-local handle; what is saved names the object’s id.
Every verb names one node and reaches nothing else — naming buildings/block03 does not touch the walls under it. That is deliberate: the editor’s subtree cascade expands a branch on the client and submits one of these lines per node, so a group gesture and a person typing the lines out are the same set of edits arriving in the same shape.
Hide versus disable
Section titled “Hide versus disable”The two look identical on screen and are deliberately different underneath:
scene.disablemakes the node non-solid. Its colliders are destroyed on the server and excluded from the client’s prediction world. You can walk through it.scene.hideonly makes the node invisible. The renderer skips its geometry; physics never hears about it. You still collide with it.
Hiding is the tool for looking behind something without changing how the level plays. Because the two states are distinct, scene.show will not silently rescue a disabled node — it says so and points at scene.enable.
The renderer implements the skip by rebuilding the map mesh’s index buffer from the always-retained per-node geometry ranges, so a hidden node costs nothing to draw. The rebuild happens only when the hidden set actually changes.
A node an authored override removes is not in that buffer at all: every load of the map, on the client and the server alike, leaves its geometry undecoded. scene.enable on such a node reads that one node from the pallet, draws it from a mesh of its own (the same per-node mesh a live translate lifts) and builds its colliders. Removing it again hides it and destroys them. A restore that arrives replicated from another editor, or in a joiner’s first scene state, takes the same path.
One type owns what is hidden on either client: SceneVisibilityState holds the replicated hides and the editor’s own, unions them with whatever the scene has lifted out for a live translate, and bakes the result into a scene — including into a map that lands after a hide arrived, which is how a joiner’s baseline survives the load it was sent before.
What an edit costs the client
Section titled “What an edit costs the client”Every edit is broadcast as a full scene state, and each client folds it into its prediction world by the cheapest route that still ends with exactly the body set the server has:
- Nothing at all, when the state changes nothing a collider can see — a node the map traced no collider to at all. What a node owns is asked of the live provenance rather than of the baking mode: its baked primitives under
boxesandhulls, its own piece of the cook under the defaultmesh. A checkbox on a picture costs nothing; a checkbox on something solid never falls into this route. - That object’s bodies alone, when it does. The affected node’s or box’s static bodies are destroyed and built again at the new pose; the rest of the world, including every other object’s share of the cooked map mesh, is never touched.
- A whole rebuild, sliced across frames, for the rest — a state that arrives for a map the prediction world was not built from, for instance. The world the player is standing in stays live and solid until the new one is finished, because the mesh cook inside it is a run of indivisible native calls measured in seconds on a large map.
Each apply logs one line saying which route it took and what it cost, so a toggle that is not free says so:
scene state cost: visibility 4 ms, prediction 0 ms (skipped: visualOnly), total 4 msComponents
Section titled “Components”Every scene object has an object switch — on or off, whole, the thing scene.disable and scene.disableEntity throw — and, under it, the two components that carry a switch of their own. This is the model the editor’s inspector draws. The renderer has a verb; the collider has none, because its switch is a field.
| Command | What it does |
|---|---|
scene.renderer <target> <0|1> | Switches the Renderer. 0 means nothing is drawn, for everyone; collision is untouched. |
<target> is an object index, or a path or name for a node.
The collider’s switch is the state field of the object’s dh.collider: solid, off or trigger. The editor’s Collider card writes it like any other component field, the server replicates it with the rest of the field table, and a client that joins afterwards reads it from that same list. There is no scene.collider verb.
The collider’s shapes and material are fields too, and adding or removing the component is the one component edit (an object index, a type id and whether it is removed). A shape list is one value, one word of JSON per shape, because a list replaces whole; the server refuses a list the collider rules refuse (a field the shape’s type does not own, a half extent of zero) and one that adds or moves a mesh shape, which is cooked at compile time. Every edit rebuilds only that owner’s bodies, from the one collider the owner has once the session’s edits are laid over the map: an owner with no edit keeps the compiled collider, a part or a box whose component is removed gets the compiled one back whatever else was written to it, a plain object whose component is removed has none, and a plain object given one starts from a unit box.
Which components an object actually has depends on what it is:
| Renderer | Collider | |
|---|---|---|
| Node | Wherever the node carries a mesh. A structural node — a group the manifest names but the GLB gave no geometry — has neither component, and the inspector shows no row rather than an unchecked box. | Wherever the part has a dh.collider: every part the compiler gave one, which is every drawn part under the default mesh mode. A part with none has no collider component, and the inspector shows no row rather than an unchecked box. |
Box (an object holding a dh.box) | Always. | Always — the compiler gives every box one over its exact box. |
| Plain object | No. | Wherever it carries a dh.collider. |
| Entity | Always. | Not yet: a logic kind keeps its own body until its kind becomes components, so the inspector shows no collider row on one. |
The renderer’s off state is not a new fact: it is the hidden state a node has always been able to be in, wearing its true name. scene.hide and scene.renderer … 0 on a node are the same edit, and a box or an entity reaches that same state through this verb.
The collider’s states are new. off destroys the body on the server and excludes it from every client’s prediction world — a player standing on one when it goes simply starts falling, the same way they do when the object is switched off whole. trigger destroys the blocking body too and puts nothing in its place: the shape overlaps and fires nothing, since touch edges belong to a dh.trigger component.
Only a dh.trigger fires touch edges. A part or a box whose collider is a trigger has no outputs, so it cannot be wired; an object carrying a dh.trigger is a logic entity like a plate, wired on its own record. Switching that object off drains it the way a plate is drained: whoever was inside is told they left.
Wiring
Section titled “Wiring”| Command | What it does |
|---|---|
scene.wire <target> "<connections json>" | Restates the object’s whole connection list, as the JSON array a map’s connections is. "[]" wires it to nothing. |
<target> is the component verbs’ exactly. The list is whole rather than granular — one verb instead of an add and a remove — and everything else follows from that: the previous list is the entire undo record, an edit is atomic under several editors, and the array is exactly what Save writes back into the object’s own entry (or, for a node, onto its part override).
The edit reaches the running graph, not only the file: a door newly wired to a button starts obeying it on the next press, with no reload. An action list already running is untouched — a delayed continuation is a wait the scheduler is holding rather than an entry in the table — so rewiring mid-run neither cancels the wait nor retargets what it was going to do.
It refuses exactly what the build refuses, through the same validator, in the same words: an output the kind does not declare, a target the map does not hold, an input that target’s kind does not accept, a param that does not match the input’s type, and wiring on geometry, which has no outputs at all. An editor that could write a map its own compiler then rejects would be worse than useless, so the two ends read one rule set.
A collider is a component state the file keeps wherever it is edited: the editor’s Save writes it into the dh.collider its owner states — a part’s override, on the same one override that already carries its pose, its name and its switch, or a box’s or a plain object’s own record (what a save writes). A renderer state is still session-only, and the editor’s pending readout counts it as pending-but-unsavable for exactly that reason: the save path has nowhere to write it yet.
Turning a logic entity off
Section titled “Turning a logic entity off”A logic entity — a button, a door, a light, a pressure plate, a physics prop — is not a manifest node and is not addressed by one. It is named by its object index, the same number that rides every snapshot record, which both ends read off the same map’s object table.
| Command | What it does |
|---|---|
scene.disableEntity <objectIndex> | Turns the entity off whole. |
scene.enableEntity <objectIndex> | Turns it back on, from either of the two ways it went off. |
scene.removeEntity <objectIndex> | Turns it off and marks it for removal from the source. See below. |
There is no hide/disable split here, because there is nothing to split: an entity’s mesh, its collider and its ticking are one object. Off means Unity’s SetActive(false) —
- its collision body is destroyed on the server (a door’s kinematic box, a prop’s dynamic box, a button’s static box, a plate’s trigger volume),
- its node stops stepping, stops answering
useand touch, and every call still wired to it lands silently, - any delayed continuation it had in flight is dropped rather than fired into a world it has left,
- and no client draws it, labels it, outlines it or picks it.
A pressure plate or a trigger someone is standing on fires its onEndTouch on the way out, so downstream wiring cannot latch at “pressed” with nothing pressing it. A player standing on an entity when it disables simply starts falling — the body is gone and the next sweep finds nothing under them.
Turning it back on rebuilds the body at the pose it was carrying when it left (a physics prop stays where the solver had dragged it) and resumes ticking. The entity is never despawned, so its wire id survives the whole round trip — which is what lets the editor keep it selected, and keep the switch that can turn it back on.
Entity states are session-only and are not written by scene.save.
Removing a logic entity
Section titled “Removing a logic entity”scene.removeEntity is scene.disableEntity plus an intent. What it does to the world is exactly what a disable does — the body goes, the ticking stops, no client draws it — and what it adds is a second set the server keeps beside the disabled one, which the editor’s Save reads to decide what the file is owed. A disabled entity owes the file nothing, because the map schema has nowhere to state an entity’s switch. A REMOVED one owes the file the plain absence of its entry, and the save writer deletes the entry outright rather than flagging it — there is no hidden-but-present state for a map to carry.
The entity’s slot in the logic list is never vacated while the session runs. An object index is a place in the map’s object table, so closing the gap would renumber every object after it and every in-flight message naming one. The list shortens exactly once, when the file is saved and the map is built again.
scene.enableEntity is the whole of the way back, and it clears both sets at once. Nothing about the entity was torn down, so its transform, its name and its component state come back with it without any of them having to be carried anywhere — which is why the editor’s undo for a removal is one line and not a snapshot.
A box has no form of this verb. A box is world geometry rather than a thing that ticks, so the verb, which acts on logic entities, refuses its index; its own switch is scene.disable.
Transforming a logic entity
Section titled “Transforming a logic entity”| Command | What it does |
|---|---|
scene.transformEntity <objectIndex> <x> <y> <z> [rx ry rz] [sx sy sz] | Sets an entity’s transform delta from its authored pose, per-op gated by its kind. |
The transform is absolute, not cumulative: it is how the thing stands relative to where the map put it, so sending 0 0 0 is how an edit is undone rather than a second edit that happens to cancel the first. The server keeps one transform per entity and replicates the whole table on every scene-state message, exactly as it does for node and box edits.
Eligibility is per operation, and the table is short on purpose. A mesh node and a box admit everything — move, rotate, scale. A structural node is a name in the hierarchy rather than a thing in the world, so it takes a rename and the switch and none of the three transforms. A light moves and rotates (its aim IS its rotation) but has no size to scale, and the mover kinds — a button, a door, a pressure plate, a physics prop — admit the same two on the same terms: the edit writes the AUTHORED rest pose the kind’s 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 hold their pose entirely — nothing about one is where it stands — while rename and the whole-object switch stay open to every kind. A spawned asset is the one kind that admits all three: nothing authored it, so there is no compiled pose an edit could contradict, and it is placed by spawn.transform naming its wire id rather than by a scene.transform* verb naming a map index it does not have. The predicate is one named place — SceneEditableObjects — that both ends ask: the client before it offers gizmo handles, the server before it accepts the verb, and a refusal names the kind and the operation. A player pawn is left out because moving somebody else’s body is a permission question that has no answer yet, and a predicate that guesses at one is worse than a predicate that says no.
The server moves the entity’s collider and nothing else. The replicated transform is deliberately left where it was: every client already knows the authored pose and applies the offset locally. That is what keeps a late-joining client’s picture identical to everyone else’s — it is handed the offset table with the rest of the edit set — and it is why the server must never also re-pose the entity, which would apply the move twice.
Client-side, that offset is added in exactly one place: LogicPropCatalogRef.Place. Everything that draws, marks, picks or frames an entity — the fixture’s box, the light it casts, the gizmo dot, the label above it, the hover and selection rings, the cursor’s pick, the wiring lines and the focus flight — resolves its point through that one call. Leaving each surface to look the displacement up for itself is precisely how a dragged lamp ended up with its handles in the new place and its rings, dot and label still on the old hook.
Unlike the states above, an entity move is savable: it rides the same path a box’s move takes, rewriting the authored object’s own position in the .dh-map source through the editor’s Save.
Respacing a light probe volume
Section titled “Respacing a light probe volume”| Command | What it does |
|---|---|
scene.spacing <objectIndex> <meters> | Sets how far apart the probes inside a dh.lightProbeVolume sit, in meters. |
scene.inset <objectIndex> <bool> | Sets whether those probes sit at cell centers (the default) or on the box’s faces. |
A light probe volume’s spacing and layout are fields of its kind — probeSpacing and insetProbes — so each verb is a scene field edit of the volume, the same edit the Inspector’s card makes, rather than a pose through scene.transformEntity. The verb is refused for anything that is not a light probe volume, and the number is clamped to the range the field’s description gives, which is the range the compiler enforces, so a spacing accepted here is a spacing the map could have stated.
The field edits replicate wholesale, like the renames: a volume with no edit is gridded at whatever its own source states, and failing that at the map’s lighting.giVolume.probeSpacing. That is what an inherited spacing IS on the wire — an absence — and it is why the client puts an unmentioned volume back to its authored number rather than leaving the last value it saw on it.
Applying one re-grids the preview for everybody: the client writes the number onto the volume and drops the memo the probe-grid preview is keyed on, so the boxes and their probe counts are the same picture on every machine watching the map. The grid itself is compile-time lighting input — nothing here reaches the prediction world, and nothing here changes a baked atlas until the map is baked again.
The layout is a field of its own, so a volume that was only respaced carries no insetProbes edit at all: that absence is the difference between “this box wants its probes on the faces” and “nobody here has an opinion”, and a respacing can never quietly overwrite an insetProbes the map states.
Like an entity move, a respacing is savable: it rewrites the authored object’s own probeSpacing — and its insetProbes, each only when this session actually chose it — through the editor’s Save.
Material overrides
Section titled “Material overrides”A material is not a node, so it has verbs of its own. material.* retunes a compiled material where it stands — every slot in the running map that binds it, at once — without rebuilding the scene, reloading the map or recompiling the pallet.
| Command | What it does |
|---|---|
material.list | Lists each material the installed map binds, the slots that bind it, and any live override or binding. Overrides recorded for a barcode nothing binds are listed after them. |
material.set <barcode> <field> <value...> | Overrides one field. |
material.reset <barcode> [field] | Clears one field’s override, or every override on that material. |
material.bind <slot> <materialRef> | Points one map material slot at a different material. |
material.unbind <slot> | Puts a slot back on what the map authors for it. |
material.variant <barcode> <inherits> | Declares a new material that inherits an existing one, carrying whatever the base is currently retuned with. |
material.new <barcode> | Declares a new material that inherits nothing: the engine’s default surface. |
A <barcode> is a dh.material barcode — pallet:path, exactly as the map’s materials block spells it — or a single-* wildcard over the materials the map binds (core:materials/*). A barcode no slot binds is not an error: the override is recorded, says so, and applies the moment something binds that material. A wildcard that matches nothing is refused with the bound barcodes listed.
Like the scene.* verbs, these are server-authoritative: the world holds the override layer, the layer rides the world settings on every snapshot, and each client lays it over the materials it resolved at load. A joining client receives the whole layer with its first snapshot, so a map retuned an hour ago looks the same to somebody who just connected.
The fields, and how each one reads its arguments
Section titled “The fields, and how each one reads its arguments”| Field | Arguments | Notes |
|---|---|---|
tint, emissiveColor, transmittanceColor, foamColor | r g b [a] | sRGB components. Alpha defaults to 1. |
uvScale | u v | |
flow | dx dz speed, or still | The direction is normalized. still is a value of its own, not the absence of one — it stills a preset that flows, which clearing the override would not. |
metallic, roughness, emissiveIntensity | one number | Clamped to the range a compiled material may state. |
waveScale, chopScale, atDistance, foamWidthMeters, chopWavelengthMeters | one number | Water only. Clamped the same way; the verb reports the clamped value, not the typed one. |
shader | lit, unlit or water | The models a material may author, which is the same list an inspector offers. water moves the surface into the water pass and away from it, taking the block’s numbers — or the preset’s defaults, on a material that never authored one. |
textures.<channel> | a dh.texture barcode, or none | Binds one channel: baseColor, normal, metallicRoughness, occlusion, emissive, height. |
alphaMode, renderFace | one word | From the same vocabulary a .dh-mat may state. |
stochastic | on or off |
Every clamp is the compiler’s, so a value accepted here is a value the material could have been authored with.
What costs a rebuild
Section titled “What costs a rebuild”Every field the schema describes is live. Some are not a retune, though: binding a texture channel, or moving a surface between passes with shader, decides which descriptor set and which pipeline the slot draws with, and only the loader can settle that. The verb takes them all the same way and the client sorts them out — it names the slots that have to be built again, builds them off the main thread and splices the finished ones in, so the map keeps drawing the old surface until the new one is ready. Everything else is rewritten in place on the next frame.
What a retune costs
Section titled “What a retune costs”A retune rebuilds the slot’s shading parameters from the authored material with the override laid over it — never from the last retune’s result — and rewrites the draws already recorded against that material in place. Water carries one more step: its 160-byte row in the water table is rewritten through the frame’s own command buffer, so the copy is ordered ahead of every read of the table without a device wait and without racing the frame still in flight. The CPU mirror the swimming and buoyancy code reads is the same fold, so a retuned current moves the player exactly as it moves the surface.
Overrides are session-scoped. Nothing here writes to your workspace, and nothing here changes a baked lightmap — an emissive retuned live lights the picture the way the baked atlas already says it does until the map is baked again.
Binding a material slot
Section titled “Binding a material slot”material.set retunes a material; material.bind swaps which material a slot wears. A <slot> is a key in the map’s materials block — the name the mesh’s sub-meshes and the authored boxes reference — and a slot the map does not author is refused with the slots it does. material.unbind releases one, and binding a slot to the material the map already authors for it clears the binding rather than recording a redundant one.
This is map-wide by construction. A slot is the material table’s key, so every sub-mesh and every box naming it changes together. It is the same edit the editor’s material rows make when you drop one material on another, and the editor submits exactly this line.
Bindings ride the same replication the retunes do — a second name-keyed override list beside them — so a client joining later sees the map as it currently stands. Each client hands the slot the material it already resolved at load: descriptor sets, detail rows and water rows are allocated during the map build, and this path has no upload context. A material no slot in that client’s map ever referenced is refused there, the slot keeps what it had, and the client says so once rather than every frame.
Unlike a retune, a binding is savable: the editor’s Save writes it into the map’s own materials table, rewriting the slot’s entry in place or adding one the table never carried.
Declaring a material that does not exist yet
Section titled “Declaring a material that does not exist yet”material.variant <barcode> <inherits> records a material no compiled pallet holds. The new barcode carries an inherits at an existing one plus whatever that base is currently retuned with, and from that moment it behaves like any other material: material.set tunes it, material.bind puts a slot on it, material.list names it, and the inspector and the thumbnails draw it. Each client synthesizes it from the base it already resolved at load — the same textures, the same descriptor set, the base’s shading parameters with the variant’s own patch laid over them — so nothing is uploaded and nothing is rebuilt.
It rides the same override list the retunes do, as an entry that names what it inherits, so a client joining afterward receives the declaration and the bind together. This is what the editor’s Make variant submits before it writes the child .dh-mat, which is why the minted material is usable immediately and the build only makes it durable. Resetting every field a variant states does not delete it; a variant that loses its declaration is an unknown barcode again.
material.new <barcode> is the same declaration with no base at all. There is nothing to borrow, so each client synthesizes the surface from the engine’s own default — plain lit, mid gray, no textures, and the registry’s neutral descriptor set — and lays the material’s own fields over it exactly as a variant’s are laid over its base. It is a separate verb rather than material.variant with an empty second argument, because a console line that has to spell an empty string to mean “nothing” is a line nobody can read. This is what the editor’s New ▸ Material submits the moment it writes the file, and it is why a just-made material is inspectable and bindable before any build.
The pivot gizmo
Section titled “The pivot gizmo”scene.select marks one node as the manipulation target. A client draws a pivot gizmo at that node’s manifest pivot whenever the level editor is up — or, during ordinary play, when client.scene.pivotGizmo asks for it. A selection outlives the session that made it, so play stays clean by default. The gizmo is three colored translate arms (red +X, green +Y, blue +Z) and three rotate rings, in the same visual vocabulary as the corner axis gizmo — dark halos under every stroke, depth as a shade rather than an opacity, drawn back to front.
Unlike the corner gizmo, this one is world-anchored: it is projected through the frame’s own view-projection, so it foreshortens, shrinks with distance, and bends with the Panini warp exactly as the geometry it annotates does. That is why its arm length is specified in meters, not pixels.
Selection is replicated, not client-local: it rides the same message the edits do, so everyone watching sees the same node called out. It is a pure view concept — selecting changes nothing about how a node renders or collides.
| Preference | Type | Default | What it controls |
|---|---|---|---|
client.scene.pivotGizmo | bool | false | Whether the selected node’s pivot gizmo draws during play. The editor draws it whatever this says. |
client.scene.pivotGizmoArm | float (meters) | 1 | Translate arm length. Tune it to the scale of the map you are authoring. |
client.scene.pivotGizmoThickness | float (pixels) | 2.5 | Arm stroke width. |
client.scene.pivotGizmoRings | bool | true | Whether the rotate rings draw alongside the arms. |
Persistence
Section titled “Persistence”Runtime edits are session-only until you ask for them to be kept. scene.save writes them to a per-map JSON document under the engine’s own user-data root — never your workspace. A saved delta is derived state about someone else’s map; keeping it out of the workspace means the workspace stays exactly what you put there, and a delta can be discarded at any time without touching a source file.
Only disabled, hidden and deleted persist. An enabled override is session-scoped by design (it un-does the map’s own authored baseline, which the map is free to change under the save), and an authored removal is the map’s to state, not the save’s.
Saving with nothing edited removes the file, so “save an empty edit set” and “forget” are the same gesture.
Positions are not here, and that is deliberate. scene.save and the level editor’s Save write to different places, and neither is a fallback for the other. This file is a delta beside somebody else’s map, kept without touching their source; the editor’s Save writes into a .dh-map you own, where everybody who loads the map gets it. A position only ever goes the second way — as a position for an authored object, and as a part override for a part, which the source otherwise never mentions. hidden and deleted only ever go the first way. On/off can go either way, because it is now sayable in both: here as a delta entry, and in the source as an enabled field or a part override. That is not two answers to one question — a delta is read over the map it is beside, so the authored value is the starting state and the delta is what a session did to it.
Resolving a saved entry
Section titled “Resolving a saved entry”Every entry carries the node’s object id (its objectId, the identity the identity sidecar keeps across rebuilds) and its path and name. On replay an entry applies to the node that carries its id; when no node does any more, because a rebuild minted a new one, it re-resolves by its saved path, then by its name. The object index never reaches the file: it is a session’s handle, and a rebuild that inserts a node renumbers it.
An entry that resolves to nothing (or ambiguously) costs one warning and is skipped. A mismatch is never an error and never aborts a map load — the same warn-and-skip semantics an authored override gets for a stale target. Refusing the whole save because one wall was renamed would break the feature exactly when a map is being iterated on, which is when it is used.
Promotion
Section titled “Promotion”scene.promote is the one-way step from “remembered by the engine” to “authored by the map”: it writes "removed": true overrides into the world instance’s children in a source .dh-map, each naming its part by local id and path, so a promoted removal is indistinguishable from a hand-written one and survives a recompile.
- Only deleted nodes promote.
deletedis a statement of intent, which is exactly whatremovedmeans.hiddenis a working state — something toggled to look behind — and an override has no field that means it.disabledis sayable in a source, as an override’senabled, but promotion is not how it gets there: the level editor’s Save writes it, from the live model, with the object in front of you. Nor is a move here to promote, for the same reason — the delta store has never held one. - The path is explicit. A compiled pallet does not record where its source lives, so the command will not guess: run it with no path to print the exact JSON it would add, and pass the
.dh-mappath when you want it written. - A commented source keeps its comments. The write goes through the same byte-preserving writer the editor’s Save uses, so the overrides are spliced in and every other byte of the file is carried through untouched.
Recompile the pallet after a promotion for the change to take effect.