Game UI authoring: User interface · layout · widgets. This page covers the editor host and how game UI composes with it.
Runtime UI (host summary)
Immediate-mode foundation in src/hikari/src/ui/. Games build through hi.ui() / world.ui; compositor + provider flatten to one atlas draw per frame. Separate palettes: runtime Theme.runtime() vs editor Theme.dark() / Theme.light() — never copy editor theme onto world.ui. Viewport remaps the game stream via ViewportUiLayer without changing the game theme.
Runtime and retained editor UI share lifecycle-neutral mechanics through ui/foundation/: geometry and affine transforms, routed input values, UTF-8 text edits, scalar range mapping/quantization, pointer/drag phases, stable-id selection, viewport math, virtualization windows, outside-press semantics, and popup placement. They deliberately keep separate storage, layout, paint, binding, transaction, and component layers.
Editor UI
Retained chrome in src/hikari/src/editor/ui/: stable generational widget IDs, measure/arrange/paint, the same compositor/provider contract as runtime UI. It is not a replaceable driver. Runtime and retained UI share lifecycle-neutral mechanics through ui/foundation/ (geometry, routed input, UTF-8 text edits, range mapping, pointer/drag phases, stable-id selection, virtualization windows, outside-press semantics, popup placement) and deliberately keep separate storage, layout, paint, binding, transaction, and component layers.
Tool-window presentation
Stateful first-party tool windows follow one model-view-presenter pattern (editor/presentation.zig):
- The model owns editable data and must be usable without a retained tree.
presentation.Surface/assertModelrejectRetainedUiandWidgetIdfields at compile time. - The view owns widget IDs and custom elements and exposes passive
push/pull; it never saves files, mutates the world, or decides policy. - The outer tool type is the presenter: it interprets view events, pulls edits into the model, invokes stores or editor services, and pushes state back.
presentation.Lifecyclesupplies shared open/close and initial-input guards.
Binding is explicit at the retained boundary (presentation.Text + TextField, ui/property_binding.zig for booleans, choices, scalars), so rebuilding or detaching a view cannot discard edits. First-party window policy (title, sizing, flags, surface kind, cardinality) is one keyed Spec entry in editor_app/tool_windows/spec.zig, compile-checked for coverage; live dialog instances belong to their EditorUiSurface, not to global EditorApp fields. Complex Asset Detail kinds (Material, Animation Graph, Particle) use the same state.model / state.view split and must register through editableController / editableControllerFor, which run presentation.assertPresenter. asset_detail/registry.zig is the single keyed table for controller routing and read-only/authoring policy, one row per asset Kind.
Retained toolkit
- Elements. Complex controls extend the tree through
retained.Element(measure, arrange, paint, hit-test, routed events, semantics) andretained.CustomLayoutfor child placement. Both participate in dirty propagation and paint caching; neither may open a second provider or input loop. Pointer events route capture → target → bubble with explicit capture. Affine transforms compose across ancestry for hit testing, clipped paint caches, and semantics. Portaled nodes register a logical owner so owner teardown removes the portal and restores focus. - Stacks and animation. Retained stacks expose flex grow/shrink, layout priority, and aspect policy; toolkit custom layouts provide grid/table/canvas/overlay tracks.
RetainedUi.animateowns interruptible style/layout channels with reduce-motion substitution. - Inspection.
RetainedUi.diagnostics(),visitDebugNodes(),inspectorOverlay(),visitSemantics()are allocation-free. The Debug Tool Window shows live tree identity, bounds, dirty state, last invalidation cause, paint cache misses, focus/hot state, and the latest route; Show hidden includes hidden subtrees. Stable automation ids resolve through the semantic tree into the same dispatcher native input uses. - Capacity. Tree topology owns traversal capacity: structural mutation may grow slots and caches, but stable input, hit testing, layout, repaint, and composition frames do not allocate. The retained test suite enforces this with an allocator that rejects everything after cold layout.
- Accessibility. The tree is published per native window after layout (NSAccessibility on macOS, UI Automation on Windows). Native callbacks enter bounded per-window action queues drained on the next frame. Publishing is gated on a client being attached (
hikariAccessibilityWanted) and onRetainedUi.semantics_dirty. - Text entry.
NSTextInputClientand IMM32 translate native UTF-16 ranges into UTF-8 byte ranges and preserve marked text; runtime and retained fields apply the sametext_editcomposition replacement, with retained provisional updates collapsing into one undo step on commit. - Compile splash. One-shot retained chrome, constructed only when startup has a game build pending and destroyed after the exit animation; later recompiles use toolbar/toast feedback.
- Change detection is a revision, not a re-hash. Consumers (viewport remap skip, chrome underlay compose, world static-publish skip) ask the provider for a
FrameRevision(owner+ monotonicrevision) viaProvider.frameChangeToken; layers that rewrite output (ViewportUiLayer,ViewportOverlay,RetainedUi) bump a local generation. An immediateUiContextfingerprints once per frame and memoes.
Graph canvases and node editors
The animation graph and material graph share one domain-neutral graph element that paints into the retained cache and consumes routed pointer events. Cards carry a category chip, pin names beside sockets, and an optional value footer; the domain supplies glyphs through graph.GlyphFn and sizes cards through graph.metrics.naturalHeight. Text scales with zoom and greeks below metrics.readable_scale. Edge arrowheads are drawn only for .state edges.
Canvas navigation is one policy (foundation/gesture.zig: canvasIntent): pinch zooms; ⌘/Ctrl+scroll zooms; a precise (trackpad) sample pans on both axes while a detented wheel zooms; Space+primary-drag and secondary-drag pan. Because secondary drag pans, node search opens on a stationary secondary release judged against ui.click's tap slop. MouseFrame carries wheel_x and pinch beside wheel_y for this. The element provides stable-id multi-selection, marquee, manipulation sessions, alignment guides, group drag, and one undo scope per gesture; the timeline ruler, dope sheet, and particle curve/gradient canvases use the same seam.
Asset browser
The browser is one virtual-grid leaf that paints only visible cells, with thumbnail-tile and compact-list layouts behind one interaction path (choice persisted per project). Cards are quiet at rest: only hover, press, and selection paint. The animation graph's clip library reuses the same leaf.
The browser edits the project. Right-click menus come from one table (asset_browser/actions.zig: New…, New Folder…, Import…, Rename, Duplicate, Delete); drag moves; F2 / forward-delete act on the highlighted tile. The browser emits a Request; editor_app/asset_flow/manage.zig gathers what is missing and performs the work through editor/asset_ops.zig, which the MCP asset_move tool also uses. A rename or move rewrites every reference in the project from the slot table in editor/asset_refs.zig, where slots are classified by key and kind (a generated companion material shares its model's stem). The open document is repointed in memory slot by slot (SceneDocument.rewriteAssetRefs, non-undoable): materials, cues, graphs, particles, and decal maps update live through component_field; geometry, collision, and prefab links mark the deferred whole-scene rebuild the cooker's events use, so the viewport rebuilds once after the moved asset has cooked under its new name. Delete counts and names referencing documents first and offers a same-kind replacement. Import is the native file dialog filtered by the source_extensions table plus a destination/name wizard.
Material detail
Material detail distinguishes documents by payload: a graph.kind: com.hikari.material-graph payload opens the graph surface (toolbox drawer, canvas right-click search, Class 2 preview on a unit plane); a plain .material.json is a hand-authored Akari material, previewed and deliberately not converted; a document that claims a graph and will not load says so. Node kinds are one comptime table (editor/material_graph/types.zig) shared with Shinra through nodes.schema.json; sampler nodes are one per material texture slot and a second is refused with a reason. The node drawer is a searchField over chrome.sections in material_graph.types.category_order; a live filter forces sections open without touching the author's collapse state.
The window is split as kinds/material/{types,state,chrome,interaction,preview,edits} and shares the animation graph's undo model: whole-document snapshots on editor/history.zig, coalesced per gesture, published as undo.Target. Graph edits debounce to the source document; Shinra rebuild events refresh the preview only when the semantic revision moved, so arranging nodes does not rebuild the plane. A bottom diagnostics console on the shared components/console.zig grammar carries validation rows (recomputed from the document as the semantic revision moves, clickable to select and frame the node) and pipeline rows from asset_pipeline_daemon.diagnostics() (where an Akari diagnostic with a line number lives), tagged by producer because a Graph row changes on the next keystroke and a Shader row on the next cook. This is not editor_app/console_ui.zig, the global log panel. A Custom Expression node inlines an Akari expression (inputs a, b, uv, time); the expression-only rule is stated in material_graph/expression.zig (editor feedback) and Shinra's expression.rs (authority). MaterialGraphVertex carries worldPos because the tangent frame derives from its derivatives and Fresnel needs it for the view vector, which is parity with the standard gbuffer varying.
Detail-window bands are sized, not measured: the animation graph's inspector band is a fixed-height strip whose frames hold still while contents swap, so a selection change never moves the canvas.
Visual language
shadcn-derived and deliberately quiet: a neutral zinc elevation ramp (canvas → surface_sunken → surface → surface_raised), one hairline border token for control outlines and dock seams, a 4/6/8 corner-radius ramp, flat fills. Colour is rationed: core blue means CTA, selection, or focus and nothing else; photon rose is the rare brand flash.
editor/ui/components/surfaces.zig holds the named recipes (card, well, popup / popupClosed, modalCard, scrim, row, chip, pill, accentPlate, notice, hud). Ask for a shape by name; do not hand-write fill + outline + radius in a panel. Each recipe takes a Fit patch for the parts the caller owns (size, padding, interactivity).
const card = try retained.panel(parent, surfaces.card(retained, .{ .min_height = 52 }));
const input = try retained.textField(row, .placeholder, surfaces.well(retained, .{ .focusable = true }));Rules when adding chrome:
- A control never outlines inside another control's outline. One hairline around a set, none on its members (
toolGroupoutlined,groupedToolButtonnot; segmented track outlined, selected segment not). Containers may hold bordered inputs. Pinned bycomponents/tests.zig. - Outline or fill, not both.
normalbuttons, dropdown triggers, inputs, and cards take the hairline; filled variants (accent,toggled,destructive, transport) do not. accentis for exclusive modes and primary actions only. An independent on/off switch usesToolButtonVariant.toggled(accent_mutedfill, accent ink).- Menu and list rows are borderless. The popup owns the outline; rows show state through hover tint and an
accent_mutedwash. - Tabs are handles, not pills.
tabStripis a recessedcanvasrail with no bottom seam; the seated handle carries the pane's ownsurfacefill so tab and content meet with nothing between them, with the accent→brand sweep on its top edge as the seated marker. - Radii come from tokens (
radius_sm/md/lg= 4/6/8,surfaces.modal_radius= 12, 999 for pills).WidgetStyle.corner_radiioverrides corners independently and every layer follows it. - Inputs sit in
surface_sunken, notcanvas. - Focus is the web
:focus-visibleshape: a translucent 3pt ring plus a full-strength hairline on the control edge.
Bottom console and notifications
The editor console (editor/editor_app/diagnostics.zig) is a UI sink on log.register; log.zig still tees to stderr and, with a project open, to <project>/.engine/logs/editor-latest.json. The sink's write path is allocation-free by contract (rows bump into budgets reserved at init), because a log record can originate from inside an allocator's own lock. Rows are narrowed by one predicate (diagnostics.Filter: exclusive severity segmented control plus a substring search), shared by counting and row lookup.
The console records everything and surfaces nothing, so editor/ui/toasts.zig is the noticing half: a bounded stack of cards over the bottom-right corner. Never push a toast directly; call sites go through editor/editor_app/notify.zig (err / warn / info / success), which writes the console record and the card from one call (<title>: <detail> under scope editor). Toasting every .err log record is deliberately not done. Guarantees: four cards built once at startup with inline text buffers (no allocator on a failure path); the fifth arrival retires the oldest and identical consecutive messages collapse into one card with a count; severity is shape first, colour second, with lifetimes 11 s / 7 s / 4 s; the host is hidden when empty (so it cannot swallow viewport navigation via sceneInputBlocked); a card may carry one bounded action (screenshot completion reveals the file in Finder/Explorer); it is the last root child so it paints above modals.
Reusable components
editor/ui/components.zig owns the shared chrome: toolbar, menu bar, tab strip, dock panel, tool group/button, separator, status pill, checkbox, segmented control, dropdown, progress, color field, context menu, components.value_rows (labeled scalar / XYZ / min-max rows binding caller-owned floats, the grammar of every typed inspector pane), plus components.surfaces. Retained also exposes tooltip, beginDrag / acceptDrop, scrollbar thumbs, and label wrap_text. Alignment enums: retained Align = ui.CrossAlign, Distribution = ui.Justify.
- Search is one component (
components/search_field.zig): well, magnifier, field, clear button. Callers keep the query in their own model;syncClearis told the query andclearPressedreports the press. The command palette deliberately does not use it (its card is the input). - Playback is one component (
components/transport.zig): play/pause plus opt-in stop / restart / loop, timecode, and scrub timeline, as a view over the caller's clock (updateTransport(State) → Action). "Nothing to show" ischrome.emptyState. - The scrub timeline is one element (
components/timeline.zig): ruler, rail, playhead, and knob from oneMetrics.xwith the pointer read back throughratioAt; press to jump, drag to scrub,Esccancels,←/→step,Home/End. Time arrives as aScalarBindingand a drag is one transaction. - Color field opens a scrollable picker (saturation/value plane, hue strip, opacity strip) with RGB float, RGB 255, HSV, HSL, and hex views of one bound colour; float RGB and HSV preserve HDR range, the others clamp to SDR. Numeric inputs forward begin/write/commit/cancel to the original bindings so a drag stays one undo step. Done or an outside press accepts; Revert or Escape restores.
- Dock tab strips keep intrinsic widths and pan horizontally when narrow (retained
scroll_x). Tabs drag between the three docks and reorder within a strip (toolkit/docking.DragSession,RetainedUi.reparent,.engine/user/docks.json). A tab dragged clear of every dock detaches into a Class 1 tool window (editor_app/panel_float.zig) with a dock-back control; detach rebuilds the panel through its registration (Registry.unmount→chrome.buildPanel) because widget ids do not crossRetainedUitrees. Builtin panel host wiring is one row each ineditor_app/panel_host.zig. The editor never splits new docks.
Hierarchy, toolbar, and inspector
- Hierarchy search: bare terms match name/id/archetype with AND;
t:/type:,n:/name:,id:, andis:(runtime,authored,broken,locked,root,child) scope; quotes group, a leading-excludes. A live filter reveals matches and ancestors without overwriting collapse state. - Hierarchy menus: right-click Focus / Add Child… / Rename / Copy / Paste as Child / Duplicate / Delete. + Create is a scrollable popup: Create (Empty / Light / Camera / Reflection Probe / Visual Zone / Decal / Audio Source / Script), Place… (Model / Texture / Decal / Audio Clip / Script / Material), then Game (project archetypes). Soft
_assetis not listed. - Decals: Create Decal authors a thin projector (
scale {1, 1, 0.25}) with a volume gizmo (GizmoMask.decals) that turns red with No surface in volume; dropping a texture onto geometry places a surface-aligned decal. - Toolbar: transform tools plus Local/World (
X) and Snap (⇧S). Edit menu: Copy / Paste / Paste as Child (⌘C/⌘V/⇧⌘V) on an editor-owned clipboard; Paste places at the camera look point. Multi-select rotates/scales about the primary pivot and fans field writes to peers with the component. Inspector Add Component / section remove attach or clear authored blocks with despawn+spawn live sync. - Hierarchy rows have two single seams: content is resolved by
resolveDocActor(document) orresolveLiveEntity(live world), and colours are decided once inrows.applyRowVisuals, called by both the bind pass and the selection pass. A state added to only one resolver renders on nobody; a second hand-writtensetStylesilently reverts the first. - Hierarchy keyboard: the virtualized list owns focus; ↑/↓ move and select, → expands, ← collapses, Escape releases. Disabled in Play via keyboard ownership.
Settings windows
Project Settings uses the schema's Apply value as presentation: every non-live row carries a timing note (Restart editor to apply, Next game launch · restart editor for Play, Applies on next package), the footer reports the strongest pending boundary, and a non-live Save repeats it in a toast. Rendering and Quality are entirely live on Save through the renderer's frame-boundary config transaction. Gameplay Tags is a page here (see Scenes and gameplay); external tag writes are blocked while the window is open. Both settings dialogs share rail/page construction and search through editor/ui/settings_dialog.zig / settings_search.zig.
Editor Settings (File → Editor Settings…) opens as a Class 1 OS tool window (editor/editor_app/tool_windows.zig) with pages Appearance / Accessibility / Scrolling / MCP / MCP Audit: theme (Auto / Dark / Light, resolved through platform/system_theme.zig and repainted live on OS appearance change), density, interface/text size (ui.ScaleLevel), viewport idle encode (Realtime default / Efficient, see Rendering), high contrast, always-show focus, reduce motion, scroll speed/bounce/overscroll, fade inactive scrollbars. Prefs live in .engine/user/prefs.json (user_prefs.Data / ui.AccessibilityPrefs) and apply live to retained chrome and the game world.ui stream. MCP pages are project-scoped (Agent MCP).
Environment panel
The left-dock Environment panel reuses the shared transport and timeline for a transient 24-hour viewport clock. Nothing in it is a gate: a scrub, a Sunrise / Noon / Sunset / Midnight jump, or Play arms the override, and the toggle releases the viewport back to the scene's clock. It dispatches only to scene components implementing onEnvironmentPreview, never mutates the document, and is restored before a Play copy begins. Authoring workflow: Time of day.
Asset Detail and preview viewports
Asset Detail (double-click a non-scene/prefab asset) opens multi-instance Class 2 tool windows (editor/asset_detail.zig, up to max_preview_surfaces = 6) attached as scene_composite. The shell (asset_detail/window.zig) owns hosts only; each asset type is a module under asset_detail/kinds/ implementing the kind.zig vtable (bind/update/unbind). The shell's compact Environment / Rendering controls are the default for a Class 2 preview; a kind that declares VTable.buildSettings fills that block itself. Every kind gets the window-level dropdown overlay through Hosts.overlay. Same asset path raises/rebinds; different assets open concurrently up to the cap. Quality gates: preview-viewport-quality-gates.
Every kind mounts one asset_detail/preview_frame.zig: Frame: optional toolbar (tools, elastic status, actions), a stage, stage-floating badges (surfaces.hud), and an interaction hint. StageFill.plate is an inset canvas for 2D pixels and empty states; .scene is a full-bleed hole for the Class 2 render. A 3D kind returns Frame.scenePlate() from VTable.viewportPlate, and the shell drives orbit/pan/dolly on it and publishes its rect as the Class 2 pane (host.setPreviewPane), so the subject centres in what the user sees as the viewport.
Per kind: 2D textures get Fit / 1:1 / zoom, an RGB·R·G·B·A channel control (alpha is a shader sample mode, ImageSample.alpha_luminance), and a Dark/Light backdrop. Audio gets a waveform plate and the transport with a playhead from the mixer's own cursor (no scrub; the mixer has no seek). Environment cubes get a full-bleed sky viewer (kinds/cubemap.zig) with face-snap verbs, auto-rotate, and a kind-owned inspector (exposure, tonemap, FOV); wheel zooms the lens. Cubemaps, models, and particles bind a per-window PreviewHost (editor/preview_viewport/: GPU sky + mesh path, orbit/pan/dolly, ground grid, surface-local RenderPipelineConfig, never the product session pipeline). Models bind one primitive per cooked part; skinned models raise the bottom band for the clip list and scrub bar. Particle previews retain their sprite atlases and request full-resolution residency; texture readiness is polled independently of playback.
Input ownership
- Scene vs chrome: retained chrome hit-tests first each frame.
RetainedUi.sceneInputBlockedis true for any chrome hit not under ascene_passthroughnode (only the viewport spacer sets it) plus active chrome presses/drags, so menus, docks, and popups block fly-camera wheel/look, gizmos, and underlay pointer frames. - Captured pointer: under
PointerMode.capturedthe OS cursor is hidden andMouseFrame.x/yis a virtual point integrated from relative motion.InputSystem.syncFramepublishesInputState.pointer_capturedand retained chrome goes inert on it. Never clamp the virtual point: it is the relative-motion accumulator, so pinning it at any edge zeroes the delta there and mouse look stops dead. Bound what hit-tests with the pointer, never the pointer itself. - Keyboard focus: a press takes focus only on a focusable node; pressing any other interactive node, empty space, or Escape releases it.
RetainedUi.accepts_keyboardis the host routing flag: Play callssetAcceptsKeyboard(false)(blur, block Tab cycle and focus acquisition), Edit restores it. One exception survives Play: a text field the author clicks takes focus and keys anyway (a search or filter box is an explicit request to type), and for every frame such a field holds focus — the click that focused it and the Escape that leaves it included — the retained UI reportsProvider.claimsKeyboard(), which the session uses to hand the game a keyboard-zeroed input frame. Buttons and Tab still cannot take focus during Play. Host shortcuts skip only whenfocusCapturesText().
Mode chrome (Play, prefab)
Two modes change what controls mean without changing how they look, so both state it in the chrome. Play tints the toolbar mode pill with the brand glow (setStatusPillMode) and swaps the transport to a red Stop. Prefab (editor_app/prefab_chrome.zig) puts an amber frame inside the viewport, a band naming the template and the scene it returns to, and re-labels Save to Save Prefab; rules in prefabs. A band that appears is layout: showing it moves scene_spacer and syncViewportInsets recreates the scene-resolution render targets, which is fine once per open/close and not per frame.
Contributions (host chrome)
Two paths extend editor chrome. Do not mix them:
| Path | Who | Seam |
|---|---|---|
| Host builtins | Editor product code | src/hikari/src/editor/contrib.zig + bindFill (retained widget trees) |
| Game / plugins | Project + composed packages | C/POD hi.editor.Api only — never contrib.zig or retained fills directly |
contrib.zig is the host-side fixed-capacity Registry for dock panels and menu bar entries. Builtins register here and bind retained content with bindFill. Game modules and source-composed plugins register through the bridge (editor/game_contrib.zig), which maps hi.editor declarations into the same registry and host-owned shells. Plugins do not call contrib.zig or bindFill; see Game editor SDK and Plugin system design — freezeChrome.
| Host API | Effect |
|---|---|
registerPanel | Tab + page in slot .left / .right / .bottom |
bindFill(id, ctx, fillFn) | Build tools/body/footer content when chrome creates the page (host-only) |
registerMenuGroup / registerMenuEntry | Menu bar groups and leaves (EditorAction) |
assembleMenus | Fixed MenuAssembly → menuBar |
Lifecycle (create-time only; freeze after chrome build):
registerBuiltins(host panels/menus)- Game/plugin
registerEditorviaapplyFromGame(POD declarations → registry) bindFillfor every host panel that owns UI —panel_fills.zig: hierarchy tools, console, inspector, profiler, residency, physics, audio, assetsassembleMenus+chrome.build(calls fills; no id special-cases)- Host may still own long-lived view structs filled during step 3 (e.g. hierarchy tree after body parent exists)
Panel ids (builtins): hierarchy, inspector, profiler, residency, physics, audio, console, assets. Dock runtime: EditorApp.workspace.left / .right / .bottom (chrome.DockHost).
Add a host panel: registerPanel → bindFill (optional) → optional menu entry, plus a row in editor_app/panel_host.zig so detach/dock-back can remount it. Do not add parallel tab strips or menu trees.
Menus (host): leaves use EditorAction / handleActionFrom. Custom open-string commands from the game go through game_contrib.invokeCommand (shell), not a second action enum.
Game editor SDK (hi.editor)
Optional create-time surface so a game module (project root and/or source-composed plugin editor modules) can extend editor chrome without linking retained editor UI into standalone game products.
Same C/POD ABI for project and plugins: actions, menus, toolbar items, dock panels, simple host-owned surface controls, and modal alerts. The foundation does not expose retained chrome ownership or raw editor internals; see Plugins and Plugin system design — freezeChrome.
Exposure (what games see)
| Symbol | Package path | When present |
|---|---|---|
hi.editor_contrib_enabled | sdk/src/hikari_game.zig | Always a bool from hikari_build_options.editor_contrib |
hi.editor | same | Full editor_contrib.zig when enabled; empty struct {} when off |
hi.editor.Api | sdk/src/editor_contrib.zig | C-ABI registration table (valid only during registerEditor) |
GameModule.register_editor | sdk/src/game_module_def.zig | Optional pointer; null if flag off or game has no real registerEditor |
editor_contrib.api_version_current is 4 (sdk/src/editor_contrib.zig). It is independent of GameModule.abi_version (abi_version_current in sdk/src/game_module_def.zig). Changing Api layout or semantics → bump editor_contrib.api_version_current and the host bridge together; adding/removing the GameModule field or handshake semantics → bump abi_version_current.
Call api.check() first in registerEditor (also enforced once in defineGameModule before plugins). It requires api_version ≥ 4, installs generation-scoped SceneApi entry pointers for hi.editor.scene(), and clears generation on refusal. Table fields that post-date the first contrib revision are still nullable POD slots — reach them through checked accessors (registerAction, registerToolbarItem, beginSurface, addControl, endSurface, setControlText, …) so a missed version check fails cleanly instead of jumping into an undefined pointer.
Api capabilities
Registration-only (inside registerEditor; structure freezes afterward):
| Entry / accessor | Purpose |
|---|---|
register_panel | Dock tab in .left / .right / .bottom (PanelDesc: id, title, slot, icon, order, default_selected, has_tools / has_footer, padded_scroll) |
register_menu_group | Top-level menu group (MenuGroupDesc: id, label, order) |
register_menu_entry | Leaf under a group (MenuEntryDesc: group, command/action id, label, order, sep, icon) |
register_action / registerAction | Unified action (ActionDesc: id, label, icon, tooltip, ActionInvokeFn invoke with host-filled ActionContext, optional query_enabled / query_checked) — one id space for menus, toolbar, surface buttons |
register_toolbar_item / registerToolbarItem | Toolbar leaf (ToolbarItemDesc: group, action id, order, sep) |
register_panel_update | Tick callback while that panel tab is selected |
begin_surface / beginSurface | Open a host-owned control list for a surface id (typically a panel id; host binds fill when ids match) |
add_control / addControl | Append a control (ControlDesc: id, ControlKind, text, optional action for button/toggle); returns ControlHandle |
end_surface / endSurface | Close the open surface |
ControlKind: label, button, separator, toggle, row_begin / row_end, column_begin / column_end.
There is no register_command / CommandFn path — product surface is registerAction only (ActionInvokeFn(user, *const ActionContext)).
Post-registration (copy api.* or the needed pointers + ctx during registration; UI-thread only — action / panel-update callbacks):
| Entry / accessor | Purpose |
|---|---|
show_message | Host modal alert (title + body); wired after chrome/dialogs exist |
set_control_text / setControlText | Update host-owned control text by handle |
set_control_enabled / setControlEnabled | Enable/disable a control |
set_control_checked / setControlChecked | Toggle checked state |
hi.editor.scene() / SceneApi | Buffered scene authoring (EditTransaction → host applyBatch with label); see Plugin API design §3.1 |
Icons are a fixed IconKind enum mapped by the host into the editor catalog (no free SVG bytes across the ABI). Builtin host menu groups games may attach to: file, edit, view, help (ids match host contrib builtins). Toolbar group ids: known host groups are placed by the host; unknown groups append.
Command / action ids:
- Known engine names (
save,undo,gizmo_translate, …) map to hostEditorAction(seegame_contrib.engineAction). - Any other id is custom; register with
registerActionbefore or with the menu/toolbar/surface entry that references it.
Panel bodies: host still owns chrome shells. For game/plugin tabs, prefer begin_surface / add_control / end_surface to declare simple host-owned controls (labels, buttons, toggles, layout markers). That implements the design’s SurfaceBuilder model — a POD command recorder, not retained-widget ownership. Use register_panel_update and set_control_* for refresh; show_message for modal feedback. Full retained trees and bindFill remain host-only. Detach/dock-back remounts a plugin tab through that same registered fill; the bridge registers an unmount hook that drops its controls' widget ids first, and set_control_* plus surface clicks route at the live tree (EditorApp.panelUi).
Not exposed (by design, today):
- Retained widget construction /
bindFillfrom the game module - Free SVG icons across the ABI
- Raw
SceneDocument*,EditorApp*,World*,Entity, renderer, or native window handles (scene mutation goes throughSceneApi/EditTransactiononly)
Isolation (must not leak into non-editor builds)
Editor-only code must never compile into standalone kaji game / pure game modules. Layers:
| Layer | Mechanism |
|---|---|
| SDK option | sdk/build.zig: -Deditor-contrib defaults false; editor_contrib.zig is only imported when true |
| Barrel | hi.editor is empty when editor_contrib_enabled == false |
| Game module | defineGameModule sets register_editor only if flag and a real registration path exists |
| Editor recompile | editor/project.zig always passes -Deditor-contrib=true for game-module builds under the editor |
| Host product | Editor host always has contrib types (build.zig host options); still only calls game registration when non-null |
| Sample / project / plugin code | Keep extensions in editor-gated units (editor plugin modules, or a project file imported only when hi.editor_contrib_enabled) so the false branch never analyzes that code |
Do not use hi.editor from always-compiled game units (session.zig, entities, …). Do not store the temporary *const Api past registerEditor — copy the table (or needed fn pointers + ctx) for post-registration calls (show_message, set_control_*). Those stay valid for the game-module generation and are editor UI-thread only.
Host wiring
| Piece | Path | Role |
|---|---|---|
| Registry | editor/contrib.zig | Fixed-capacity panels + menus (host builtins; game/plugin via bridge) |
| Bridge | editor/game_contrib.zig | Maps Api → registry + actions / toolbar / surfaces / panel-update tables; host string intern pool; setAlert for modals |
| Create order | editor/editor_app/app.zig | builtins → applyFromGame → assembleMenus → freezeChrome → chrome.build (host fills) → later setAlert |
| Game recompile | editor/editor_app/game_recompile.zig | rebindFromGame — clears invoke/update hooks, re-runs registration (new dylib fn pointers); structural menus/panels/toolbars/surfaces stay |
| Menu / toolbar click | editor/editor_app/shell.zig | EditorAction → handleActionFrom; custom id → game_contrib.invokeCommand (warns if missing) |
| Panel tick | editor/editor_app/tick.zig | game_contrib.tickSelected when update_count != 0 |
| Alerts | editor/dialogs/alert_dialog.zig | showText free-string modal used by game show_message; showPrompt relabels the confirm button (Action) for the version warning and the scene-changed-on-disk reload |
Create vs reload: panel / menu / toolbar / surface structure is create-time (frozen after assembly). Ids/labels are host-interned so they never dangle after dylib unload. Command/action and panel-update handlers rebind on every game-module reload. Adding a new structural id after freeze requires reopening the project (register returns failure + warn). No per-frame registration.
Samples
Primary full demo (actions, menu, toolbar, panel, surface controls, show_message, setControlText):
Project glue for plugin editor registration:
src/games/example/src/root.zig—registerEditorPluginswhenhi.editor_contrib_enabled
Verify in editor with the example project: View → Example Capability, toolbar plugins group, and the Example Capability right dock panel. Standalone kaji game must not compose editor plugin modules. Put editor-only code in an editor plugin module (or a project file gated on hi.editor_contrib_enabled); call api.check() first; copy api.* for post-registration calls.
What not to do
- Depend on
hi.editorfrom always-on game code. - Force
-Deditor-contrib=trueon standalone game product builds. - Build a parallel command-id enum for chrome actions the host already owns — map known names or use custom open strings.
- Call
contrib.zig/bindFillor keep retained widget pointers from game/plugin code — usehi.editorsurface controls only. - Assume free SVG icons, document mutation APIs, or raw
EditorApp/Worldhandles across the ABI. - Treat this as a general plugin host (no hot-swap of structural contribs without reloading / reopening the project).
Agent MCP (per-project)
The editor can expose a local Model Context Protocol server so AI agents operate the same authoring surface as chrome (document SSOT, history, Play gates). Off by default.
| Setting | Location |
|---|---|
| Enable | Editor Settings → MCP → Enabled |
| State | <project>/.engine/user/mcp.json (durable enabled/host/port; survives --clean); Bearer token in OS secure storage (platform/secure_storage.zig) |
| Client config | Pretty-printed JSON on the MCP page (includes Bearer token); Copy configuration writes it to the OS clipboard (platform/clipboard.zig) |
| Rotate secret | Regenerate MCP token (immediate secure-store write; enable/disable does not rotate) |
Transport: native MCP Streamable HTTP at loopback /mcp; every POST requires Authorization: Bearer <token>. Implementation: src/hikari/src/editor/agent/. Design: docs/design/editor-mcp.md.
MCP asset authoring covers animation graphs, graph materials (surface and volume), and particle systems through one ETag-guarded JSON Patch plane. Native editor validation runs before the atomic source write; dirty open detail windows conflict instead of being overwritten, and clean ones refresh on the next UI tick. The adapter registry is the extension seam for future JSON asset editors. Asset creation/import/move/duplicate/delete use the shared editor lifecycle service and recipe registry rather than MCP-specific file operations. Model, texture, and audio import settings reuse the inspector's Shinra schema and sidecar IO; source move/duplicate/delete keeps that sidecar paired with the asset. Project configuration, plugin enablement/settings, gameplay tags, and editor preferences likewise adapt their existing schemas and stores; stale writes require a fresh SHA-256 ETag and open dirty surfaces conflict.
Scene/document lifecycle is also semantic rather than action-emulated:
scene_open, scene_create_and_open, scene_save_as, prefab_open, and
document_close route through editor_app/document_lifecycle.zig. Dirty
transitions reject by default; explicit save is supported, while discard needs
matching document id/revision preconditions (including a dirty stacked parent
when leaving prefab isolation). The MCP adapter remains contained under
editor/agent/.
viewport_capture requests the renderer's actual asynchronous final-viewport
readback; viewport_capture_status returns the resulting HDR EXR or SDR BMP
path under .engine/screenshots/. Chrome and MCP share a coordinator around the single
renderer completion mailbox. Chrome presents the saved filename as an actionable
toast; clicking it reveals the selected file in Finder/Explorer.
For repeatable compositions, viewport_camera_get, viewport_camera_set, and
viewport_camera_frame expose the main edit-mode fly camera without turning it
into MCP-owned state. The shared viewport controller remains authoritative for
pose validation and matrix publication; the agent adapter only translates JSON
and wakes an Efficient-mode scene encode before a subsequent capture.
Actions, shortcuts, and the menu bar
Editor “commands” for chrome are not a separate action-id registry and are not history.Command (document undo). They are EditorAction values in src/hikari/src/editor/shortcuts.zig. Menus, keyboard shortcuts, tool buttons, the command palette (⌘/Ctrl+P — editor/command_palette.zig), and automation dispatch the same enum through authoring.handleActionFrom and the scoped commands.Router. The palette also ranks project assets and opens a hit through the same browser selection path.
ShortcutRegistry.poll ──┐
MenuBar leaf click ──┼──► EditorAction ──► scoped command route ──► executor
Tool chrome (save, …) ──┤
Command palette ──┤
Automation / MCP ──┘
▲
editor_state.mode_changed ────┴──► ActionContext ──► menu enablement
selection / history (polled each shell tick)| Piece | Location | Role |
|---|---|---|
EditorAction | editor/shortcuts.zig | Stable action identity (save, undo, gizmo modes, menu stubs, …) |
actionInfo / ActionContext | same | Labels, optional SVG icons, shortcut glyphs, gates (edit_only, needs selection / undo / redo) |
ShortcutRegistry | same | Key chords → EditorAction; installDefaults; chordFor for display |
commands.Router | editor/commands.zig | Preview/target/bubble route over retained scopes; stale generational scopes cannot receive callbacks |
authoring.handleActionFrom | editor/editor_app/authoring.zig | Play/edit gate plus sole source-aware route entry |
authoring.executeAction | same | Root-scope production fallback; executes without recursively routing |
authoring.processShortcuts | same | Polls registry from the key window keyboard; authoring chords are primary-only / edit-only / not-while-typing; close_window (⌘/Ctrl+W) always applies to the key window |
| Menu bar | editor/ui/components/menu_bar.zig | Retained top strip + cascading popups (not OS menu APIs) |
history.Command | editor/history.zig | Invertible document mutations only — different concept |
Close Window (⌘/Ctrl+W). EditorAction.close_window posts the same cancelable WindowCommand.close as the caption close control: a tool key window retires (GPU detach → native destroy) without quitting; the primary key window continues the existing process-quit path (macOS windowWillClose / Windows primary PostQuitMessage). Bare W remains gizmo Translate. Works in Play and while a text field has focus.
Menu bar. Built in editor/editor_app/chrome.zig above the toolbar. Popups parent to the retained root so they float over docks without reflow. Static trees use MenuSpec (label, optional SVG icon, optional EditorAction leaf, nested children, separator). Icons are the same outline SVG model as the rest of chrome (ui.icons.* or a custom ui.Icon); leaves fall back to actionInfo(action).icon when MenuSpec.icon is null. Rows reserve a fixed leading icon column so labels align when only some items have icons. Host menus (contrib.registerBuiltins): File (New Scene / Open Scene / Close Prefab / Save / Save As / Project Settings / Editor Settings / Close Project / Close Window / Quit), Edit (Undo/Redo/Copy/Paste/Paste as Child/Duplicate/Delete/Rename/Focus), View (Command Palette / gizmo modes + space/snap / Debug Tool Window), Help (About). New Scene / Save As use name_prompt.zig; Open Scene uses the asset picker (.scene). Interaction: click top title to open, hover another title while open to switch, hover/click submenu rows for cascades, Esc or outside click to close. shell.process calls updateMenuBar then routes the activated action through handleActionFrom.
Diagnostic input ownership. Viewport visualizers and temporal diagnostic controls are toolbar-button-only in the editor, with no default shortcuts, menu entries, or command-palette entries. Engine/native hosts never reserve diagnostic keys (including VSync toggles), and shared overlays display no hardcoded input hints. Games may explicitly author their own diagnostic actions and bindings; the engine supplies no fallback keys.
Enablement. ActionContext is built from play mode, selection primary, and document history (undoLabel / redoLabel). syncMenuBarEnabled runs from authoring.syncActionChrome each shell tick and on editor_state mode_changed (via game_recompile.syncStateControls, same path as recompile-button enablement). Do not invent a second enablement bus for menus.
What not to do
- Do not add a parallel command-id enum for menus or the command palette — extend
EditorActionandactionInfo(palette catalog iscommand_palette/commands.zig). - Do not call OS menu bars (NSMenu / Win32) for editor chrome; keep platform frontends thin and chrome platform-neutral.
- Do not route UI actions through
history.Command; that type is only for undoable document edits. - Do not fork Play/Edit policy — mode lives on
SessionCore, mirrored byeditor_statethroughenterPlay/enterEdit.
Editor documents and property editing
SceneDocument owns authored scene data, stable string actor IDs, deterministic serialization, lookup, history, and atomic replacement. The runtime world is a live preview, not the editor's source document.
Document entry (shared mutation plane)
Chrome, plugins, and MCP must enter authoring only through editor/authoring/scene_mutation.zig (apply / applyBatch / applyJoin / txn helpers). That plane is the product SSOT: one op vocabulary, max_operations, validation-on-clone, and a single undo label per batch.
| Front door | Path into the plane |
|---|---|
| Host chrome (hierarchy, inspector, gizmos, shortcuts) | editor_app/authoring/*, property_binding → scene_mutation |
| Plugin SceneApi | hi.editor.scene() / EditTransaction → host bridge → applyBatch |
| MCP | agent/scene_edit.zig (wire) + agent/plane.zig → scene_mutation |
Under the plane, editor.Document history still records ActorMutation values (scene/scene_actor_mutation.zig) as the live/history payload. SceneEditAccess applies those mutations to the live world so preview and document stay in sync; invertibility drives undo/redo. Component fields go through ActorMutation.component_field / ComponentFieldEdit — never poke live entities directly.
Property controls bind stable actor IDs and property paths through editor/property_binding.zig. Scalar, boolean, and choice bindings preserve begin/write/commit undo boundaries (same plane). Transactions are fail-atomic: a failed write or history commit reverts every eagerly applied command, and editor workflows must cancel and report the error rather than continue with a partial edit. Retained widgets must not keep pointers into document actor storage.
The hierarchy (editor/panels/hierarchy.zig → editor/panels/hierarchy/) groups actors under
the scene name/id. In Edit it reads the SceneDocument only (idle frames do not
walk the live world). In Play it mirrors the live world: a Global group for
loose/runtime entities (subsystem and spawnLoose spawns), then one group per
scene layer (including additive loads), with parent attach/detach reflected
inside each group. Stop restores the document tree. Actors with broken soft asset refs (failed mesh/material/script,
unknown archetype) use destructive name/icon styling; the inspector shows an
Asset issues banner with path + status. Full cause→result tables:
Assets and Shinra — soft refs.
The inspector also reflects authored transform metadata and user-data schemas.
Identity shows Active (entity active); Physics shows Enabled
(physics.is_active); Render shows Visible (render.is_visible). Live
edits flow through ActorMutation (including .active) so document and world
stay in sync — Active / enable trio.
Editor overlays, picking, grid/origin axes, light/trigger gizmos, and game
viewport UI all compose into the shared final UI stream.
Profiler
Two layers:
- Build — Kaji
--profiler-timing/--profiler-timing=true|falsecompiles the profiler subsystem in or out (omitted: on in debug, off in release). Without it, the Profiler tab shows “Instrumentation off”. - Runtime — Profiler on/off (the toggle button in the tab's header strip) is the global sampling switch (default off). With it off, the panel shows only the header strip and an empty stage. Turn it on to reveal the summary card, the collapsed Options section and the zone list, and to enable Record (NDJSON under
<project>/.engine/traces/<stamp>-<platform>-<conf>-editor|game.jsonviaio_stream; the button becomes Stop and a recording strip under the header shows elapsed time, frames written, hitch samples and the file path while the file is open). Under Options, Write every1/2/3/6frames (in-game slider 1–12; default 6) throttles how often a snapshot is written — use 1 when hunting single-frame hitches. The chosen rate is stored in the file header assample_every. Works in Edit and Play. OrderName/Costpicks stable A–Z (default) or by time.
Each recording starts with a schema-v4 QA handoff envelope: engine name/version and build VCS revision, project name/id and the project VCS revision observed when recording starts (probed asynchronously from a mapped Perforce client or Git, so it stays correct across syncs and hot recompiles), capture UTC, product, configurations, platform/graphics API/GPU preference, linkage, Zig version, CPU count, sample rate, and compiled instrumentation switches. It excludes absolute paths, user names, and host identifiers. Every frame sample carries ms, its offset on the monotonic clock from the moment the recording opened (t counts written samples, not frames). Every zone row carries s, its start offset against an origin shared by all threads in that frame, with ts saying whether those offsets are real; GPU passes and detail sections keep s at zero. A hitch escape hatch writes a frame costing more than hitch_ms (default 33.3 ms) even when the throttle would skip it, marked "hitch":1, limited to one out-of-band write per period. Readers keep hitch samples out of the latency histogram while the timeline and worst-frame search see them.
Profiler Trace Viewer (View → Profiler Tracer Viewer) is the offline Class 1 tool window for those recordings; Open trace and Compare trace start in <project>/.engine/traces. Four panes read one session:
- Summary band: mean/P95/P99/max, share of samples over the 60 and 30 FPS budgets, and a log-spaced latency distribution, plus the QA envelope for A (and B). While a large file indexes, the band becomes the progress readout.
- Timeline: horizontal axis is wall time, not sample ordinal. CPU and GPU draw as min/max envelopes plus a mean line (an envelope keeps a downsampled chart honest); over-budget frames are ticked along the top; a ribbon shows what the frame governor gave up. Primary-drag selects an interval, double-click or
Zframes it,Wselects the worst frame,Ffits,Escclears. - Cost table: ranks zones and passes over the selected scope; clicking a row plots that zone across the capture as a timeline track. With a comparison open it becomes an A→B regression ranking.
- Flame graph: the selected sample as nested spans, one lane per thread plus GPU and detail sections on one horizontal scale. On a v4 capture CPU lanes sit at their measured offsets, so the space between spans is real idle and a lane that waited reports its busy share. The recorder writes each thread as a depth-0 header carrying the thread's root time, so a depth-0 header is a lane boundary.
Large traces stay memory mapped and index over bounded editor ticks with a budget in milliseconds, not bytes. The index keeps fixed-ceiling summaries, an aggregation pyramid, log-spaced histograms, whole-capture name aggregates, and a bounded per-name bucket series that folds in pairs at its resolution cap, so a ten-minute capture costs what a ten-second one does. Rows decode through a direct reader for the exact recorder shape (falling back to the general JSON scanner on mismatch), names resolve through a row-order memo, aggregate names alias the map, and a frame folds all-or-nothing. The native bridge is platform/dialogs.pickFile.
Thread registry. Worker and game threads register in a process-wide table in the platform library (shared across app / physics / render dylibs). Registration is re-affirmed each frameBegin / threadBegin so a lost slot cannot stay invisible for the rest of the session. Each completed game/physics/render ring carries its producing world_id; capture admits only the active world plus id 0 session workers such as audio. GPU timestamp snapshots carry the same id, detail EMA histories reset across Play/Edit, and the panel clears until a newly scoped frame exists. File-trace frames include world_id. Capture also skips dead TLS left by threads that exited without unregister and always includes the capturing game thread when its completed ring matches. frame_ms is the game-thread root time when present, otherwise render.
When built in, the right dock Profiler tab (editor/panels/profiler_panel.zig) shows per-thread zone samples for the live frame (game, physics, render, audio device callback, audio.worker, nested scopes — including publish / world.physics_sync / audio.tick / audio.drain / audio.play.apply / audio.decode / audio.mix). The summary card carries the smoothed frame time, fps and GPU time, a fixed-scale (0–40 ms) history strip of per-frame cost folded by maximum so a single-frame hitch is never thinned away, the window peak, and a budget bar against 16.7 ms. The zone list is one virtualised paint leaf fed by a display model (panels/profiler/live_table.zig) rather than the raw snapshot: values are EMA-smoothed with a held peak tick on each bar, a zone that vanishes is held dimmed in place for about a second before it is dropped, repeats of one zone fold into a single row with a ×N count, order is stable across refreshes, and thread headers collapse on click. A Filter zones well above the list narrows it by name: space-separated terms, case-insensitive, a zone stays when any term matches and a thread header stays while any zone under it does; the filter is re-applied on every refresh, so the narrowed view keeps updating. Header, summary, options and the filter stay pinned; only the list scrolls under sticky column captions. The tree is built once — nothing is added or removed per refresh. Further switches under Options → Detail:
- Editor timing (editor product only) — retained UI composition (
ui.input/measure/arrange/paint/compose) plus chrome phases (hierarchy,inspector,assets,shell, …) under aneditorsection. - Entity timing — per-entity play-frame costs for Zig
update, script update,onCollision, andonMessage, listed asentities → name → method. Rows stay sticky with an EMA-smoothed ms value and drop after ~3s quiet. - Message timing — flush cost by interned message name under
messages.
Detail switches are off by default. With the Profiler tab open in Edit, frame pacing stays active so editor samples are not idle-throttled.
Asset References
Asset References (View → Asset References, also in the command palette) is a Class 1 tool window that draws what an asset needs and what needs it. The left pane lists every file asset in the project behind a search box; clicking one puts it in focus. With Follow selection on (the default) the focus tracks the tile highlighted in the Asset Browser, so the window answers "what is this wired to?" for whatever is being looked at.
The picture is a layered graph: assets that use the focused one stack to its left, assets it uses stack to its right, so every edge points rightwards and the arrowhead confirms the direction rather than being the only clue to it. Uses / Used by / Both picks a side; Direct / Whole chain picks one hop or the transitive closure (six hops, cut at 400 nodes with a banner saying so). Hovering a node lights its edges and dims the rest and shows the path, the kind, its own counts, and the slot the focus reaches it through (material ×2, mesh, skybox.primary). Clicking a node re-roots the graph on it; Back (or Backspace) walks the history; double-clicking reveals the asset in the Asset Browser. F or Fit frames the picture; the wheel zooms around the pointer and a trackpad swipe pans.
The index is honest by construction. Every authored document that can carry references (asset_refs.canReference) is read once, a few per editor tick so a large project never stalls a frame, and every reference slot in it (asset_refs.collectReferences, the same table the rename and delete flows use) becomes an edge. Nothing is inferred from names. A reference to a path no asset has is kept as an unresolved node, drawn in the destructive hue with a "missing" tag, because a scene pointing at a material that no longer exists is exactly what the window is for. Two assets of different kinds can share a stem (a model and its generated material); typed slots keep them apart. Documents the editor cannot read are counted in the status line rather than dropped silently. Rescan rebuilds the index; a catalog whose size changed rebuilds on its own.
Residency tab
The Residency tab always names the active world role and session-local world ID. World-owned rows are never summed across the retained editor and disposable Play worlds: scene jobs, physics publications, and world-specific GPU rows carry world_id, and a sample from the previous world is discarded at a Stop boundary. GPU object IDs use the same world namespace, so two live scene graphs cannot alias in renderer residency.
CPU vertex/index mirrors and geometry staging free lists are also measured from the active World's component, swarm, and render-command ownership. Their high-water marks live on that World; the process-wide cross-module ledger is only a balance checker and is never used as the panel total.
The overview cards are built once and only their labels change; a drill-in page is rebuilt only when what it would draw has changed (a navigation or filter change, or a reading that differs from the one on screen), never on a refresh that read the same thing. Texture mip streaming is shown as a budget block — resident bytes against the configured budget, with a bar that turns warm at three quarters and hot at the budget — on the GPU page and on the overview's GPU card.
Physical caches that are genuinely shared are shown as SESSION, not copied into either world's total: decoded AssetStore entries, hashed geometry, textures, pipelines, and process-level pools. The GPU page shows WORLD and SESSION badges; the overview's world GPU total counts only WORLD rows. This distinction is intentional—the physical cache exists once even when both worlds reference it, so attributing it to both would double-count memory.
Physics tab
The right-dock Physics tab is always part of editor chrome. Build with --profiler-physics to enable its backend-neutral 5 Hz snapshot; otherwise it shows a compile-option notice. A status strip names the loop state and backend; a summary card carries the smoothed step cost, the achieved cadence, the fixed step, and a cost strip (one column per published sample, about fifty seconds) with a budget bar against one pace period. Sections below it cover stepping (steps per second, catch-up steps, dropped simulated time, pace target, step size — folded by the physics thread itself, pace waits excluded), body motion/sleep state, collider shapes and joints, contacts and collision-buffer pressure, collision resource pools, and memory. The tree is built once; a sample only writes values into bound rows, so nothing flickers or scrolls back at the 5 Hz refresh. Each editor world owns a separate middleware backend and the snapshot carries its engine world_id; Stop cannot present the last Play sample under the editor heading. The allocator line is explicitly session-shared because both backend allocations pass through that one instrumentation allocator. The tab reads only a synchronized physics-thread publication and does not call middleware or lock the live world. See Physics — Physics diagnostics.
Audio tab
The Live output card shows two-second Music/SFX/Voice/Master waveforms with peak/RMS meters, peak hold and clipping; click a row to expand. It needs no profiler flag. Capture and telemetry updates stop while the tab is hidden or closed, and the tap is absent from standalone builds. Bus taps are dry and pre-master; Master includes reverb. See Live output monitor for signal points and callback performance contracts.
The right-dock Audio tab is always part of editor chrome. A stable Mix strip (master + bus gain/mute, reset) posts into AudioSystem and works whenever the session audio system is live — no --profiler-audio required. Build with --profiler-audio to enable the diagnostics section below the strip (worker-published snapshot); otherwise that section shows a compile-option notice. Diagnostics summarize bus occupancy vs soft caps, voice lane utilization, duck multipliers, command-queue depth, decode-cache size, and streamer ring pressure. The tab never locks the mixer or inspects live streamers; Mix is post-only and the snapshot is a synchronized audio-worker publication. See Audio — Editor and Audio — Diagnostics.
Standalone / Play also expose the same snapshot through World.profiler_overlay (profiler/profiler_overlay.zig). The sample game binds toggle_profiler to F4 and draws the overlay from its debug entity; the engine does not hard-wire that key. Build flags and modules: Build and packaging. Frame placement: Application lifecycle.
Play versus Edit
In Edit, the scene is rendered but simulation is frozen. In Play, game UI is clipped and remapped to the scene viewport; editor chrome remains outside it. Do not let editor chrome paint behind panels or let editor overlays appear in standalone game rendering.
Cross-scene game UI belongs on GameSubsystem (session services), not on scene actors. In-game Quit in the editor stops Play and switches back to the retained document world; it does not close the editor. See Session services.
Document vs live world on Stop. SceneDocument is the only authoring source. Entering Play builds a disposable world from document.saveToUtf8; Play never writes into the document or the retained editor world. Runtime Scene.load mutates only Play. Stop (enterEdit) rebinds the retained editor world and resets the session path to document.path, so unsaved edits survive even when gameplay changes scenes.
Neither transition blocks the frame. The Play copy is built across ticks on the scene-load budget while the authoring viewport keeps rendering and taking input; the Play button reads Starting… for that window, canAuthor is false (the document was captured at the click), and clicking again withdraws the request. Stop is a single-call switch with the copy retired invisibly afterwards. Editor frame pacing stays at the interaction rate for both, so a transition is never idle-throttled.
Play fence on Stop (once): GameSubsystem.onPlayEnded (game HUD flags) then World.ui.resetInteraction (engine modals/popups/focus/drag/scroll). Not on Play enter, and not in onSceneWillUnload (that also fires on mid-play Scene.load).
Opening another scene while dirty. Double-clicking a scene asset does not auto-save. If the document is dirty, the editor shows a Save / Discard / Cancel confirm modal (editor/dialogs/confirm_dialog.zig) and only proceeds after Save or Discard.
The open scene changing on disk. SceneDocument.disk_stamp (size + mtime from fs.Stat) records what the file looked like at the last load or save; editor_app/document_watch.zig stat-polls the path twice a second in Edit mode (never during Play, never while another modal is up) and raises a Reload / Cancel prompt on the alert dialog (alert_dialog.showPrompt with the .reload action) when the stamp differs. Reload re-reads the file through asset_flow.reloadFromDisk (same replacement path as opening a scene, minus the same-path no-op) and discards unsaved edits; Cancel keeps the in-memory document and adopts the new stamp so the same edit is not asked about again. The cook daemon is not the source here: it reports cooked outputs and cannot tell an outside write from the editor's own save. Prefab documents and the stacked parent scene are not watched.
Editor state and events
src/hikari/src/editor/state.zig is the editor-local state coordinator and typed event queue. It is MQ-like in that producers enqueue state transitions and subscribers receive ordered events, but it is deliberately an in-process editor service rather than a cross-thread or runtime-wide message broker.
The coordinator owns editor mode and deferred-work readiness. Feature payloads stay with the owning subsystem: the game recompiler retains its staged module and Shinra retains its asset notifications. The coordinator tracks only the work kind and count, then publishes work_ready when the current editor mode permits application. Asset hot reload (Work.asset_reload) is deferred while Play is active; apply at a frame boundary after Stop (or when Edit allows).
Events are dispatched synchronously on the editor thread from EditorApp.tick. Hooks may enqueue more events while dispatch is in progress; those events are delivered later in the same ordered pass. Hooks must remain fast and must not retain borrowed event data. Deferred work keeps its payload in the owning feature; apply only after work_ready, then complete.
Do not fork mode policy. Simulation mode lives on SessionCore; the editor coordinator mirrors it only through EditorApp.enterPlay / enterEdit. Deferred features gate on the coordinator after those paths run.
mode_changed also refreshes chrome that depends on Play vs Edit, including menu-bar enablement for edit_only EditorActions — see Actions, shortcuts, and the menu bar.
Performance contract
Keep UI state and layout backend-neutral. Avoid per-widget allocation and temporary formatted IDs in frame paths. The atlas/glyph geometry is precomputed; capacities are retained/prewarmable; layout reflow sorting is conditional; idle navigation short-circuits. Maintain one flattened UI draw on both Metal and D3D12.
Production text shaping, fallback, raster modes, glyph residency, and editing semantics are specified in Typography. Editor and game UI share that engine; font/page/mode changes must remain vertex data rather than new draw calls.
Motion (fade, spring, enter/exit) is a shared value layer — not a second widget system. Retained chrome uses opacity / shift on WidgetStyle plus editor/ui/overlay_motion.zig for modal/splash enter-exit. See Motion Kit.
For practical authoring details, see User interface.
Live actor inspection during Play
During Play the inspector reads the selected actor in the Play World, including
its local transform, activity, parent, tags, built-in fields and editor-visible
user component fields. It does not fall back to authored values. Runtime-added
components appear immediately with a Runtime pill; removed components leave
the view. A declared component removed and re-added during Play also gets the
pill. hidden and transient fields (including Data.state) remain excluded.
Selection of a runtime-spawned actor uses the same view. Swarm instances have no
individual actor inspector.
scene/observation.zig owns one selected-actor subscription per World. Mutation
APIs, component membership changes, simulation producers and the unified event
dispatcher publish coalesced value/structure flags. Native tick batches retain
their batching; only the observed actor is invalidated. Borrowed Zig pointers
allow ordinary assignments, so callbacks conservatively invalidate the selected
actor even when a callback did not change a field. This is not write interception.
There is no timer, World scan, JSON snapshot, or per-frame value comparison.
Idle synchronization consumes a flag without reading fields; after invalidation
only the selected actor's displayed fields are read. Labels retain their widgets
and unchanged text is not reallocated. Membership changes rebuild sections while
preserving their collapse state. Hiding/unmounting the panel stops observation.
Reads and subscription changes happen at the editor's post-tick synchronization
boundary, after gameplay workers have joined.
editor/panels/inspector/live.zig owns the retained view; live_values.zig adapts
built-in fields and generated user-field accessors. Generated accessors live in
the compiled game module, from sdk/src/define_component/value.zig and
properties.zig, rather than an on-disk per-component generated file. They
preserve optional payloads, nested paths, enum names, strings, assets, and exact
64-bit integers. Rebuilding the game module regenerates them with its declarations;
the module ABI rejects an older layout.
Editing remains disabled. live_target.zig is the future write boundary:
field targets contain World identity, actor generation, component membership
revision, and field identity, never a borrowed component pointer or document
binding. It rejects stale targets, non-Play Worlds, writes during callback borrows,
and authoring fields marked read-only. Its field write adapters use runtime
setters/generated accessors and normal change notifications. Hierarchy/tag editing
and nullable built-in edit controls still need their UI policies when editing is
enabled. There is no document/history write in this path; Stop discards the Play
World and returns to the unchanged Edit World. Future widgets must call this
boundary between ticks, using the active Play module binding.