Skip to content
hikari
RenderingEditorAIToolchainDocumentation
GitHub
hikari

© 2026 Flying Rat Studio.
All rights reserved.

Explore the engineDocumentationContributorsLicenseBack to top
Documentation / Systems
Browse docs
Overview
Tutorials16
OverviewFirst game projectFirst entityAuthored user_data and the inspectorFirst mesh and materialFirst physics body and triggerFirst character controllerFirst session servicesFirst UIFirst input actionFirst messagesPlay, Edit, and scenesFirst runtime spawnFirst motionFirst skeletal animationAssets in Play (soft refs and hot reload)Dynamic editor recompile
Guides16
OverviewDevelopment guideGameplay APIChoosing component storageBuild and packagingProject filePluginsHikari Plugin APIData-driven content, JSON, and pathsScripting with KawaShader authoringTime of dayUser interfaceUI layoutUI widgetsMigration from Unity / UnrealActor and component lifecycle
Systems28
OverviewArchitectureApplication lifecycleFrontends and driversPlatforms and supportSession services and cross-scene stateScenes and gameplayActor communication (hi.actors)Game-facing refsSave / replication wire versionCoordinate space and camera conventionsRenderingRenderer architecture mapFrame governorGPU particlesVisual ZonesVolumetric mediaInputAudioPhysicsMotion KitTemporal KitAssets and ShinraPrefabsAsset residencyAsset formats (Shinra pipeline)UI and editorEditor asset hot reloadEditor Project Selector
Language reference2
OverviewAkari language referenceKawa language reference
Engine overview
Start exploring
  • No matching sections. Try fewer words or another topic.
NavigateEnter Openesc Close
Overview
Tutorials16
OverviewFirst game projectFirst entityAuthored user_data and the inspectorFirst mesh and materialFirst physics body and triggerFirst character controllerFirst session servicesFirst UIFirst input actionFirst messagesPlay, Edit, and scenesFirst runtime spawnFirst motionFirst skeletal animationAssets in Play (soft refs and hot reload)Dynamic editor recompile
Guides16
OverviewDevelopment guideGameplay APIChoosing component storageBuild and packagingProject filePluginsHikari Plugin APIData-driven content, JSON, and pathsScripting with KawaShader authoringTime of dayUser interfaceUI layoutUI widgetsMigration from Unity / UnrealActor and component lifecycle
Systems28
OverviewArchitectureApplication lifecycleFrontends and driversPlatforms and supportSession services and cross-scene stateScenes and gameplayActor communication (hi.actors)Game-facing refsSave / replication wire versionCoordinate space and camera conventionsRenderingRenderer architecture mapFrame governorGPU particlesVisual ZonesVolumetric mediaInputAudioPhysicsMotion KitTemporal KitAssets and ShinraPrefabsAsset residencyAsset formats (Shinra pipeline)UI and editorEditor asset hot reloadEditor Project Selector
Language reference2
OverviewAkari language referenceKawa language reference
Engine overview
Systems44 min read

UI and editor

On this page
On this pageRuntime UI (host summary)Editor UITool-window presentationRetained toolkitGraph canvases and node editorsAsset browserMaterial detailVisual languageBottom console and notificationsReusable componentsHierarchy, toolbar, and inspectorSettings windowsEnvironment panelAsset Detail and preview viewportsInput ownershipMode chrome (Play, prefab)Contributions (host chrome)Game editor SDK (hi.editor)Exposure (what games see)Isolation (must not leak into non-editor builds)Host wiringSamplesWhat not to doAgent MCP (per-project)Actions, shortcuts, and the menu barEditor documents and property editingDocument entry (shared mutation plane)ProfilerAsset ReferencesResidency tabPhysics tabAudio tabPlay versus EditEditor state and eventsPerformance contractLive actor inspection during Play Back to top

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 / assertModel reject RetainedUi and WidgetId fields 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.Lifecycle supplies 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) and retained.CustomLayout for 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.animate owns 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 on RetainedUi.semantics_dirty.
  • Text entry. NSTextInputClient and IMM32 translate native UTF-16 ranges into UTF-8 byte ranges and preserve marked text; runtime and retained fields apply the same text_edit composition 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 + monotonic revision) via Provider.frameChangeToken; layers that rewrite output (ViewportUiLayer, ViewportOverlay, RetainedUi) bump a local generation. An immediate UiContext fingerprints 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).

