Skip to content
hikari
RenderingEditorAIToolchainDocumentation
GitHub
hikari

© 2026 Flying Rat Studio.
All rights reserved.

Explore the engineDocumentationContributorsLicenseBack to top
Documentation / Guides
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
Guides20 min read

User interface

On this page
On this pageQuick startFrame lifecycleSprite images and nine-sliceTheming game UIPaddingGroup transformsCross-axis alignmentTextRich text: styled spans on one lineWorld-anchored UIResource barsTruncating rich textGrids and large collectionsAnimationFocus and deviceMenu rows: one button as the whole hit surfaceControlsFilling the remainder (panels)Worked exampleThemes, tokens, and environmentOwnership guidanceCapacity prewarmingReplacing the built-in UIRendering contractCurrent scopeImplementation reference Back to top

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, …).

PageCovers
This pageQuick start, frame lifecycle, themes, ownership, rendering contract
UI layoutCoordinates, lengths, stacks, justify/flex, scroll/clip
UI widgetsControls, focus, modals, identity, icons

Editor chrome: UI and editor. Tutorial: First UI.

Quick start

Build scene-owned UI from an entity update callback:

zig
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.

json
{
  "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:

zig
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.

zig
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.

zig
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 / .end within the available width; wrapped text aligns per line. Needs a resolved width (max_width or 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. text forwards 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.

zig
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:

zig
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:

zig
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.

zig
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 trailing as 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

zig
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:

zig
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.

zig
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.

zig
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:

zig
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:

VariantPlateOutline
primary / destructivefillednone
secondaryfilled surfacehairline
ghostnone at rest; wash on hover/pressnone

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:

zig
// 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, …) — label is 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. SliderStyle carries step, width, track_height, thumb_width.
  • ui.dropdown(…, DropdownStyle) — set text_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:

PathRole
src/ui_showcase/theme.zigpalette, the spacing/type/metric scale, sprite wrappers, composite widgets (beginPanel, meter, menuRow, beginRow, keycap, promptStrip)
src/ui_showcase/screens.zigthe five screens and all their state
src/entities/ui_showcase_entity.zigcomponent wiring, font slots, pointer mode
tools/gen_ui_atlas.pyregenerates 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:

zig
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).

zig
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:

zig
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:

zig
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 UiContext facade: src/hikari/src/ui/context.zig; domain modules under src/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
PreviousTime of dayNext UI layout

Documentation follows the current engine checkout.

Snapshot cc148c75Source docs/guides/user-interface.md
On this pageQuick startFrame lifecycleSprite images and nine-sliceTheming game UIPaddingGroup transformsCross-axis alignmentTextRich text: styled spans on one lineWorld-anchored UIResource barsTruncating rich textGrids and large collectionsAnimationFocus and deviceMenu rows: one button as the whole hit surfaceControlsFilling the remainder (panels)Worked exampleThemes, tokens, and environmentOwnership guidanceCapacity prewarmingReplacing the built-in UIRendering contractCurrent scopeImplementation reference Back to top