Immediate-mode game UI. Describe layout during update; results return immediately; geometry draws as the final pass of the frame.
Two surfaces. Host/editor owns full ui.UiContext (world.ui). Games use hi.ui() (UiApi) — stacks (optional id), scroll, text, rect, sprite images/nine-slice, button, checkbox, dropdown, slider (SliderStyle), text field, numeric field, tabs, progress, tooltip, icon (string id), spacers/separator/flexSpacer, modals, density/scale, capacity, richText, world anchors, virtual list/grid, lastInputDevice, setFont / setTheme / warmFont. Host-only: trees, split panes, drag-drop, popups, context menus, custom SVG Icon values, and most ergonomics helpers (withVStack, bind, …).
| Page | Covers |
|---|---|
| This page | Quick start, frame lifecycle, themes, ownership, rendering contract |
| UI layout | Coordinates, lengths, stacks, justify/flex, scroll/clip |
| UI widgets | Controls, focus, modals, identity, icons |
Editor chrome: UI and editor. Tutorial: First UI.
Quick start
Build scene-owned UI from an entity update callback:
const hi = @import("hikari_game");
const UiBehaviour = struct {
pub fn update(_: *@This(), _: hi.ActorContext, _: *const hi.TickContext) void {
const ui = hi.ui();
const toolbar = ui.vStack(.{
.position = .{ 16, 16 },
.width = .{ .percent = 0.30 },
.height = .{ .points = 138 },
.padding = 16,
.spacing = 10,
.surface = .surface,
});
defer ui.end(toolbar);
ui.text("Scene Controls", .{ .role = .secondary });
if (ui.button("Reload Scene", .{
.id = "main-toolbar.reload-scene",
.variant = .primary,
})) {
hi.world().requestSceneReload();
}
}
};
const UiLogic = hi.defineComponent(.{
.name = "ui_logic",
.storage = .embedded,
.data = struct {
// … update() that calls hi.ui() …
},
});
pub const UiEntity = hi.defineActor(.{
.archetype = "ui",
.components = .{UiLogic},
});Add that entity archetype to the active scene to make the interface follow the scene's normal lifetime. Step-by-step: Tutorials — First UI.
Frame lifecycle
Applications do not call beginFrame themselves when using World. World.onTick begins the UI frame before updating scene entities. Beginning a frame:
- clears the previous frame's generated vertices while retaining their capacity;
- copies the current viewport dimensions and mouse state;
- resets per-frame hover state;
- verifies that every stack opened during the previous frame was closed.
Entities then emit widgets into world.ui. After simulation, World.publishRenderState copies the completed vertices into the immutable render-frame snapshot. The render thread uploads that snapshot and executes the final ui render-graph pass.
This keeps widget and layout behavior backend-neutral. Metal and D3D12 are responsible only for vertex upload, pipeline state, alpha blending, and drawing.
Sprite images and nine-slice
Author *.spriteatlas.json as metadata over an ordinary texture. Coordinates and borders are source texels; the texture remains a normal compressed, mipmapped .shintexture and can later be reused by a world-sprite renderer.
{
"kind": "com.hikari.sprite_atlas",
"version": 1,
"texture": "asset://./textures/hud",
"sprites": {
"heart": { "rect": [0, 0, 32, 32], "pivot": [0.5, 0.5] },
"panel": { "rect": [32, 0, 64, 64], "border": [8, 8, 8, 8] }
}
}One atlas per game-UI frame. The UI pass is a single draw with one imagery slot. The first atlas a frame touches owns it; image / nineSlice from any other atlas that frame return false and a one-shot warning names both sides. Pack a game's UI sheets into one atlas (the example project's tools/gen_ui_atlas.py does this). A HUD from one sheet and a menu from another gives a silently empty menu, so if two systems can be on screen together make sure only one owns the slot.
The asset reference is the extension-free atlas stem:
const hud = hi.AssetRef.must(.sprite_atlas, "asset://./ui/hud");
_ = ui.image(hud, "heart", .{
.width = .{ .points = 32 },
.height = .{ .points = 32 },
.fit = .contain, // stretch | contain | cover
});
_ = ui.nineSlice(hud, "panel", .{
.width = .fill,
.height = .{ .points = 48 },
});Calls return false while assets are pending or a name is absent.
Author chrome — panels, frames, bars, icons — as white RGB with the shape in alpha. The UI shader multiplies tint.rgb * texel.rgb and tint.a * texel.a, so one sprite covers a control's idle, hover, accent and disabled states, and the sheet stays small. Only artwork that must carry its own colour (key art, thumbnails) needs real RGB.
Sprites are layout leaves: they claim a rect and paint, they never own children. Art behind content is therefore a zStack with the sprite as the first layer. A background sprite sized .fill also needs a parent with a resolved height — inside an .auto parent it falls back to the sprite's natural size. Either give the plate a concrete height or measure the content with ui.contentSize and apply it on the next frame.
Theming game UI
Widget chrome is engine-painted: ui.button, ui.slider, ui.checkbox and ui.dropdown fill from the context's Theme, not from anything the game passes per call. ui.tokens() carries spacing and sizes only. A game shipping its own art direction therefore has to move the theme, or its sprite work sits on top of controls from a different palette.
hi.ui().setTheme(.{
.accent = .{ 0.851, 0.647, 0.400, 1 },
.text_primary = .{ 0.902, 0.886, 0.855, 1 },
.text_on_accent = .{ 0.055, 0.051, 0.043, 1 }, // a light accent needs dark label text
.border = .{ 0.902, 0.886, 0.855, 0.16 },
});Every field of ThemePatch is optional — null keeps the current value. The host snapshots the theme on the first setTheme and hi.ui().resetTheme() restores it, so a scene that themes the UI must reset on teardown or the palette leaks into the next scene. Colours only: metrics, radii and font slots are not part of the patch.
Padding
padding insets all four sides; padding_x / padding_y override it per axis. Reach for them on any row with a declared height: a uniform inset shortens the row's inner height too, which silently squeezes the content below the height of the controls sitting in it and leaves the whole row looking low.
Group transforms
opacity and offset on any stack apply to that stack and its whole subtree, folded into the emitted vertices once when the stack closes. Nested groups multiply, like CSS or SwiftUI .opacity.
const panel = ui.vStack(.{
.opacity = t, // whole panel fades
.offset = .{ (1 - t) * -24, 0 }, // …and slides in
});Layout does not reflow and hit rects follow the offset, so a panel can slide on and off without disturbing anything around it. At the identity values (1, {0,0}) it costs nothing; otherwise it is one linear pass over floats that are already hot. This is the intended way to animate a screen — not threading an alpha through every leaf.
Cross-axis alignment
cross_align places children on the axis a stack does not advance along, and applies to leaves as well as controls. The three single-pass rules (.stretch is .start for sized children; content-sized children are placed when they close, so their hit rects lag one frame; flow aligns per wrapped line) are in UI layout.
Text
tracking— extra advance per glyph. Measurement and word wrap account for it, which is why padding a string with spaces is not a substitute: spaces break centring and wrapping.align_x—.start/.center/.endwithin the available width; wrapped text aligns per line. Needs a resolved width (max_widthor the parent's), otherwise it degrades to.start.shadow— one offset pass behind the glyphs. The cheapest way to keep a HUD readable over an arbitrary 3D scene without an opaque plate behind every label.ellipsize/max_lines— cut what does not fit and mark the cut.textforwards these to the rich-text layout pass rather than keeping a second answer to where a string should be cut, so a plain label and a styled paragraph truncate identically.
ui.textWidth(value, style) and ui.textSize(value, style) measure without drawing. Immediate mode places a widget before its content is measured, so anything that has to fit a label — a keycap, a chip, a plate behind a caption — needs the answer up front; the alternative is a hand-padded guess that is wrong at every other font and scale. Both honour the whole style, truncation included, so they agree with what text then draws.
const w = ui.textWidth(label, .{ .size = .caption });
const cap = ui.zStack(.{ .width = .{ .points = @max(20, w + 12) }, .height = .{ .points = 20 } });Rich text: styled spans on one line
ui.text draws one string in one style. A span list draws many styles as one
paragraph, so it still wraps as a unit:
const spans = [_]hi.ui_types.Span{
.{ .text = "Press " },
.{ .icon = hi.ui_types.icons.play, .icon_size = 14 },
.{ .text = " to open the " },
.{ .text = "Ancient Chest", .color = rare_blue },
};
ui.richText(&spans, .{ .wrap = true, .shadow = .{ .offset = .{ 1, 1 }, .color = shadow } });Each span overrides only what it names: color or role, scale (a multiplier
on the paragraph's), font, tracking. A span with icon set draws an inline
glyph instead of text, sized to the line by default.
The reason to use this instead of three adjacent labels is wrapping. Adjacent labels each wrap against their own box and none of them knows where the previous one stopped, so a line break lands in the wrong place as soon as the text is long or translated. A span list breaks anywhere in the stream, including in the middle of a span, and a word may straddle two spans. Mixed sizes on a line share one baseline.
ui.richTextWidth(&spans, style) measures one unwrapped line, for sizing a
plate before drawing into it.
World-anchored UI
Nameplates, damage numbers, interaction prompts and quest markers are UI pinned to a position in the world. The host installs the camera that formed the frame's image, so a game only supplies the world position:
const plate = ui.worldAnchor(head_position, .{
.pivot = .bottom_center,
.offset = .{ 0, -8 },
.max_distance = 40,
.fade_distance = 8,
});
defer ui.end(plate.scope);
if (plate.visible) {
ui.text("Wanderer", .{ .size = .caption });
}Opening an anchor always succeeds, so the defer stays balanced; visible says
whether the target is worth building content for. It is false without a camera,
behind the camera, past max_distance, and well off screen.
The scope also reports distance (world units), point (where it landed, in
UI points), fade (the distance fade already applied to the block's opacity),
and behind.
Set clamp_to_viewport to turn an anchor into an offscreen marker: the block
slides along the viewport border, inset by clamp_margin, pointing at a target
that has left the screen. Pair it with show_behind so targets behind the
camera still mark an edge.
For raw projection, ui.worldToScreen(position) reports the point in UI points
along with valid (false without a camera), behind, on_screen and
distance, so an arrow can point at a target it cannot draw on.
Anchored content can be interactive. A nameplate's size is unknown until its
contents are laid out, so the block is placed by the size it had last frame,
through the same mechanism offset uses, which hit rects already follow. Any
difference is corrected in the emitted geometry, so painting is exact on every
frame and clicking works from the second frame an anchor draws. The frames that
can lag by one are the first, and one where the content changes size. That is
the same guarantee justify and flex already give.
Resource bars
progress covers three shapes, because a health bar differs from a loading bar
only in two knobs.
ui.progress(health, .{ .segments = 4, .trailing = recent_health });- Continuous is the default: a plain filled track.
- Segmented splits the track into equal cells with a gap. Players count blocks faster than they judge a length, which is why resource bars in games are usually segmented. The boundary cell fills partially, so the underlying value still reads continuously.
- Chip draws
trailingas a band between itself and the value. Above the value it is the damage just taken, draining behind the bar; below it, the healing a pickup would add. The default colour follows the direction, using the destructive role for a loss and the muted accent for a gain, so the two never read the same. Equal values draw no band at all.
color, track and trailing_color override the theme when a bar needs to
belong to a specific piece of art rather than the palette.
Truncating rich text
ui.richText(&spans, .{ .ellipsize = true, .max_width = 180 });Unwrapped, that is one line cut to the width and marked with ...; wrapped, set max_lines and the last permitted line is cut. The marker borrows the colour and face of the span it cut. Truncation needs a width from max_width or the parent; without one, unwrapped text does not truncate.
Grids and large collections
flow packs children by their own width and wraps when it runs out, so columns
drift as soon as two cells hold different content. A grid steps by the cell
instead, so column N is at the same x in every row:
const bag = ui.grid(.{ .columns = 6, .cell = .{ 48, 48 }, .row_spacing = 4 });
defer ui.end(bag);
for (items) |item| drawSlot(item);A collection of any size should be virtualized: only the rows in view are built, while the container still scrolls as though all of them were there.
const rows = ui.virtualList("inventory", &scroll, .{
.count = items.len,
.item_height = 28,
.container = .{ .height = .{ .points = 320 } },
});
defer ui.end(rows.scope);
for (rows.first..rows.end_index()) |i| drawRow(items[i]);virtualGrid is the same idea windowed by row, so first is always a multiple
of columns and a row is built whole or not at all. Both report count and
total_extent, and both take an overscan in rows so a fast scroll does not
show a gap before the next frame catches up.
Two things to know. item_height is the row pitch, the row plus its gap:
virtualization maps a scroll offset to an index by dividing by a fixed extent,
so the container's own spacing is forced to zero and the gap belongs in that
number. And a scroll area learns its own limit by measuring once, so an offset
set before a container's first frame only takes effect on the second, exactly as
with a plain scroll area.
Animation
ui.animate(id, target, rate, dt) is retained per id: frame-rate independent easing where rate is the fraction of remaining distance closed per second. Slots are evicted a couple of frames after a widget stops drawing, so screens that go away stop costing anything.
const glow = ui.animate("menu.row.0", if (selected) 1 else 0, 12, dt);Focus and device
Widgets register themselves as focusable; a game only steers. ui.moveFocus(.down) / .next / … returns false when nothing focusable lies that way, ui.hasFocus() and ui.clearFocus() round it out, and ui.lastInputDevice() returns .mouse / .keyboard / .gamepad — swap button prompts on it and hide focus rings while the player is on the mouse.
ui.setSafeArea(.{ left, top, right, bottom }) inset anything anchored to a screen edge, for TV overscan and notches.
ui.widgetState() returns the interaction state of the widget built immediately before the call — hovered, pressed, focused, focus_visible. A control only reports that it was activated, so this is how a front-end painting its own selection art follows the pointer. focus_visible separates a pad's focus from a mouse's: a highlight keyed on it tracks the stick without stranding a ring behind the cursor. Paint-only leaves (text, rect, image) report all-false rather than leaking the previous widget's state.
Menu rows: one button as the whole hit surface
A menu row is not a boxed control with an icon glued beside it. Make the row's button the full-size hit surface, reserve the icon column in its label inset, and paint everything else over it:
const row = ui.zStack(.{ .id = id, .width = .{ .points = 400 }, .height = .{ .points = 44 }, .cross_align = .stretch });
defer ui.end(row);
const activated = ui.button(label, .{
.id = id,
.variant = .ghost, // chrome-less: a row, not a box
.width = .fill,
.height = .fill,
.padding_x = 52, // the icon column — the label starts after it
.align_x = .start, // labels share a left edge instead of each centring
});
const state = ui.widgetState();
const lit = ui.animate(id, if (selected or state.hovered or state.focus_visible) 1 else 0, 14, dt);
// …then the icon, rail and chevron as paint, over the button.One widget owns the row, so there is no second target to miss between the icon and the label, the engine still owns the hover/press wash, the focus ring and keyboard activation, and the label's left edge is a declared number rather than whatever the icon happened to measure.
ButtonStyle carries height, padding_x, corner_radius, align_x, outline, fill, min_width/max_width and flex for exactly this kind of composition. text_scale is optional: leave it null and the label matches the surrounding ui.text at the same environment, which is what keeps a screen on one type ramp.
Chrome comes from the variant, and two knobs override it:
| Variant | Plate | Outline |
|---|---|---|
primary / destructive | filled | none |
secondary | filled surface | hairline |
ghost | none at rest; wash on hover/press | none |
ghost is the chrome-less variant — that is what separates it from a secondary, and it is why listItem, segmented, tabs and a disclosure row do not draw a box around every unselected entry. outline = true boxes one deliberately; fill = false suppresses the plate and its hover/press wash, leaving a pure hit surface. The focus ring always draws — it is the only affordance a gamepad has.
A card is the same idiom with the layers reversed: fill = false, the button down first so the art paints over it, then paint the card's own response from widgetState.
Painting unconditionally is cheap: a fill below half of one 8-bit alpha step emits no geometry at all, so a wash at rest, a ghost's plate and a selection glow at zero cost nothing but the layout claim.
Controls
Beyond the basics: segmented (mutually exclusive row — the shape of a console tab strip), toggle (settings switch; prefer over checkbox), listItem (menu / inventory / save-slot rows), numericField, tabs.
A settings sheet reads as a table only when its columns are declared once and shared by every row: caption on the left, a fixed value column, then a fixed control column. Give a toggle the control column's width and a real label ("Enabled" / "Disabled") — an empty-title toggle collapses to a bare square that says nothing about what it is showing.
Filling the remainder (panels)
Layout is single pass, and flex only shifts siblings — it never grows them. .fill resolves against the parent's whole size, not what is left, so a body sized .fill between a header and a footer eats the panel and pushes the footer out of the dialog.
Ask for the remainder instead:
// header and tabs already emitted…
const footer_h: f32 = 40;
const body_h = @max(80, ui.remainingHeight(240) - footer_h - spacing);
const body = ui.scrollArea("settings", &scroll, .{ .width = .fill, .height = .{ .points = body_h } });ui.remainingSpace() returns [2]?f32 — null on an axis where the stack sizes to its content. ui.remainingHeight(fallback) is the vertical shorthand. Both are exact and cost nothing: the pen position and the declared size are already known.
Two controls are easy to get wrong:
ui.slider(label, …)—labelis the slider's identity, never drawn. Settings rows draw their own caption and value readout, which is also what lets a column of them align.SliderStylecarriesstep,width,track_height,thumb_width.ui.dropdown(…, DropdownStyle)— settext_scale, or a dropdown inherits the sheet's display scale and renders as a banner.
Worked example
scenes/ui_showcase.json in the example project (AURORA) is a five-screen front-end — title, chapter select, loading, HUD, pause/settings — built entirely on hi.ui() and one atlas:
| Path | Role |
|---|---|
src/ui_showcase/theme.zig | palette, the spacing/type/metric scale, sprite wrappers, composite widgets (beginPanel, meter, menuRow, beginRow, keycap, promptStrip) |
src/ui_showcase/screens.zig | the five screens and all their state |
src/entities/ui_showcase_entity.zig | component wiring, font slots, pointer mode |
tools/gen_ui_atlas.py | regenerates assets/ui/aurora.png and its .spriteatlas.json |
The atlas is generated, not hand-packed, so the sheet and the JSON rect table cannot drift: re-run python3 tools/gen_ui_atlas.py from the project root after editing the sprite list. It also writes tools/aurora_preview.png, the sheet composited over a UI-dark plate — the raw PNG is mostly white-on-transparent chrome and is misleading to eyeball directly.
Themes, tokens, and environment
Each context contains a ui.Theme. Defaults are runtime/game (Theme.runtime()): cool slate panels and cyan primary actions. Editor chrome calls Theme.dark() / Theme.light() — a neutral zinc elevation ramp whose border token is a hairline (white @ 9% on dark, near-black @ 11% on light) doing double duty as control outline and dock seam, with softened core-blue CTAs, muted selection washes, full #3B5BFF focus rings, and rare photon rose #FF3D9A brand. Do not apply the editor theme to world.ui when embedding game UI in the viewport — the two providers are independent.
Game modules reach the same setter through hi.ui().setTheme (see Theming game UI); host and editor branding uses world.ui.setTheme directly:
world.ui.setTheme(.{
.accent = .{ 0.55, 0.25, 0.90, 1.0 },
.surface = .{ 0.08, 0.07, 0.12, 0.96 },
.text_primary = .{ 0.96, 0.96, 1.0, 1.0 },
});Theme owns color tokens. DesignTokens owns spacing, control heights, radii, type sizes, and icon sizes for each Density. Game modules get the same table through ui.tokens() — spacing, control heights and type steps, plus radius_sm/md/lg, control_padding_x/y, icon_sm/md/lg and slider_track, so a game's own plate agrees with the corner and the inset of the engine-painted control in front of it instead of guessing at them.
There are two metric tables behind that one Density word, because a game HUD and a tool window want opposite things from it: DesignTokens.forDensity is HUD-sized (immediate UI / world.ui), DesignTokens.forEditorDensity is the dense editor scale (24pt rows compact, 30pt comfortable, a 4/6/8 radius ramp). Environment.init selects the runtime table and Environment.initEditor the editor one; ui.Metrics records the choice so a nested pushEnv inherits it instead of silently reverting to HUD sizing. Editor chrome is the only caller of initEditor (RetainedUi).
Both immediate and retained UI read the active Environment (density + text_scale multiplier + resolved tokens).
world.ui.setDensity(.comfortable);
world.ui.pushEnv(.{ .density = .compact, .text_scale = 1.1 });
defer world.ui.popEnv();
// or
world.ui.withEnv(.{ .text_scale = 1.2 }, &state, buildSettings);Controls resolve missing metric fields from the environment (button height, text scale, padding, radii). Public styles take semantic roles/variants (SurfaceRole, TextRole, ButtonVariant, TextSize) rather than raw colors. Low-level addRect/panel still accept colors for data-driven drawing (color swatches, overlays).
Ownership guidance
Capacity prewarming
UI arrays retain their high-water capacity and therefore stop allocating after warmup. Editor hosts that need deterministic allocation-free CPU frames can reserve construction and triple-buffer publication storage up front:
world.reserveUiCapacity(
256 * 1024, // float count, not bytes
2048, // focusable/layout widget estimate
1024, // persistent ID cache estimate
);These are capacity ceilings, not per-frame allocations. Exceeding them grows the relevant retained buffer normally. GPU dynamic vertex buffers are independently grow-only per frame slot.
Replacing the built-in UI
ui.Provider is the type-erased interface between World and UI frame production. It abstracts lifecycle and output instead of copying every built-in widget into a vtable, allowing a third-party immediate-mode or retained system to keep its own control model:
const provider = hikari.ui.Provider{
.context = custom_ui,
.vtable = &custom_ui_vtable,
};
world.setUiProvider(provider);
world.useGameUi();The vtable receives begin_frame(context, input, width, height), set_display_scale(context, scale), finalize_frame(context), and vertices(context). The provider context is externally owned and must remain alive while registered.
The renderer-facing contract is a stream of ui.QuadRecords — one 96-byte record per primitive carrying four clip-space corners, four RGBA8 colours, a UV rectangle and the page/mode/range metadata — expanded into six vertices per record on the GPU by instance (src/hikari/src/ui/compositor/record.zig). Each provider also publishes one immutable ui.Atlas for the frame. Solid primitives use the atlas's white texel and glyphs select coverage or MTSDF reconstruction through vertex metadata, preserving the existing single-draw overlay pass. Providers flattened together must use the same atlas; a system requiring per-command scissor rectangles or multiple materials needs a future draw-list extension and must not call Metal or D3D12 directly from application code.
Keep responsibilities separated as follows:
- The engine owns
ui.UiContext, layout, widget interaction, themes, generated vertices, the built-in UI shader, and backend rendering. - A game owns the screens, labels, actions, visibility rules, and scene entities that compose its interface.
- A platform backend must not implement widget behavior or expose native renderer types to game UI.
Scene entities are the preferred presentation controllers today. This means unloading a scene naturally removes its UI and avoids putting screen-specific behavior into a global game subsystem.
Rendering contract
Responsive layout does not create renderer work per widget. Rectangles, glyphs, backdrops, panels, and controls are flattened in visual order into one contiguous CPU vertex stream. The completed stream is copied into the render-frame snapshot, uploaded as one UI vertex buffer, and submitted by the final UI pass with exactly one draw call when the stream is non-empty.
New widgets must preserve this batching contract. They should append geometry to ui.UiContext; they must not create their own GPU buffers, pipelines, descriptor changes, or draw submissions.
Current scope
In (host UiContext): mouse/keyboard/gamepad immediate UI — stacks, fields, buttons, checkboxes, toggles, sliders, scroll, lists, trees, tabs, splits, modals, themes, flex spacers, focus navigation. Game hi.ui() is the subset in the first paragraph.
Out (tracked in game-sdk backlog): multiline edit, custom drawing, deeper a11y, stick-hold / left-stick nav, production text shaping. Immediate scrollArea paints vertical overflow thumbs; retained chrome paints and drags whichever axis overflows (scroll_x / scroll_y).
Flex/justify reposition only — they never resize interactive widgets. App code describes UI via ui.UiContext; composition stays in ui.Compositor.
Editor retained chrome (panels, docks, strings, bindings): UI and editor.
Implementation reference
- Public entry point (barrel):
src/hikari/src/ui/ui.zig - Style/value types (Theme, Length, StackStyle, ...):
src/hikari/src/ui/style.zig - Internal layout engine (Layout, geometry/length math):
src/hikari/src/ui/layout.zig - The stateful
UiContextfacade:src/hikari/src/ui/context.zig; domain modules undersrc/hikari/src/ui/context/(scopes,frame,config,interaction,widgets,stack,focus,draw) - Geometry and overlay composition:
src/hikari/src/ui/compositor.zig - Bindings, edit results, and inspector ergonomics:
src/hikari/src/ui/ergonomics.zig - Default font atlas:
src/hikari/src/ui/atlas.zig+src/hikari/src/ui/fonts/inter/(Inter, page 0) +src/hikari/src/ui/fonts/geist/(Geist, page 1). Editor chrome defaults to Geist; game HUD stays Inter. - Sample game HUD:
src/games/example/src/session_ui.zig - Render-frame publication:
src/hikari/src/scene/world/world_render.zig - Render-graph pass:
src/hikari/src/graphics/rendergraph/passes/ui.zig