zig
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 (toolGroup outlined, groupedToolButton not; segmented track outlined, selected segment not). Containers may hold bordered inputs. Pinned by components/tests.zig.
  • Outline or fill, not both. normal buttons, dropdown triggers, inputs, and cards take the hairline; filled variants (accent, toggled, destructive, transport) do not.
  • accent is for exclusive modes and primary actions only. An independent on/off switch uses ToolButtonVariant.toggled (accent_muted fill, accent ink).
  • Menu and list rows are borderless. The popup owns the outline; rows show state through hover tint and an accent_muted wash.
  • Tabs are handles, not pills. tabStrip is a recessed canvas rail with no bottom seam; the seated handle carries the pane's own surface fill 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_radii overrides corners independently and every layer follows it.
  • Inputs sit in surface_sunken, not canvas.
  • Focus is the web :focus-visible shape: 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; syncClear is told the query and clearPressed reports 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" is chrome.emptyState.
  • The scrub timeline is one element (components/timeline.zig): ruler, rail, playhead, and knob from one Metrics.x with the pointer read back through ratioAt; press to jump, drag to scrub, Esc cancels, ←/→ step, Home/End. Time arrives as a ScalarBinding and 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 cross RetainedUi trees. Builtin panel host wiring is one row each in editor_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:, and is: (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 _asset is 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) or resolveLiveEntity (live world), and colours are decided once in rows.applyRowVisuals, called by both the bind pass and the selection pass. A state added to only one resolver renders on nobody; a second hand-written setStyle silently 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.sceneInputBlocked is true for any chrome hit not under a scene_passthrough node (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.captured the OS cursor is hidden and MouseFrame.x/y is a virtual point integrated from relative motion. InputSystem.syncFrame publishes InputState.pointer_captured and 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_keyboard is the host routing flag: Play calls setAcceptsKeyboard(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 reports Provider.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 when focusCapturesText().

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:

PathWhoSeam
Host builtinsEditor product codesrc/hikari/src/editor/contrib.zig + bindFill (retained widget trees)
Game / pluginsProject + composed packagesC/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 APIEffect
registerPanelTab + page in slot .left / .right / .bottom
bindFill(id, ctx, fillFn)Build tools/body/footer content when chrome creates the page (host-only)
registerMenuGroup / registerMenuEntryMenu bar groups and leaves (EditorAction)
assembleMenusFixed MenuAssembly → menuBar

Lifecycle (create-time only; freeze after chrome build):

  1. registerBuiltins (host panels/menus)
  2. Game/plugin registerEditor via applyFromGame (POD declarations → registry)
  3. bindFill for every host panel that owns UI — panel_fills.zig: hierarchy tools, console, inspector, profiler, residency, physics, audio, assets
  4. assembleMenus + chrome.build (calls fills; no id special-cases)
  5. 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)

SymbolPackage pathWhen present
hi.editor_contrib_enabledsdk/src/hikari_game.zigAlways a bool from hikari_build_options.editor_contrib
hi.editorsameFull editor_contrib.zig when enabled; empty struct {} when off
hi.editor.Apisdk/src/editor_contrib.zigC-ABI registration table (valid only during registerEditor)
GameModule.register_editorsdk/src/game_module_def.zigOptional 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 / accessorPurpose
register_panelDock tab in .left / .right / .bottom (PanelDesc: id, title, slot, icon, order, default_selected, has_tools / has_footer, padded_scroll)
register_menu_groupTop-level menu group (MenuGroupDesc: id, label, order)
register_menu_entryLeaf under a group (MenuEntryDesc: group, command/action id, label, order, sep, icon)
register_action / registerActionUnified 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 / registerToolbarItemToolbar leaf (ToolbarItemDesc: group, action id, order, sep)
register_panel_updateTick callback while that panel tab is selected
begin_surface / beginSurfaceOpen a host-owned control list for a surface id (typically a panel id; host binds fill when ids match)
add_control / addControlAppend a control (ControlDesc: id, ControlKind, text, optional action for button/toggle); returns ControlHandle
end_surface / endSurfaceClose 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 / accessorPurpose
show_messageHost modal alert (title + body); wired after chrome/dialogs exist
set_control_text / setControlTextUpdate host-owned control text by handle
set_control_enabled / setControlEnabledEnable/disable a control
set_control_checked / setControlCheckedToggle checked state
hi.editor.scene() / SceneApiBuffered 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 host EditorAction (see game_contrib.engineAction).
  • Any other id is custom; register with registerAction before 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 / bindFill from the game module
  • Free SVG icons across the ABI
  • Raw SceneDocument*, EditorApp*, World*, Entity, renderer, or native window handles (scene mutation goes through SceneApi / EditTransaction only)

Isolation (must not leak into non-editor builds)

Editor-only code must never compile into standalone kaji game / pure game modules. Layers:

LayerMechanism
SDK optionsdk/build.zig: -Deditor-contrib defaults false; editor_contrib.zig is only imported when true
Barrelhi.editor is empty when editor_contrib_enabled == false
Game moduledefineGameModule sets register_editor only if flag and a real registration path exists
Editor recompileeditor/project.zig always passes -Deditor-contrib=true for game-module builds under the editor
Host productEditor host always has contrib types (build.zig host options); still only calls game registration when non-null
Sample / project / plugin codeKeep 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

PiecePathRole
Registryeditor/contrib.zigFixed-capacity panels + menus (host builtins; game/plugin via bridge)
Bridgeeditor/game_contrib.zigMaps Api → registry + actions / toolbar / surfaces / panel-update tables; host string intern pool; setAlert for modals
Create ordereditor/editor_app/app.zigbuiltins → applyFromGame → assembleMenus → freezeChrome → chrome.build (host fills) → later setAlert
Game recompileeditor/editor_app/game_recompile.zigrebindFromGame — clears invoke/update hooks, re-runs registration (new dylib fn pointers); structural menus/panels/toolbars/surfaces stay
Menu / toolbar clickeditor/editor_app/shell.zigEditorAction → handleActionFrom; custom id → game_contrib.invokeCommand (warns if missing)
Panel tickeditor/editor_app/tick.ziggame_contrib.tickSelected when update_count != 0
Alertseditor/dialogs/alert_dialog.zigshowText 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):

  • src/games/example/plugins/example_capability/editor/root.zig

Project glue for plugin editor registration:

  • src/games/example/src/root.zig — registerEditorPlugins when hi.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.editor from always-on game code.
  • Force -Deditor-contrib=true on 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 / bindFill or keep retained widget pointers from game/plugin code — use hi.editor surface controls only.
  • Assume free SVG icons, document mutation APIs, or raw EditorApp / World handles 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.

SettingLocation
EnableEditor 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 configPretty-printed JSON on the MCP page (includes Bearer token); Copy configuration writes it to the OS clipboard (platform/clipboard.zig)
Rotate secretRegenerate 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.

text
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)
PieceLocationRole
EditorActioneditor/shortcuts.zigStable action identity (save, undo, gizmo modes, menu stubs, …)
actionInfo / ActionContextsameLabels, optional SVG icons, shortcut glyphs, gates (edit_only, needs selection / undo / redo)
ShortcutRegistrysameKey chords → EditorAction; installDefaults; chordFor for display
commands.Routereditor/commands.zigPreview/target/bubble route over retained scopes; stale generational scopes cannot receive callbacks
authoring.handleActionFromeditor/editor_app/authoring.zigPlay/edit gate plus sole source-aware route entry
authoring.executeActionsameRoot-scope production fallback; executes without recursively routing
authoring.processShortcutssamePolls 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 bareditor/ui/components/menu_bar.zigRetained top strip + cascading popups (not OS menu APIs)
history.Commandeditor/history.zigInvertible 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 EditorAction and actionInfo (palette catalog is command_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 by editor_state through enterPlay / 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 doorPath into the plane
Host chrome (hierarchy, inspector, gizmos, shortcuts)editor_app/authoring/*, property_binding → scene_mutation
Plugin SceneApihi.editor.scene() / EditTransaction → host bridge → applyBatch
MCPagent/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:

  1. Build — Kaji --profiler-timing / --profiler-timing=true|false compiles the profiler subsystem in or out (omitted: on in debug, off in release). Without it, the Profiler tab shows “Instrumentation off”.
  2. 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.json via io_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 every 1/2/3/6 frames (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 as sample_every. Works in Edit and Play. Order Name/Cost picks 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 Z frames it, W selects the worst frame, F fits, Esc clears.
  • 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 an editor section.
  • Entity timing — per-entity play-frame costs for Zig update, script update, onCollision, and onMessage, listed as entities → 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.

PreviousAsset formats (Shinra pipeline)Next Editor asset hot reload

Documentation follows the current engine checkout.

Snapshot cc148c75Source docs/systems/ui-and-editor.md
On this pageRuntime UI (host summary)Editor UITool-window presentationRetained toolkitGraph canvases and node editorsAsset browserMaterial detailVisual languageBottom console and notificationsReusable componentsHierarchy, toolbar, and inspectorSettings windowsEnvironment panelAsset Detail and preview viewportsInput ownershipMode chrome (Play, prefab)Contributions (host chrome)Game editor SDK (hi.editor)Exposure (what games see)Isolation (must not leak into non-editor builds)Host wiringSamplesWhat not to doAgent MCP (per-project)Actions, shortcuts, and the menu barEditor documents and property editingDocument entry (shared mutation plane)ProfilerAsset ReferencesResidency tabPhysics tabAudio tabPlay versus EditEditor state and eventsPerformance contractLive actor inspection during Play Back to top