Skip to content

Halcyon UI

Halcyon is the engine’s player-facing UI system: a settings screen, a scoreboard, a HUD panel. You describe what the screen should look like in ordinary C#, and Halcyon works out what changed, lays it out, and emits a flat list of draw commands.

It is also what the developer tooling paints with. The debug HUD, the world gizmos and the DigitalHeaven overlay’s windows do not go through widgets — they are pinned to screen coordinates or world-projected, which Halcyon’s layout deliberately does not express — but they go through Halcyon’s draw list, which is the part of Halcyon that has no layout in it at all. See the developer surfaces.

Halcyon borrows deliberately from two places.

Flutter supplies the structure: immutable widget descriptions, a long-lived element tree they reconcile into, and the constraints-down/sizes-up layout protocol. The value of that protocol is that layout is a pure function of the tree and its constraints — a child never reaches up for its parent’s size, so the same tree under the same constraints always produces the same result, which is what makes the whole thing testable without a window.

S&box supplies the reconciliation and the styling posture: a plain keyed diff rather than a virtual DOM, and the understanding that a UI system’s styling layer eventually wants to look like CSS whether or not it is authored as CSS.

CSS conventions are followed on purpose. Every style and layout property mirrors a standard CSS name and semantic: padding, gap, background-color, border-radius, flex-grow/flex-shrink/flex-basis, justify-content, align-items, white-space. Two things follow from that. Anyone who knows CSS already knows what these do, and a stylesheet-and-selector authoring layer can be added later that resolves into the same structs without any of this changing.

A widget is an immutable record describing what should be on screen. Building one is cheap; they are created and discarded freely.

public sealed record SettingRow : StatelessWidget
{
public required string Label { get; init; }
public required string Value { get; init; }
public override Widget Build(BuildContext context) => Ui.Row(
[
Ui.Text(Label),
Ui.Space(),
Ui.Text(Value, color: UiColor.FromBytes(150, 150, 160)),
],
alignItems: AlignItems.Center,
padding: EdgeInsets.Symmetric(horizontal: 12f, vertical: 6f));
}

StatelessWidget builds from its own properties. StatefulWidget pairs with a State<TWidget> that survives rebuilds and calls SetState when something it owns changes:

public sealed record Toggle : StatefulWidget
{
public required string Label { get; init; }
public Action<bool>? OnChanged { get; init; }
public override WidgetState CreateState() => new ToggleState();
}
public sealed class ToggleState : State<Toggle>
{
private bool _on;
public override Widget Build(BuildContext context) => Ui.Box(
onClick: () => SetState(() =>
{
_on = !_on;
Widget.OnChanged?.Invoke(_on);
}),
style: new BoxStyle { BackgroundColor = _on ? Accent : Idle, BorderRadius = 4f },
padding: EdgeInsets.All(6f),
child: Ui.Text(Widget.Label));
}

SetState never rebuilds on the spot. It marks the element dirty and returns; the rebuild happens on the next UiTree.Update(). That is what keeps a build a function of state at a single instant rather than a function of the order events happened to arrive in.

WidgetCSS analogueWhat it does
Flexdisplay: flexRow or column, with Gap, Padding, JustifyContent, AlignItems.
FlexChildflex: g s bA marker declaring a child’s Grow, Shrink and Basis. Unwrapped by the parent; never becomes an element.
SpacerEmpty space that claims leftover main-axis room.
BoxdivSizing, padding, background, border radius, border, and an optional click handler.
TextWidgettext nodeA run of text, wrapped to its constraints.
Stackposition: relative overlayOverlapping children anchored by a nine-point Alignment.
Positionedposition: absolute with top/leftFills the space its parent offers and places ONE child at an absolute offset inside it, measured against unbounded constraints. The offset moves the child and never resizes it, so a child larger than the box — or partly outside it — keeps its full size and is simply clipped. This is how a floating window states where it is.
ScrollViewoverflow-y: autoA vertical viewport that clips and owns a live scroll offset. A MaxHeight above zero makes it shrink-wrap to content up to that ceiling and scroll past it. RevealKey names a descendant to scroll into view. Scrollbars and ScrollbarStyle decide the gutter.
VirtualListoverflow-y: auto over a windowed row setA bottom-anchored list that builds only the rows the viewport can see. Index 0 is the oldest; the offset is measured from the end, so growth at the newest end never shifts what you are reading. Carries the same Scrollbars and ScrollbarStyle.
Scrollbarthe bar of a overflow: scroll boxNever authored. A scroller mounts one as its own trailing child, and it reads every number it draws off the parent through IScrollAxisSource. See Scrollbars.
Popupposition: absolute anchored to a triggerAn anchor plus a floating surface. Only the anchor takes part in layout, so opening a popup cannot move anything; the surface is sized to its own content up to the viewport, painted after the whole tree and clipped by nothing.
EditableTextthe editable part of inputA single-line editable run: caret, selection, click-to-caret, horizontal scroll. Focusable by construction.
TextFieldinputEditableText inside a padded Box that swaps style while focus is within it. An optional Leading widget rides inside the frame, ahead of the text — how the settings search field carries its magnifier.
SelectableText::selection on a text nodeA text run that belongs to a numbered line and paints the slice of it a SelectionRegion says is selected. Not focusable and not editable.
SelectionRegiona selection rootOwns the drag gesture over the selectable runs beneath it, and paints the whole-line bands once a selection spans more than one line. Lays out as its child.
KeyListeneronkeydown on a containerBinds keys for a whole subtree, seeing whatever the focused element declined. Paints nothing and lays out as its child.

Stage C2 added the input controls a settings screen is made of. All five are controlled: none holds the value it renders, so the single source of truth stays where the caller keeps it (for the settings screen, a Preference<T>). A null handler leaves the control drawn but inert rather than removing it — and inert means inert: a control with no handler does not respond to the pointer either, so the same widget doubles as a readout. Everything that does carry a handler picks up hover and pressed paint automatically, derived from its own style.

WidgetCSS/HTML analogueWhat it does
ButtonbuttonA labeled, clickable chip. A thin wrapper over Box — it exists so a call site says “button” — plus the one thing a button owns that a box does not: an Enabled flag. See below.
Switchinput type=checkboxA pill track with a round thumb that slides between the ends, track color crossfading with it. Stateful, because the travel is an AnimationController the widget owns; composed from boxes, because a click is something the tree already routes.
Sliderinput type=rangeTrack, fill and a draggable thumb, with arrow-key stepping and Home/End. ThumbInset insets the thumb’s travel from both ends, for the case of a thumb narrower than its track that has to ride inside it; a thumb larger than its track leaves it at zero. Has an element of its own, because a drag needs a local coordinate and reconstructing one from absolute rectangles would be the second coordinate path the hit-test contract exists to prevent.
Segmenteda radio groupA row of mutually exclusive chips, keyed by label so filtering an option set moves the element with the option.
DropdownselectOne value plus an option list that hangs off the chip as a floating menu. Built on Popup, so opening it moves nothing around it; the menu is as wide as its widest entry, never narrower than the chip, and scrolls once it reaches MaxMenuHeight.
CanvascanvasA fixed-size box painting an explicit list of rectangles at explicit local positions, clipped to its bounds. The escape hatch from layout for geometry computed by something other than a layout algorithm. The pixel grid rounds what it emits like everything else; a canvas whose shapes are a lattice declares Unit so the cell itself is rounded to whole device pixels first.

Ui supplies short factories — Ui.Row, Ui.Column, Ui.Box, Ui.Text, Ui.Button, Ui.Flexible, Ui.Space, Ui.Layers, Ui.Scroll, Ui.Popup, Ui.VirtualList, Ui.DragSurface, Ui.SelectableRun, Ui.Selection — so a Build method reads as a shape rather than a wall of object initializers. The records remain the full-fidelity form; reach for new Box { ... } whenever a factory would take more arguments than it saves.

Ui.Button is the reusable chip: a rounded box, one run of text, and the paint states a click implies.

Ui.Button(
"Cancel",
onClick: OnCancel,
enabled: !busy,
fontSize: FontAtlas.SmallFontSize,
color: theme.Muted,
hoverColor: theme.Text,
style: new BoxStyle
{
BackgroundColor = theme.Menu,
BorderColor = theme.Border,
BorderWidth = 1f,
BorderRadius = Button.DefaultRadius,
},
hoverStyle: ..., pressedStyle: ...);
MemberMeaning
LabelThe text drawn inside the chip. Required.
OnClickFired on a press and release inside the chip. Null draws it inert.
EnabledFalse turns it off — see below. Defaults true.
DisabledOpacityThe alpha multiplier a disabled chip paints at. Defaults to Button.DefaultDisabledOpacity (0.5).
Style / HoverStyle / PressedStyleThe three paints. A null hover or pressed style is derived from Style.
PaddingInset from edge to label. Defaults to Button.DefaultPadding.
WidthA fixed outer width, or null to shrink-wrap the label.
FontSize / Color / HoverColorThe label’s size, its ink, and its ink under the pointer. HoverColor is the only reason this widget is stateful.

Disabled is four behaviors, not one. A chip with Enabled: false does not fire, does not take its hover paint, does not ask for the pointer cursor, and fades whole — fill, border and label all multiplied to DisabledOpacity. Each is a separate way to get “disabled” wrong, so each is pinned by its own test against an enabled control that differs in nothing else; a widget that only dropped the click would still glow under the pointer and still promise, with its cursor, that it was going to do something. Losing the cursor needs no rule of its own: the cursor follows the click handler, so dropping the handler reverts the chip to the ordinary arrow. HoverStyle and PressedStyle are pinned to the resting paint rather than merely left underived, because a call site that handed in an explicit hover style would otherwise still light an unusable control up.

Fading the whole object, rather than flattening it, is the convention for a chip that is looked at on its own — a floating control with no row around it for a flattened surface to sink into, which is the case the loading box’s Cancel is. Only alpha moves; hue, radius and border width are untouched, so it stays the same object seen dimly rather than a second palette nobody declared. The settings screen keeps its own convention for controls that sit inside a row (ink to Disabled, surface flattened to the card’s value), where taking the chip away is what removes the affordance. Both say the same thing in the place it reads.

A Popup is positioned relative to its anchor, never to the viewport. That is the whole design: every floating surface a UI actually needs hangs off something, and a coordinate-positioned overlay would have to be told where its anchor ended up, which is the one number only layout knows. Placement picks the side (Below/Above), Alignment picks the edge that lines up (Start, End, or Stretch for “exactly the anchor’s width”), and MinWidthFromAnchor floors the surface at its anchor’s width without pinning it there.

The surface is sized to its own content. It is measured against an unbounded width and then laid out at exactly the width that measurement reported. The unbounded pass is the whole trick: a surface is normally a column of rows stretched to a common width, and a stretching column offered a loose ceiling fills it — so measuring a menu against the viewport reports the viewport every time, whatever is in it, which is “every menu runs to the right edge of the screen” in one sentence. Offered no ceiling, stretch degrades to the widest row. The second, tight pass then puts that width back so the rows still stretch to a common edge. The screen is the only ceiling, and a surface that reaches it scrolls inside itself.

Placement is a request. PopupElement.Place resolves it once the anchor’s absolute position is known — layout cannot answer “would this run off the bottom”, because it never learns where it ended up. A surface with no room on the side it asked for takes the other one when that fits, and is then slid back inside the viewport on both axes. Slid rather than shrunk: a menu that resized itself near a screen edge would re-wrap its own labels as it moved.

Dismissal lives in UiTree, not in each caller. A press outside an open popup dismisses it; Escape dismisses the innermost one and consumes the key, so a menu closing over a paused game does not also unpause it. Both arrive as OnDismiss, and a popup with no OnDismiss opts out of both — which is what a completion list wants, since it is dismissed by its own editing rules rather than by clicking away from it.

Two consequences worth knowing before using one. A dismissing press is not swallowed: it still reaches whatever is under it, so these menus are not modal, and clicking a pill while a menu is open both closes the menu and toggles the pill. And the surface paints above every layer, so it does not inherit a Transition’s opacity, translate or blur — a screen that owns popups closes them when it hides.

VirtualList builds its children during layout rather than build: only the rows intersecting the viewport, plus Overscan pixels either side, become elements at all. Each is wrapped in a Box keyed by index and clipped to its own rect, and its measured extent is cached against that index — so a row that scrolls away keeps contributing its real height to the scroll range instead of reverting to EstimatedItemExtent. The estimate is only ever used for rows that have never been on screen, which is why scrolling far up stays cheap without the list lying about how long it is.

It is bottom-anchored by construction, because the thing it exists for is a scrollback. The offset is measured from the end, PinToEnd glues the newest row to the bottom, and OnScrolled reports every offset change so an owner can drop follow-mode the moment the user scrolls away.

A scroller mounts its bar as its own trailing child, not as a piece of its own painting. That single decision buys three things from machinery that already exists: hit testing walks children last to first, so the bar takes a press before the content under it; drawing walks them first to last, so the bar paints on top of that content; and hover, press, pointer capture and the cursor request all arrive the way they do for any other element. ScrollbarElement owns the drag gesture and nothing else — every position it draws is read from its parent through IScrollAxisSource, and every position it commits is written back through the same interface.

IScrollAxisSource exists because the two scrollers store their position in opposite directions. A ScrollElement counts from the start of the content; a VirtualListElement counts back from the end, which is what makes follow mode an offset of zero rather than a correction. Every number on the interface is measured from the START, so the inversion lives in one method on the virtual list and a bar never learns which kind of list it is attached to. A test pins the two producing an identical thumb for identical content, at an offset deliberately off the midpoint of the travel — at the midpoint the two coordinates are the same number and a list that had forgotten to invert would agree by accident.

The gutter is the default, and it is reserved unconditionally

Section titled “The gutter is the default, and it is reserved unconditionally”
ModeGutterPainted
Autothe defaultReservedWhenever the content overflows
AlwaysReservedAlways, as a full-length thumb when nothing scrolls
OverlayNone; the bar floats over the trailing edgeWhenever the content overflows, then fades after FadeHoldSeconds
NeverNoneNever; the wheel still scrolls

The reservation is a function of the policy alone and never of the measured overflow, which is the part worth understanding. A gutter that appeared when the content grew would narrow the content, which rewraps its text, which changes its height, which can remove the overflow that summoned the gutter — a layout oscillation with no fixed point and no way to reproduce it reliably. Reserving unconditionally costs fourteen pixels and has no failure mode at all. It also means the content is laid out inside the remaining width, so a bar never covers a word, and the scroll position is legible without moving the pointer to summon it.

Tone follows the same budget as everything else: neutral at rest, accent on touch. Any panel-heavy screen has three or four bars on it, so a resting thumb is white at 0.18 alpha — furniture — over a lane at 0.05, and the brand accent arrives only under the pointer. The thumb also widens from 6 px to 8 px inside its 14 px hit band, because near-white has nowhere left to lift to. The client’s one definition of all of this is HalcyonScrollbarTheme; Halcyon itself ships the neutral half, since it has no theme and cannot see the preference store.

A press on the lane pages one viewport toward the press. It does not jump to the pressed position — a click that teleports a reader to an arbitrary point in a document loses their place with no way back. A press on the thumb starts a drag measured against the thumb the user can actually see, so the thumb does not slide out from under the hand that grabbed it.

The bar declares CursorKind.Arrow explicitly rather than declaring nothing. Declaring nothing is indistinguishable from an arrow on an empty screen and wrong everywhere a bar is actually useful, which is beside text: an I-beam region under the bar would otherwise win the cursor and invite the player to type into a scrollbar.

Two prerequisites landed with it. VirtualListElement now tracks a row count of its own, because every one of its window routines indexes Children directly and an appended bar would otherwise be measured as a row and displace every real one. And PointerState carries ScrollDeltaX beside the vertical delta, with UiTree routing once per non-zero axis — two scalars rather than a vector, because the axes are consumed independently: a horizontal gesture inside a vertical-only list has to reach whatever outside it can use one, on the same frame its vertical component was swallowed. Wave 1 draws vertical bars only; the plumbing under them is per-axis throughout, so horizontal is a second case in existing arithmetic rather than a rewrite.

Ui.DragSurface is a Box with drag hooks whose HoverStyle and PressedStyle are pinned explicitly to its own Style. That is the entire point: leaving them null is what asks a draggable Box to derive a lifted paint, and a window body that lit up under the pointer would be claiming to be a button. A drag surface is background — it must react to the gesture and to nothing else. Press targeting still prefers a deeper interactive child, so a slider sitting on one keeps its own press.

UiTree.SetRoot(widget) declares what should be on screen; UiTree.Update() applies it. The declared tree is diffed into the retained element tree, which is what actually owns state, scroll offsets and laid-out geometry.

The matching rule is short:

  1. A new child widget matches an existing child element by its explicit Key if it has one, searched across the whole sibling list so a reordered child finds its element wherever it moved to.
  2. Otherwise it matches by child slot — but a keyed element never answers to its slot, so keyed and keyless siblings can be mixed without the keyed ones being stolen.
  3. In both cases the runtime widget type must match. A mismatch unmounts the old element and mounts a new one.

There is no virtual DOM and no patch list. The widget tree is the diff input, and it is discarded immediately afterward.

The practical consequence: a list of things that can reorder, be inserted into, or be removed from wants keys. Without them, slot is identity, and reordering two rows swaps their state rather than moving it.

The root gets the same test, so a host may re-declare its entire UI every frame — the natural shape for a game loop — without losing a scroll position to it. Only a different root type tears the tree down.

Constraints flow down, sizes flow up, parents position children.

A BoxConstraints is a min/max envelope on each axis. Tight means min equals max and the child has no say; loose means the minimum is zero. A parent loosens before measuring a child it intends to size itself around — passing its own tight minimum down is how a label inside a min-width panel ends up stretched.

The flex algorithm is a CSS subset:

  • Each child’s basis is its declared Basis or, failing that, its measured content size (CSS flex-basis: auto).
  • Gaps are reserved before anything is distributed, so an overflowing child can never squeeze the spacing away.
  • Surplus space is handed to the grow factors; a deficit is taken from the shrink factors weighted by each item’s own size, exactly as CSS does, so a wide item gives up more than a narrow one with the same factor.
  • Whatever survives is what JustifyContent distributes. This is why a row containing a growing child ignores justification entirely — the grow already consumed the slack.
  • AlignItems places children on the cross axis; Stretch degrades to Start on an unbounded cross axis rather than forcing an infinite size.
  • Overflow is reported, not resized. The container clips; CSS does the same.

The main-axis half of this is a pure static function, FlexSolver.Solve, which takes a span of FlexItem and writes a span of FlexPlacement. It touches no widgets and no elements, which is why it can be tested with a handful of numbers.

These are gaps to be filled on demand, not non-goals: flex wrapping, percentage sizes, aspect-ratio constraints, transforms, margins (padding covers today’s cases), reversed flex directions, space-around/space-evenly, and align-self.

Absolute positioning exists in exactly one shape — Positioned, an offset applied to a single child inside the box the parent offered. There is no right/bottom anchoring and no positioned stacking context; a surface that wants to sit against the far edge still measures its own offset.

Text measures through an IFontMetrics abstraction — Advance, LineHeight, Ascent — so layout has no idea what a font file is. The core assembly ships only that interface; FontAtlas in Engine.Client implements it with stb_truetype (see the font pipeline).

Wrapping is greedy first-fit: UI labels are short, ragged edges do not matter, and greedy is the only variant that stays linear and is therefore safe inside a layout pass that may run more than once per frame. Newlines always break. A word too long for any line is broken between characters rather than left to paint outside its panel.

BoxStyle and TextStyle are the resolved style layer: plain value structs holding the properties an element actually paints with.

new BoxStyle
{
BackgroundColor = UiColor.FromBytes(24, 24, 28),
BorderRadius = 6f,
BorderColor = UiColor.FromBytes(60, 60, 70),
BorderWidth = 1f,
}

Every property is a plain value — a color, a float, an edge inset — with no delegates, lookups or anything else that could not be tweened. Nothing interpolates between two BoxStyle values today: the transition engine that ships composites whole subtrees, and per-property animation arrives with the stylesheet layer. The shape is the precondition, not a claim about current behavior.

BoxStyle.Gradient is a two-stop linear ramp, the analogue of CSS’s background-image: linear-gradient(...). It supersedes BackgroundColor exactly as a CSS background image covers the color underneath it, is cut to the same BorderRadius, and sits under the same border.

new BoxStyle
{
Gradient = GradientFill.Linear(
from: UiColor.FromBytes(6, 6, 8).WithAlpha(0f),
to: UiColor.FromBytes(6, 6, 8).WithAlpha(0.94f),
direction: GradientDirection.ToLeft,
exponent: 1.9f),
}
MemberMeaning
From / ToThe two stops, authored in sRGB like every other UiColor.
DirectionToRight, ToLeft, ToBottom or ToTop. Arbitrary angles are not expressible.
ExponentA gamma on the ramp’s position: the mix factor is position^Exponent. Above one the fill holds near From; below one it does the reverse. Zero — what a default struct carries — reads as 1, so a gradient naming only its stops behaves like CSS’s linear-gradient.

Two stops, not N. Every ramp the engine has wanted is a fade between one color and another, most often a scrim fading to nothing, and that is the shape that stays a fixed-size, value-comparable struct with no allocation. The exponent is what keeps two stops sufficient: it curves the fill in the shader rather than approximating a curve with extra stops. Something that genuinely needs three stops can be two boxes.

It mixes in sRGB, then linearizes. The attachment re-encodes on store, so a flat fill is linearized in the vertex stage and blended in linear light. The gradient deliberately runs the other way round: it interpolates the two stops in the space they were authored in and linearizes the result. Interpolating in linear light instead would put the ramp’s perceptual midpoint nowhere near its geometric middle — linear is proportional to radiance and the eye to roughly its cube root, so a linear-space fade dumps almost the whole visible transition into a narrow strip at the dark end. Mixing in sRGB is also what CSS does by default, so a ramp authored against a browser lands the same here.

It is dithered. The mix factor carries the same noise field the flat fill does, scaled by the ramp’s own span so the jitter stays proportionate whatever the stops are. Perturbing the factor rather than the output color is what lets one noise term move the color and the alpha in step. Without it, a wide low-contrast ramp — the menu scrim is the worst case, eighty display levels spread across nine hundred pixels — bands visibly.

The interface has always carried a fine noise over its surfaces. It is the same noise term, now with an amplitude: client.ui.noise is a peak-to-peak figure in 8-bit steps, and it rides a seventh push-constant lane into halcyon_rect.frag — so every rectangle Halcyon draws is noised by the same number, panels, cards, scrims and switch tracks alike, with no per-call-site opt-in to forget.

Three things it is deliberately not:

  • Not a post-process. A full-screen noise pass would noise the world too, and would have to be re-applied over UI drawn after it. The noise belongs to the surfaces, so it is applied where the surfaces are shaded.
  • Not applied to glyphs. Text is already carrying antialiasing coverage at a fraction of a pixel; jittering it a step either way is legible as a shimmer, not as texture. Only the rect path is noised.
  • Not in linear space. The offset is added in sRGB, before srgbToLinear, which is what makes “steps” mean the same amount of visible noise over a dark panel and a light one. Adding it after would make the same number nearly invisible on the dark surfaces the interface is mostly made of.

The default is 8, which is visible without being a texture; 0 turns it off entirely, and 32 is the ceiling.

One number drives the gradient dither too. A ramp is where banding actually happens, so it is tempting to give gradients their own hidden dither floor that the preference cannot reach — and that is exactly how the two would drift apart until a scrim visibly did not match the panel on top of it. Instead the gradient path perturbs its mix factor by noiseOffset() / reach, dividing out the ramp’s own span so the output jitter is the same number of steps whatever the two stops are, and a ramp with no reach falls back to the flat path’s noise rather than coming out conspicuously smooth. Turning the noise off therefore also turns off the anti-banding, which is the honest trade: it is one effect with one control, not two.

The field used to be interleaved gradient noise, evaluated per pixel — cheap, stable, and laid out on a lattice of diagonal lines. At the low end that lattice is invisible; at the amplitudes this preference actually reaches it is the first thing the eye finds, and the effect reads as a repeating pattern rather than as texture.

What replaced it is the tile PUI uses — assets/noise/noise_256_monochrome.webp from the Flutter UI package, copied to Assets/Noise/ so the engine and a PUI app carry one noise field rather than two that almost match. Engine/scripts/make-ui-noise.ps1 bakes it to Assets/Noise/noise-256-field.r8: PUI’s alpha times its gray, folded into one signed byte per texel (127 means “leave this pixel alone”), 64 KiB embedded straight into DigitalHeaven.Engine.Client and uploaded once as R8Unorm. There is no decoder, no mip chain and no file to ship. UiNoiseField owns the constants; halcyon_rect.frag reads it with texelFetch — no filtering, no scaling, one texel to one device pixel, exactly as PUI paints it, which is why the specks do not grow with the interface scale.

Two places this parts company with PUI, both on purpose:

  • Added, not composited. PUI modulates the tile to an opacity and composites source-over, which pulls a surface toward the tile’s own gray. Its slider stops at three hundredths; this one goes to 32 steps, where that shift would visibly lift a dark card. The baked field is mean-zero and is added, so it is the same specks with no tint.
  • Scrambled per block. A Flutter surface is a few hundred pixels of noise seen once. A full screen is a grid of 256-pixel tiles seen side by side, which is close enough to wallpaper to notice. Each block hashes its own coordinates into one of the eight dihedral symmetries of the square plus an origin inside the tile, so no two blocks on a screen present the same arrangement. Nothing is blended across the seams and nothing needs to be — the field is white noise, so a flipped or shifted sample of it is just as valid a sample and joins its neighbor invisibly.

The one honest cost: a photographic distribution spends fewer of its samples at full swing than the triangular one it replaced, so at the same step count it reads a little softer.

Animation. GradientFill is a value struct whose stops and exponent a component-wise lerp would walk with no special case. Direction is the one member that could not be blended: it is a discrete axis choice, and half way between “to right” and “to bottom” is a diagonal the type cannot express, so a future lerp must switch on it.

Interaction states are derived, not authored

Section titled “Interaction states are derived, not authored”

Nothing in the theme names a hover color. A control’s hover and pressed paint is derived from its own base style by lifting every color it carries toward white — UiColor.Lift(amount), applied to the background, both gradient stops and the border, with alpha left alone:

ConstantValueUsed for
BoxStyle.HoverEmphasis0.08Style.Hovered() — the pointer is resting on it
BoxStyle.PressEmphasis0.16Style.Held() — it is being held down
BoxStyle.BareScrimAlpha0.6the scrim an unpainted control gains instead, since it has no fill to brighten

Deriving rather than authoring is what keeps this theme-agnostic: a chip restyled to any color keeps a correct hover state, and the brand palette gains no new entries. It also means every clickable Box in the engine — settings chips, the console’s pills, log rows, the sidebar rail — lit up the moment this landed, with no call site changes.

Only what acts lights up. ResolveStyle returns the base style untouched when OnClick is null, so a box used as a panel and a switch used as a readout stay perfectly still under the pointer. A control that wants something other than the derived paint sets HoverStyle/PressedStyle explicitly, and passing default(BoxStyle) for both is the opt-out — the menu links take it, because a link there is a word on the backdrop and a scrim behind it would put a surface where the design has none.

Keyboard focus stays a ring, not a brightness step, so a focused control and a hovered one never look alike: Slider draws FocusRingColor at FocusRingWidth around its thumb while focused and leaves the thumb’s own fill exactly as authored.

The accent is a ramp, and the ramp is arithmetic

Section titled “The accent is a ramp, and the ramp is arithmetic”

The same instinct applies one level up. ThemePalette grows from one seed — #C478B8 by default, the brand accent — and every tinted surface in the interface is derived from it through TonalRamp, which works in OKLCh: convert sRGB to Björn Ottosson’s OKLab, read it as lightness, chroma and hue, then hold the hue exactly, replace the lightness with a fixed target, and scale the chroma by a fixed fraction. Four tones fall out:

ToneLChroma ×DerivedWhere it lands
TonalWash0.220.40#281125A hovered rail row — present, but quieter than a selection
TonalContainer0.300.52#41203CThe selected rail row’s rounded container
TonalTrack0.560.85#995B8FA switch that is on, a slider’s fill — the loudest tone, and still under the accent
TonalOn0.860.35#E3C7DEInk and thumbs written on those surfaces

OKLCh rather than HSL, because HSL’s “lightness” is a channel average and means something different at every hue: a 30% yellow and a 30% blue are nowhere near each other on screen, so an HSL ramp needs hand-correction per hue and stops being a recipe. OKLab’s L is perceptual, so one number produces the same apparent step whatever the accent is. Chroma is scaled rather than set, so a muted brand color yields a muted ramp instead of being pushed to a saturation it never asked for, and it is pulled down at both ends — a very dark tone at full chroma reads as mud, a very light one as a pastel cast.

Since it is arithmetic, changing the accent re-derives the whole look. A test pins the four bytes above (so a silent recolor of the UI is caught), and separate tests pin the structure — the tones climb in lightness in the order they are used, every tone keeps the accent’s hue, and swapping in a blue, a green or a yellow lands the same lightness targets. Pinning alone would be a change detector; structure alone would pass on a ramp that had drifted several shades.

client.ui.themeColor picks the seed, and the whole ramp follows. ThemePalette.From(seed) derives the accent, the focus ring, the text selection, the four tones above and the console’s own accent fill and selection tints in one shot; UiTheme holds the live palette, and HalcyonSettingsTheme, HalcyonConsoleTheme and HalcyonChatTheme read through it rather than owning static readonly colors. That is why the settings screen’s card tint, a switch that is on, the console’s completion highlight and the chat caret all move together the moment the preference changes — none of them is authored.

SeedValue
orchid#C478B8The brand pink, and the default — the interface’s color is unchanged
rose#E05A78A warm red-pink, one step around from the brand accent
ember#E0784CBurnt orange, the warmest seed
gold#D8B24AMuted gold — the lightest seed, so its container tone reads warmest
fern#6FBF73A mid green, saturated enough to stay green in the darkest tone
teal#4FBFB0Blue-green, the coolest seed that still carries visible chroma when dark
azure#5A9FE0A clear mid blue
iris#8C7CE0Blue-violet, one step around the cool way

The list is deliberately short and pre-picked rather than a free color wheel: a seed has to survive being pushed to L 0.22 and to L 0.86 and still read as itself at both ends, and most arbitrary colors do not. Appearance → Theme draws the choice beside five labeled swatches — the seed and the four tones it produces — so the ramp is visible before it is applied rather than after.

One tone is not seeded. Danger (#E05A5A) and the three tones derived from it stay fixed, because a destructive action must not turn out green because somebody picked a green interface.

The wire carries the resolved triple, not the ordinal. The client sends its seed as a packed 0xRRGGBB in clientInfo, and again in ClientThemeColor whenever it changes mid-session — so a server that has never heard of a preset added later still stores a usable color. Protocol 27 is exactly this pair of additions; the server stores the value per player and replicates nothing, pending a use in chat.

The raw accent is still the accent. It is spent only where something must read as the highlight rather than as a surface: the focused text field’s border, and the focus ring. Everything that is a filled surface — a selected row, a switch track, a slider fill, a category chip on a search hit — takes a ramp tone. A chip that was previously the accent at fill strength is now TonalOn, because it is a label saying which section a hit came from, not a highlight competing with the control beside it.

Widgets and elements also carry a Classes set:

Ui.Box(classes: ["panel", "settings"], child: /* ... */)

Nothing consumes it yet. It is the binding point for a future stylesheet-and-selector layer, which would match on classes together with the live pseudo-state the elements already track (Hovered and Pressed — the :hover and :active equivalents) and resolve down into the same BoxStyle/TextStyle structs described here.

UiTree.PointerUpdate(pointerState) routes a pointer sample. Hover is set on the whole ancestor chain, matching :hover. A press targets the nearest clickable ancestor of the deepest hit, so clicking the label inside a button presses the button. A release outside the pressed element cancels the click. The wheel goes to the innermost ScrollView under the cursor, and walks outward only when that one has nowhere left to go — per axis, so a scroller that refuses the horizontal component still consumes the vertical one and passes the rest outward on the same frame. Because a bar is a child of its scroller rather than a sibling of it, a notch delivered over the gutter reaches the scroller through the same walk.

Open popup surfaces are hit-tested before the root and drawn after it, which is the same ordering stated twice: a floating surface is on top, so it takes the pointer first and paints last. A press that misses every open surface dismisses them, and then carries on to whatever it actually landed on.

Only elements that paint or act absorb a hit

Section titled “Only elements that paint or act absorb a hit”

Element.ConsumesPointer decides what happens when a point lands inside an element’s box but hits none of its children. A Box answers true — it is a surface, and a surface stops a pointer even where it is empty. The pure-layout elements — Flex, Stack, Layer, Spacer and the component wrapper — answer false, and the hit path unwinds back past them so the sibling underneath gets its turn. It is the same idea as Flutter’s HitTestBehavior.deferToChild.

This is not a nicety. Screens anchor themselves by filling the viewport with an invisible column, and a screen’s root is never unmounted (an exit has to animate, so the mount latch keeps it). With every element consuming its own box, the first screen ever opened kept a full-screen invisible column in the hit path forever and swallowed every click aimed at anything below it — which is precisely how the pause menu came to be drawn, animated, hovered by nothing and completely unclickable once the console had been opened once.

The same trap has a smaller shape, and it bit a second time: a Box used only to fix a width. Ui.Frame is that box with ConsumesPointer = false, and it exists so a sizing wrapper cannot become a surface by accident. The chat feed’s carrier was a plain Box, so once a session had shown chat, its 560×62 rectangle took the pointer over the menu underneath it for the rest of the process — and the links inside that band never lit under the pointer and never answered a click. A link that is dead and a link that is covered look identical, so it was reported as “Disconnect is grayed out”, which is worth remembering: when a control reads as disabled and its enablement rule says it is not, suspect the hit path before the predicate. Any wrapper that paints nothing is a Frame.

StateSet onRebuilds?
Hoveredthe whole ancestor chain under the pointerno
Pressedonly the press target — the nearest clickable Box ancestorno
Focusedthe focused element and its ancestors (:focus-within)yes

Hover and press deliberately do not dirty a build: they change every frame the mouse moves, and a rebuild per sample would make pointer motion the most expensive thing the UI does. They are therefore resolved in DrawBox.ResolveStyle(hovered, pressed) picks the paint at the moment of painting. A widget that needs structure to change on hover, rather than paint, can still ask for it explicitly with Box.OnHoverChanged plus SetState; the menu links do exactly that to recolor their text.

Focus is the exception because it moves at human speed, so it can afford to rebuild — which is what lets a build read context.IsFocused.

Hit-test positions descend in each element’s own local space. A parent converts before recursing, in exactly one place, and a scroll offset is not special — layout has already baked it into the child’s offset, so there is only ever one conversion per level.

This is pinned deliberately rather than by accident. A system that re-bases into local space on the way down but un-bases in global space on the way back is correct whenever the error cancels — at unit scale, or at zero scroll offset — and only becomes visible once a transform sits above a scrolled container, one bug per scrollable axis. UI scaling will eventually sit above ScrollView, which is exactly that arrangement, so the tests already compose nested scrolled containers at differing nonzero offsets where a mismatch cannot cancel out. Click-to-caret rides on the same contract: a text field converts the local point it is handed, plus its own horizontal scroll, into a character offset — it never looks at an absolute rectangle either.

Clipping falls out of this for free: a scroll view rejects a point outside its own box before any child sees it, so content scrolled out of view is unreachable even though its absolute rectangle still says it is up there.

The client routes the pointer into Halcyon only when ImGui does not want it: WantCaptureMouse wins outright, and on the frames it does, Halcyon is handed a position far offscreen rather than simply skipped — so a widget that was hovered or pressed sees the pointer leave instead of freezing mid-interaction.

The OS cursor changes shape to say what is under it: a hand over anything clickable, an I-beam over text you can edit or select, a diagonal over a window’s resize grabber. Halcyon resolves which one out of the walk it already does — there is no second system of cursor regions, and therefore no way for a cursor to be declared and silently not apply.

Every Widget carries a CursorKind?. Null means “no opinion”, and the deepest hit element that has one wins, so a popup entry beats the popup, and a wrapper can never override what it wraps. Nothing with an opinion under the pointer resolves to Arrow.

Most call sites never set it, because the two common shapes are derived:

ElementAsks forWhen
BoxPointerOnClick is non-null
EditableTextTextalways, read-only included
SelectableTextTextalways — the I-beam promises “draggable text”, not “typable text”
SliderPointerOnChanged is non-null

That one rule about Box covers every button, toggle, segmented chip, dropdown and menu entry in the engine, since all of them are a clickable box underneath. It also gets disabled right for free: “disabled” throughout this codebase is a null handler — the graying is only a color — so a control stops asking for the hand at exactly the moment it stops responding.

Dragging is deliberately not enough to infer a shape. A whole-window drag surface would otherwise put a hand over an entire panel, so a grabber declares its own explicitly — the scrollbar states Arrow, and a window’s resize frame states the diagonal or axis of the handle under the pointer.

While a press is captured, the captured element’s declaration wins over whatever the pointer is now above. This is the cursor half of the rule OnPointerDrag already encodes for coordinates: a drag keeps working off its own bounds, so it has to keep looking like itself too — a resize leaves the corner that started it on its first moved pixel, and a diagonal that snapped back to an arrow there would be worse than none at all.

UiTree.Cursor is the frame’s answer, surfaced through HalcyonLayer and UiLayer to the host, which hands it to ClientRuntime.SetCursorShape. WindowHost maps CursorKind onto GLFW’s standard cursors and only touches the mouse when the shape changes. Diagonal-resize and not-allowed cursors exist only in GLFW 3.4, so each kind maps to an ordered list of candidates and the first supported one is used — a corner handle degrades to ResizeAll rather than to a bare arrow. A frame where ImGui captures the mouse parks Halcyon’s sample far offscreen, so the fallback to Arrow happens on its own with no extra gate.

The developer overlay’s windows are hit-tested by arithmetic rather than by elements — they predate the widget tree — so OverlayWindowInput resolves their edge and corner cursors against the same zones its drag model uses, and ClientHost prefers the widget tree’s answer whenever it has one, matching the order the two are painted in.

A widget opts in with Focusable = true, and the tree holds exactly one focused element — focus is a property of the tree, not a flag several elements set independently. UiTree.FocusedElement is an element reference rather than a key or a path, because the element already is the identity that survives reconciliation: a rebuild that updates a field in place keeps focus, its caret and its selection for free, while a rebuild that genuinely unmounts the field drops focus through the same unmount path that clears hover and press.

CallDoes
Focus(element)Focuses it. Throws if it is unmounted or not Focusable — both are silent-failure shapes otherwise, where focus appears to move and no key ever arrives.
ClearFocus()Focuses nothing, handing the keyboard back to the host.
FocusNext(backward)Tab / shift+Tab. Declaration order — a pre-order walk, which is the order the widgets were written and drawn in — wrapping at either end.
KeyDown(keyEvent)Routes to the focused element, then up its ancestors. Returns whether the UI consumed it.
TextInput(char) / TextInput(string)The same route for typed characters.
WantsKeyboardTrue while anything is focused. The one flag a host gates its own bindings on.

Focus lights the whole ancestor chain (Element.Focused, BuildContext.IsFocused) exactly as hover does, so a card can draw a ring around a field it merely contains — the :focus-within equivalent. Unlike hover it also marks that chain for rebuild, which is what lets a build read context.IsFocused and pick a style; it is affordable precisely because focus changes at human speed, never per frame.

The tree itself binds two keys, and only as a fallback after every element has declined, so a widget can always override them: Tab moves focus and Escape drops it. Escape belongs to focus rather than to any field — “get me out of this control” is a property of being focused — and with nothing focused the tree does not consume it, so the host’s pause menu still opens on the next press.

Text state lives in a TextEditController, not in the widget and not in the element:

private readonly TextEditController _name = new("halcyon");
Ui.Field(_name, placeholder: "Type something", width: 288f, onSubmit: Rename)

The controller owns an immutable TextEditValue of (Text, Selection, Composing). Every edit produces a new value — the operations in TextEditing are pure static functions, and one that changes nothing returns the same instance — and assigning it raises Changed. Application code reads and writes controller.Text; the widget is free to be rebuilt from scratch every frame, which is exactly what an animating panel does.

TextSelection is directional, like Flutter’s: a BaseOffset anchor and an ExtentOffset that moves. The caret is a collapsed selection where the two are equal. Shift+arrows, shift+home/end and a mouse drag move only the extent, so a selection dragged backward past its anchor and forward again lands where a person expects; Start/End are derived minimum and maximum, computed where rendering needs an ordered range. Composing is a TextRange, empty by default and reserved: IME is not implemented, but the slot is in the model so CJK input can be additive rather than a change to the value’s shape.

Caret geometry goes through CaretMetrics, which measures with the same IFontMetrics the glyphs are drawn from — OffsetToX for the caret and selection rectangle, XToOffset for click-to-caret using the half-advance rule, and Reveal for the horizontal scroll that keeps the caret inside a field narrower than its text. Surrogate pairs are one caret stop in every direction, and word-wise movement follows the desktop rule: skip whitespace, then consume a run of one kind (word characters or punctuation).

EditableText is the bare editable run and TextField is the styled wrapper around it — a Box that swaps to FocusedStyle while focus is within, which is the whole reason it is a StatefulWidget. Cut, copy and paste go through UiTree.Clipboard, an IUiClipboard; it defaults to an in-memory implementation, so a headless tree is fully functional and the Engine.Client binding to the window’s clipboard is one small class. Pasted text is flattened to a single line before it is inserted.

A log is read, not edited, but it still has to be selectable — and a second selection model beside TextEditController’s would have been two answers to “which characters are highlighted?”. Two widgets extend the editing machinery instead of duplicating it:

  • SelectableText (Ui.SelectableRun) is a text run that also knows which line it belongs to and which slice of that line is selected. It lays out exactly as Text does, through the same TextLayout and the same IFontMetrics, and maps a point to a character with the same CaretMetrics.XToOffset a click into a field uses.
  • SelectionRegion (Ui.Selection) wraps a subtree and owns the gesture. It is ClaimsPress, so a drag that starts over selectable text selects rather than dragging the window the text sits in, and it reports every change through onSelectionChanged — the selection itself is caller state, exactly like a TextEditController.

The model is LineSelection: an anchor (line, offset) and a focus (line, offset), both indices, never pixels. Its one interesting rule is that the two regimes are derived rather than latched:

  • While the focus is on the anchor’s line, the selection is character-preciseRangeOn returns the crossed characters, and the run paints its own highlight.
  • The moment the focus lands on any other line, the selection is whole linesRangeOn returns the entire line for everything between the two, and the region paints one full-width band per line so column gaps leave no holes.

Because the regime is a function of the current focus and not a mode that was entered, dragging back onto the starting line restores character precision for free.

SelectionRegion locates a point by walking its own children in local space, and a point that lands in a line’s vertical band but not on a run — the unselectable chrome beside a message, say — still resolves to that line. Runs outside the region’s box, which a virtualized list overscans into existence, are ignored. Nothing here is a pixel threshold: escalation is topological, so a tall line and a short one behave the same.

CopyText assembles the result by asking the caller for each covered line’s text and joining with '\n', skipping lines the caller no longer has. Only what is inside a selectable run can ever be copied, which is the structural reason a row’s metadata columns stay out of the clipboard.

WindowHost already buffered typed characters for the console, so Halcyon reads the same buffer; the physical keys it needs — the editing keys, both sides of every modifier — are a short table in HalcyonKeyMap, since printable characters arrive as text rather than as keys. KeyboardSampler turns polled key states into press and auto-repeat events, capping the repeats one long frame may produce so a hitch cannot delete a paragraph.

Delivery happens after Tick and before SetRoot, so a focus move is reflected by the build that immediately follows it. It is gated on ImGui’s WantCaptureKeyboard exactly as the pointer is gated on WantCaptureMouse, and a frame where Halcyon has nothing mounted drops focus and clears the sampler rather than leaving the keyboard captured by a field that no longer exists. In the other direction the host suppresses its own backtick and Enter bindings while a focused console is the front window, so neither a backtick nor a chat-opening Enter typed at the prompt can also toggle a screen behind it. Escape is suppressed only while Halcyon has something open that owns it — a dismissable popup or the completion panel — which UiLayer.PopupOwnsEscape reports before the key is routed, because the shell and Halcyon are fed by two independent key paths and a popup cannot consume a key on the shell’s behalf after the fact.

That whole gate is one function, ClientKeyRouting.Route(ownership, key), which answers a ClientKeyTarget (None, Withheld, Menu, Console, Overlay, Chat, ChatClose) rather than a bare bool — so every shell binding is decided in one place and can be tested from both sides of the seam, and the console’s open/close contract is a case in it rather than a condition scattered through the host. Its first rule is the chat prompt: while chat is composing, every key except Escape is withheld from the shell, including the movement keys, because a prompt that owns the keyboard must own all of it — typing “was” while a chat line is open cannot also walk you forward. Escape is the exception, and it is deliberate: it routes to ChatClose, so the shell can always get the player out of a prompt rather than depending on the prompt still holding the keyboard to hear its own cancel key. A rewrite of the pause/console contract relocates that one rule rather than hunting conditions.

A subtree can claim keys of its own with KeyListener (Ui.Keys(child, onKey)): it sees any key the focused element declined, before the tree’s own Tab and Escape fallbacks, which is how a screen binds a key regardless of which of its fields happens to hold focus.

UiTree.Draw(drawList) walks the laid-out tree and appends to a DrawList — an append-only buffer of RoundedRect, TextRun, TexturedRect, PushClip/PopClip and PushLayer/PopLayer. There are no GPU types anywhere; a texture is an opaque int.

Clips are intersected as they are pushed, so the rectangle on every PushClipCommand is final and a backend can hand it straight to a scissor without tracking a stack. Layers compose the same way: PushLayer returns the values already combined with every enclosing layer, so a backend never has to multiply opacities itself. Offsets add and opacities multiply; blur sigma deliberately does not accumulate, because two nested blurs of sigma 2 are not a blur of sigma 4.

Everything lands on the pixel grid, exactly once

Section titled “Everything lands on the pixel grid, exactly once”

Layout is fractional; the draw list is not. A flex solver that rounded as it went would accumulate its rounding into the gaps between siblings, and a centered child would jump a whole pixel every time its parent grew by one. So layout keeps its fractions, and the conversion to whole pixels happens in one place: DrawList, as each command is recorded, with the enclosing layer’s translate already folded in. The Vulkan backend rounds nothing at all — by the time geometry reaches it, it is already on the grid.

The rule lives in PixelGrid, and DrawList.Grid is the copy in force. Its Scale is the device pixel ratio, so geometry is snapped on the device grid rather than the logical one; it is 1 on every display DigitalHeaven currently drives, and the arithmetic costs nothing when it is.

Snapping is half-up, not MathF.Round. Banker’s rounding is right for sums of measurements and exactly wrong for geometry: it is not monotonic across the half-pixel boundary, so a panel sliding continuously to the right sends 0.5 to 0 and 1.5 to 2 and the gap between two edges flickers between one pixel and two as the animation crosses each boundary. Half-up moves every coordinate the same direction, so a block of content stays rigid while it travels. There is no relax-during-animation: content moves in whole-pixel steps, which is what keeps subpixel text antialiasing filtering against the same phase from the first frame of a transition to the last.

Four rules, by what is being snapped:

ShapeRuleWhy
A boxAll four edges roundTwo boxes sharing an edge keep sharing it. Rounding the size instead would let each of them round the shared edge its own way and leave a seam.
A hairline — a rule, a divider, an icon stroke at or below ThinShapeMaxExtentWidth first: the thickness rounds once, then the far edge is placed that far from the snapped near edgeA 1.5-pixel bar whose edges round independently is 1 pixel thick at one subpixel phase and 2 at the next, so a row of nominally identical strokes comes out ragged and an animated one pulses. A hairline has no neighbors to need exact adjacency, so it trades that for constant thickness. Strokes are centered by parity, so a gizmo’s dark halo and the fill it backs stay concentric.
A clip or a layer’s boundsOutward: floor the top-left, ceiling the bottom-rightA scissor that rounded inward could land half a pixel inside a fill that rounded outward and shave a column off the content it was only meant to bound.
A text originRounds in X and Y; the line pitch is rounded tooConsecutive baselines then differ by exactly one integer, so a paragraph cannot drift off the grid down its own height. Glyph advances and ascents are rounded in the metric rather than at paint time, so measurement, hit testing and painting agree.

A diagonal line is left exactly where it was asked for, position and width both — a rotating gizmo arm has to sweep, not step.

Persistent origins — a dragged window’s position, a scroll offset — snap where they are written, not only where they are drawn, so hit testing and painting agree about where a thing is. Momentum may stay fractional inside the scroller; the offset it publishes does not.

Why any of this matters: a rounded rect is rasterized as a quad that is exactly its own rectangle, while the signed-distance edge is antialiased symmetrically about that boundary — half a pixel inside, half outside — and the outer half falls outside the quad and is never shaded. On a whole-pixel edge that costs nothing. On a fractional edge the left and top edges spread one border pixel over two columns that between them still carry its full weight, while the right and bottom edges simply lose the column that fell outside. The result is a button that looks like it was drawn with a thicker pen along its top and left, which is what flex layout at fractional offsets produced all day.

HalcyonRenderBackend (Engine.Client) consumes a DrawList. It mirrors the ImGui backend’s proven shape — dynamic rendering, one alpha-blended depth-free pipeline family built for the swapchain format, the caller owning the rendering block — and differs in two deliberate ways.

Textures are per-draw from the outset. Every registered texture gets its own combined-image-sampler descriptor set and an opaque one-based id, so TexturedRect works with any of them. The ImGui backend binds only its font atlas, which is why it cannot draw an image at all.

There is no vertex buffer. Each command is a four-vertex triangle strip generated in the vertex shader from gl_VertexIndex, with everything that varies carried in a 96-byte push-constant block (viewport, rect, uv, color, border color, shape). That trades a draw call per command for no buffer management whatsoever, which is the right trade at Halcyon’s volume: a screen is tens of quads, and the rounded-rect SDF needs the rectangle’s extents per fragment anyway, so the data would be duplicated into every vertex regardless. Text is the one place the cost shows — a quad per glyph — and batching glyph runs is the obvious optimization if a paragraph-heavy screen ever needs it.

Six fragment shaders share one pipeline layout:

ShaderDraws
halcyon_rect.fragRounded rect fill and border, from a shared SDF in halcyon_sdf.glsl. The border is the ring between the shape and the same field offset inward, which keeps the stroke an even width around the corners. It also evaluates the gradient fill, reusing the uv push-constant lane (dead in this pipeline) for the ramp’s second stop rather than growing the six-vec4 block every Halcyon stage shares. A box with no gradient takes a uniform branch and records the same commands it always did.
halcyon_texture.fragA textured rect masked by the same rounded coverage. Repeat wrap, so a UV rectangle wider than one unit tiles.
halcyon_text.fragA glyph quad on the grayscale path: one coverage sample from the single-channel atlas, stem-darkened and put through the mask correction in halcyon_text.glsl. No sRGB decode of the atlas — coverage is a geometric fraction and gamma has no meaning for it.
halcyon_text_subpixel.fragThe same glyph quad on the subpixel path: five atlas taps become three per-channel coverages, each corrected independently, emitted as two outputs for dual-source blending. Bound only where the device supports it.
halcyon_blur.fragOne axis of a separable gaussian over an offscreen layer target. Sigma and the per-tap stride ride in the shape lane; the source is premultiplied, which blurs correctly channel-for-channel.
halcyon_composite.fragDraws a finished layer target back over the frame, premultiplied, scaled by the layer’s composed opacity. Deliberately no sRGB decode: the target carries the pass’s own sRGB format, so the sampler has already linearized.

Translate and opacity from a layer are applied CPU-side while walking the list: both are affine over a subtree, so the backend folds the translate into each rectangle’s position and the opacity into each color’s alpha. No offscreen target, no GPU work — for an unblurred layer, which is every layer of a settled UI.

The pass renders into the post-tonemap LDR window, after the HDR resolve, and records the frame’s three lists in one go — see the developer surfaces for what goes in which and why the order is what it is.

Blur is a second entry point, not a second pass

Section titled “Blur is a second entry point, not a second pass”

A blurred subtree has to be composited offscreen before anything can sample it, and Renderer.RecordUiPass records inside an already-open vkCmdBeginRendering block, which Vulkan forbids nesting. So the backend has two entry points and the renderer calls both:

  • Prepare runs before the swapchain block opens (immediately before the scene-to-UI barrier, where the tonemap pass has provably closed its own block). It walks the frame’s draw list for layers whose sigma clears client.ui.blurThreshold, and for each one replays that layer’s commands into an offscreen target sized to the layer’s bounds grown by 3σ and clamped to the viewport, then runs the two-pass separable gaussian across a ping-pong pair. Each of those is a rendering block of its own.
  • Record then draws one composite quad per prepared layer in place of replaying its commands, positioned by the layer’s composed translate and modulated by its composed opacity.

The subtree is replayed offscreen with its own translate and opacity divided back out, so the ordering is exactly the design note’s: blur wraps the content, translate wraps that, opacity outermost. A blurred layer nested inside another is replayed sharp inside the outer composite — two nested gaussians are not a third gaussian, and Halcyon’s own UI does not produce the case.

Targets are pooled per frame-in-flight slot and grow-only, so a viewport change resizes at most once and a shrink simply uses less of an existing image. Renderer.DrawFrame already waits on that slot’s fence before recording, which is what makes recreating and rewriting a slot’s images mid-recording safe without a retirement queue.

FontAtlas implements IFontMetrics against StbTrueTypeSharp (MIT, pure managed), referenced by Engine.Client only — the Halcyon core assembly stays dependency-free. It bakes an alpha-only 2048×2048 atlas of printable ASCII at four sizes (small 11, body 14, heading 22, title 44) from the JetBrains Mono the engine already ships, uploaded as R8_UNORM — a quarter of the memory of the RGBA atlas ImGui bakes for the same glyphs. Every one of those sizes is baked exactly, so no size the UI actually uses is ever a scaled bitmap.

The atlas is rasterized at 3× horizontal oversampling and box-filtered back down by stb, which is what makes subpixel rendering possible without a second atlas: the stored texel already carries a third of a pixel’s worth of coverage. All four sizes together occupy about 563 000 of the atlas’s 4.19 million texels, so 2048 has room for several more sizes before it needs to grow.

Metrics and rasterization are split on purpose:

  • Advances, ascent and line height are analytic, scaled from the font’s own tables, so they are exact at any size, including sizes that were never baked. Layout therefore always agrees with painting.
  • Glyph images come from the nearest baked size and are scaled to fit. Off-size text is slightly soft; it is never mispositioned.

Shaping is advance-width only — no kerning, no ligatures, no bidi, no complex-script shaping — exactly as ImGui does it. A character outside the baked range substitutes ? in both the advance and the glyph lookup; measuring one and painting the other would silently drop characters while keeping the space they occupied.

Halcyon has two text paths and picks between them per draw. The subpixel path is ClearType-class: it places edges at thirds of a pixel by driving a display’s red, green and blue stripes independently. The grayscale path is the classical one, a single coverage value per pixel. Neither is a mode a player switches into for the session — client.ui.textAa sets a ceiling, and the backend decides, for every run, whether that ceiling can be honored this frame.

Coverage is not alpha: the mask correction

Section titled “Coverage is not alpha: the mask correction”

The UI composites in linear light against an sRGB attachment: the hardware decodes on load, blends linearly, and re-encodes on store. Coverage from a rasterizer is a geometric quantity — the fraction of the pixel the outline covers — and handing it straight to that blend puts a half-covered texel roughly a quarter of the way to the text color instead of half. Light text on dark thins out; dark text on light thickens. This is the same problem Skia solves with SkMaskGamma, and Halcyon solves it the same way.

TextAntialiasing.MaskAlpha solves for the alpha that makes the linear blend land where an encoded-space blend of the same coverage would:

  • The destination is not known at the draw call, so it is guessed the way Skia guesses it: the perceptual inverse of the text color, E_b = 1 − E_f. Light text is assumed to sit on dark and vice versa, which is true of every surface the UI actually draws.
  • Both endpoints go through the real sRGB transfer function, toe included, not a pow(2.2) approximation.
  • The correction is fixed at both ends — zero coverage stays zero, full coverage stays full — and monotonic in between, so it can never invert a gradient.
  • When the text color and its guessed destination are within LuminanceEpsilon in linear luminance, the correction is the identity. There is no contrast to correct and the solve would divide by nothing.

The subpixel path applies the same correction independently to each of R, G and B. That is not a detail: correcting the three channels by different amounts, or correcting a luminance and scaling the channels by it, is exactly how a subpixel renderer acquires a color cast. The luminance that selects the correction is computed once per run from the encoded text color and reused for all three channels.

The earlier empirical pow(1.45) blend curve is gone. It was a fit to this effect, and the derived correction replaces it.

Stem darkening, and the size it is aimed at

Section titled “Stem darkening, and the size it is aimed at”

One perceptual constant survives, and it is measured rather than chosen. StemDarkeningPeak emboldens coverage before it becomes an alpha, with the shape c + (1 − c)·c·amount — zero at both ends, largest at half coverage, so only the partial texels a thin stroke is made of move at all. FreeType calls this stem darkening, DirectWrite calls it enhanced contrast, Apple emboldens the outline outright.

The amount is aimed by rendering the same string in the same typeface at the same physical size through Halcyon and through Windows’ own ClearType, and summing the ink in each:

SizeEngine ink ÷ Windows ink, undarkenedShipped
110.7080.871
140.8300.830
220.8310.831
440.9010.901

22 px and 44 px define the band: a fixed systematic difference the constant is not trying to close. 11 px sits 12 to 19 points below it — a real deficit. 14 px does not: undarkened it measures 0.830 against 22 px’s 0.831, the same weight to within a thousandth. So the ramp runs full at 11 px, zero at 14 px and linearly between, and the peak is 0.45 because that is what measures 11 px back into the band (0.15 leaves it at 0.763, 0.30 at 0.816). An earlier shape that darkened everything below 22 px pushed 14 px to 0.937 — heavier than every other size on the sheet, including the 44 px display line.

Per-channel alpha over an arbitrary background cannot be expressed with one output. The subpixel pipeline declares two:

layout(location = 0, index = 0) out vec4 outColor; // text color, premultiplied
layout(location = 0, index = 1) out vec4 outBlend; // per-channel coverage

and blends with SrcColor = One, DstColor = OneMinusSrc1Color, so each channel is attenuated by its own coverage. This needs the Vulkan dualSrcBlend device feature. GraphicsDevice requests it and exposes SupportsDualSourceBlend; when a device does not have it the subpixel pipeline is never created, and the resolve below returns grayscale for every run — one code path, no branch at the call site.

The filter is ClearType’s own: two box-3 passes compose to [1, 2, 3, 2, 1] / 9. The atlas is already box-3 prefiltered by stb’s 3× oversampling, so the shader gathers three adjacent stored texels and gets the five-tap kernel exactly. That reach is why FontAtlas.AtlasPadding is 3 rather than 1, and why AtlasRightMargin exists at all: stb’s own padding clears the left and top edges only, and a tap two texels past the last column wraps into the next row, which is a different glyph.

Degradation is per draw, and it is a question about animation

Section titled “Degradation is per draw, and it is a question about animation”

TextAntialiasing.Resolve is a pure function, and every one of its conditions alone forces grayscale:

ConditionWhy
The ceiling is grayscaleThe player asked.
The device has no dualSrcBlendThere is no subpixel pipeline to bind.
The size is not a baked oneA scaled bitmap has already lost the stripe alignment the filter depends on.
Layer opacity < 1A fringe blended at partial alpha is a colored halo, not a sharper edge.
Text color alpha < 1Same, one level down.
The layer translate is not a whole pixelA transition is animating this subtree right now.
The run is inside an offscreen blur compositeThe composite is resolved and re-blended, so the destination the correction assumed is not the destination it lands on.

The interesting one is the translate. LayerValues carries only translate, opacity and blur sigma — no scale, no rotation — which makes a fractional translate a complete signal that a transition is mid-flight. The run’s own layout position is deliberately not tested: the filter is horizontal, and the backend rounds both the pen and the baseline before emitting a glyph, so a run laid out at y = 391.5 paints exactly the rows a run at y = 391 does. Testing it would be testing a number that is thrown away — and it was, which is why vertically-centered rows in the settings screen used to fall back to grayscale for no benefit.

Vertical scrolling keeps subpixel, and that is correct: ScrollElement moves content through layout Offset, and a vertical translation moves every fringe rigidly without changing any of them. Every desktop text stack does the same.

The evidence that the predicate holds is a capture pair rather than an argument. Shooting the whole UI timeline twice — once with the ceiling at subpixel, once at grayscale — every settled frame that draws text differs between the two runs, and every mid-transition and mid-fade frame is byte-identical. On a fading specimen the maximum per-pixel channel spread is 3 of 255, against 135 on the settled one.

KeyDefaultWhat it does
client.ui.textAasubpixelCeiling on text antialiasing. subpixel uses the display’s stripes; grayscale is the fallback for panels whose stripes run the other way, or for anyone who sees the fringing rather than the sharpness. Exposed in the options window under Appearance → Text
client.ui.textSpecimenfalseMounts a full-viewport specimen sheet: the same string at every baked size over dark, light and mid-gray grounds. For A/B-ing the two paths live

client.ui.scale magnifies the whole interface — every menu, panel, control and label — between 75% and 200% in 5% steps. It applies on the next frame from anywhere: the Interface scale slider at the top of the options window’s Appearance section, a console line, or Ctrl and the mouse wheel wherever a menu is open.

It is a layout input, not a transform. UiTree.UiScale is multiplied into every metric an element reads off its widget — Element.Scaled(float), Scaled(float?) and Scaled(EdgeInsets) — so a box’s padding, declared width and height, corner radius and border weight, and a layer’s translate, all come out of layout already magnified. Nothing is applied at draw time and nothing is applied at pointer time: hit testing runs against the same rectangles layout produced, so a magnified button is hit exactly where it is drawn, by the same code that hits an unmagnified one.

The consequence worth knowing about is that an authored width is magnified while the viewport is not — the display does not grow when the interface does. A screen that means “the whole viewport” is therefore handed displaySize / uiScale, and UiTree.ViewportSize stays in real pixels. Without that division a viewport-sized frame at 125% would be a quarter larger than the screen and drag everything anchored to its edges off the bottom and right.

Text is re-rasterized, not stretched. FontAtlas.Rebake(scale) re-bakes the whole ladder (11/14/22/44 × scale) into the same pixel buffer and the backend re-uploads into the already-registered image, so a wheel notch costs one repack rather than a new four-megabyte atlas. FontAtlas.MaxBakeScale and UiPreferences.MaxScale are pinned equal by a test, because a ceiling raised at one end only would produce blurry off-size text at the top of the range instead of a clamp.

client.ui.scale is not the same knob as PixelGrid.Scale. That one is the device pixel ratio, and it decides which grid commands are snapped onto; snapping keeps happening in device pixels no matter what the interface scale is.

The gesture is gated on a menu being up — the pause menu, the main menu, the options window, the console, the demo panel or the specimen sheet. During play the wheel belongs to the game, so the gesture does not exist there. Chat is deliberately excluded too: its feed scrolls, and a modifier that quietly stole those notches would be a worse trade than a player having to open a menu.

A claimed notch is consumed — it never also scrolls whatever is under the pointer — and it is claimed at the top of the frame, before the tree is built, so the readout is up on the same frame as the gesture. The layer only reports the notch; the host writes the preference, so the wheel, the slider and a console line cannot drift apart.

Changing the scale raises a small pill near the bottom of the screen carrying the current percentage and a Reset button back to the default. It uses the standard panel transition, so it arrives and leaves the way every other surface does.

It lingers for client.ui.scaleBarLinger seconds after the last change, and hovering it holds it open — the timer is re-armed on both edges of the hover, so the pill cannot fade out from under a cursor on its way to the one button it carries. A notch that the clamp turns into no change at all still counts as a gesture and still raises the readout; otherwise pushing against the end of the range would look like the wheel had stopped working. At the default the Reset button is drawn but inert, so the pill does not change width the instant the scale comes home.

KeyDefaultWhat it does
client.ui.scale1Interface magnification, 0.75–2.0, snapped to 5% steps
client.ui.scaleBarLinger1.4How long the scale readout stays up after the last change, seconds. Hovering it holds it open regardless

UiTree.Tick(dt) advances every registered AnimationController and marks the owning states dirty; call it before Update() or the frame paints one tick stale. A controller holds nothing but raw linear 0..1 progress and a direction. That is the design note’s central instruction: an exit is the entrance played in reverse, from wherever the entrance got to — not a second timeline authored to look like the first one backwards. Reversing at 0.4 resumes from 0.4, which a separate exit starting at 1.0 cannot do without a jump.

Curves are applied where the value is consumed, never in the controller, because one timeline feeds several properties that ease differently. TransitionRecipe.Evaluate(progress) turns progress into a LayerValues (translate, opacity, blur sigma — and deliberately no scale).

The standard panel transition, frozen from Engine/design-notes/halcyon-menu-transition.md:

PropertyValueCurve
Duration150 ms, one timeline both ways
Translate7 px vertical → 0ease-out-cubic, complete by 50% of the timeline
Opacity0 → 1ease-out-cubic, complete by 50% of the timeline
Blur sigma6 → 0ease-out, across the full duration
Scalenone

The front-loading is the whole effect: at 75 ms the panel is placed and opaque while the blur is still resolving, which reads as focus pulling in rather than as a crossfade. An optional reverse flag flips the travel direction and nothing else.

The content-arrival sibling is the same machinery with different numbers — 320 ms, 16 px, opacity 0.08 → 1, everything eased over the full duration, no stagger and no per-child delay.

new Transition
{
Visible = _open, // false plays the same timeline backwards
Recipe = TransitionRecipe.Menu,
Child = /* ... */,
}

Defaults live as named constants on TransitionRecipe so tests assert against one source of truth, and Engine.Client seeds a recipe from client.ui.* preferences each frame so they are tunable live from the console.

Nothing player-facing changes by default. client.ui.demo true mounts a Halcyon card — a flat surface, text at two sizes, a focusable text field, a toggle, a proportional bar and a scrolling list — wrapped in the standard transition and bound to the preference, so flipping the key demonstrates both the enter and the exit.

The field autofocuses, which does mean the demo panel holds the keyboard while it is up: Escape unfocuses it and a second Escape reaches the pause menu. That is acceptable for a developer-only panel that is off by default, and the alternative — a capture that cannot show a focused field or a caret at all — is worse.

PreferenceDefaultMeaning
client.ui.demofalseShow the demo panel
client.ui.transitionDuration0.150Panel transition duration, seconds
client.ui.transitionTranslate7Vertical travel, pixels
client.ui.transitionBlur6Peak blur sigma, pixels. Zero turns the offscreen composite off entirely
client.ui.blurThreshold0.05Sigma below which a layer skips the composite and draws direct
client.ui.blurMaxSigma16Ceiling on any layer’s sigma, bounding target size and tap count
client.ui.transitionArriveFraction0.5Fraction of the timeline over which translate and opacity finish
client.ui.panelOpacity0.98Opacity of panel and card fills. High because Halcyon blends in linear light, where the same alpha transmits far more of a bright surface behind the panel than a gamma-space toolkit would; the developer overlay’s chrome sits at the same value for the same reason
client.ui.caretBlink1.06Caret blink period in seconds, one full off-and-on cycle. Zero holds the caret solid
client.ui.textAasubpixelCeiling on text antialiasing; the backend still decides per draw
client.ui.textSpecimenfalseShow the text specimen sheet for A/B-ing the two paths
client.ui.noise8Noise over every Halcyon surface, peak to peak in 8-bit steps. Clamped 0–32; zero is off
client.ui.themeColororchidThe seed the accent and every tinted surface are derived from. One of orchid, rose, ember, gold, fern, teal, azure, iris

The pause menu and the main menu are one screen with two link sets. Stage C3 deleted the ImGui pause modal — the centered dialog and its full-screen fader are gone, not toggled off — and replaced it with a left-aligned column over the live scene, in the shape Garry’s Mod uses: the brand wordmark, then bold text links in groups, over a flat scrim that dims the whole viewport. Nothing is a card and nothing is centered; the world stays the thing you are looking at.

The title is the real mark, drawn as geometry

Section titled “The title is the real mark, drawn as geometry”

The menu’s title is the shipped DigitalHeaven wordmark, not its name set in the UI font. BrandWordmark holds the mark’s 98×5 block grid — the same art the CLI prints on the terminal and the same art Docs/DigitalHeaven.Docs/src/assets/logo.svg ships as 91 rectangles — merges each row into runs, and hands them to a Canvas. The whole grid is painted twice: once in the shadow color one grid unit below and right, then once on top, split into white for DIGITAL and the brand accent for HEAVEN by which side of the grid a run sits on.

Geometry rather than a texture, for three reasons that are all the same reason:

  • No sampling, so no filtering to get wrong. Halcyon antialiases a rectangle across one pixel of its distance field, so on a whole-number scale at a whole-number origin every edge lands on a pixel boundary and coverage is exactly zero or one. The mark is as sharp as nearest-neighbor at every size, with no texture upload, no image decoder and no mipmap decision. HalcyonMenuTheme.LogoScale is 3 — 588×57, the same optical weight the old two-line text lockup had — and it is a whole number on purpose: the grid’s rows are one unit apart, and a fractional scale would land those gaps on a half pixel where the antialias closes them up.
  • The mark is a lattice, and says so. The shapes are authored in grid units and the canvas is given Unit = LogoScale, so the cell is rounded to whole device pixels before any run is placed. A whole LogoScale is not enough on its own: client.ui.scale multiplies it, and 3 × 1.1 is 3.3, which rounds the mark’s stems to different widths and reshuffles them on every resize. Quantizing the cell instead means the mark grows in whole steps — unchanged at 110%, one unit bigger at 125% — which is what pixel art wants. Its drop shadow is measured in units for the same reason, so it stays exactly one cell behind the artwork and grows with it.
  • The canvas snaps, because everything does. The menu centers its column vertically, so its origin is half of whatever slack is left over — a fraction, most of the time. Every run shares that one origin, so they all round the same way; the canvas’s clip is rounded outward so the snap cannot shave the far edge.
  • The font atlas stays as it is. It bakes printable ASCII only, so the block character the art is written in has no glyph in it; rendering the mark as text would have meant widening the atlas for one string.

Tests pin the grid against the shipped SVG’s numbers — 91 runs, three units tall on a four-unit pitch, filling exactly 196×19 — and that no run straddles the boundary between the two words, which would silently repaint half a letter. A second set pins the mark and the links in device pixels across a sweep of magnifications and viewport heights, because a menu measured only at 100% on an even viewport is measured at the one setting where every pairing bug is invisible.

The links carry the same shadow the mark does, and carry it the same way: the shadow is declared on the run through TextStyle, not built as a second Text behind the first. The menu has no panel under it, so the shadow is the only thing keeping a white word legible over a white wall — and it is the surface where a shadow that drifts by a pixel against its own label is most obvious, because there is nothing else on that side of the screen to look at.

Which links appear is a function of one thing — whether there is a session:

GroupWith a session (pause)Without one (main menu)
1ResumeStart Game
2Options, ConsoleOptions, Console
3Disconnect, QuitQuit

The link set is not the only thing that answer decides. The scrim and the lens vignette belong to a session, not to the menu. A pause screen dims the world because there is a world behind it that you are being taken out of; a main menu has no world at all — ending a session dissolves the map and unloads it — so a full-viewport dim over an empty frame is dimming nothing, and a vignette is a lens over a scene that is no longer there. Both are multiplied by a session level that is 1 throughout a session and 0 at the main menu. Only the flat full-viewport box goes: the wordmark, the links and everything else the column paints are untouched, because they are the menu’s own art rather than the pause treatment.

That level is a ramp, not a switch, because the frame a session ends on is the frame the world starts dithering away on, and a dim that popped off while the map was still fading would announce the transition twice at two different speeds. client.ui.sessionFade is how long the ramp takes, and 0 there is a real off switch: both are gone on the same frame, with no animation at all.

The default is declared as RenderPreferences.DefaultWorldDissolve rather than as its own copy of 1, and a test pins the two together, so the treatment lifting on the same clock the world dithers away on cannot silently drift apart. Going the other way needs no ramp at all: a session beginning snaps the level straight back to 1, because the menu that was up disappears on the same frame and there is nothing left to ease.

MenuLinks holds that table and nothing else — no UI types at all — so “Resume and Disconnect exist exactly while a session does” is asserted directly rather than inferred from a rendered tree. A link that cannot act is absent, not disabled: the column is short enough that its shape reads as the state it is in.

The scrim behind the column is a single flat box over the whole viewport. It went through a run of flat bands (Halcyon painted rectangles and not ramps, and the bands were visible as banding — which is what put gradient fills in the draw command layer) and then a left-edge ramp, before landing on an even dim: a ramp of any width puts its own soft vertical edge somewhere on screen, and that edge is a shape the design never asked for.

Leaving a session is not leaving the process

Section titled “Leaving a session is not leaving the process”

Disconnect ends the session: the net client disconnects (which clears replicas, the local pawn, the map identity and the server’s commands and convars), the prediction state resets, and the two continuous motion-driven audio sources are silenced. The window, the renderer, the preference store, the audio device and Halcyon itself are all untouched — and so are the loaded map and the render scene, deliberately, because they are the menu’s backdrop rather than session state. Quit is that same teardown plus taking the window down: Dispose runs the identical seam first, so there is exactly one path and quit-to-desktop cannot drift from quit-to-menu. Start Game reconnects to the endpoint the client was launched with, so the round trip world → menu → world needs no relaunch.

There is no transport axis in any of this. ClientHost.ReconcileSession keys on state != ClientState.Disconnected and nothing else, so an embedded server is a session exactly as a remote one is: Disconnect is drawn and live while singleplayer is running (it shuts the local server down and returns to the menu, which is what the disconnect console command has always done), and a connect still in flight counts as a session from the first frame. A theory pins the link set against every ClientState, and another pins that every link the menu draws answers a click anywhere in its own hit box — the stronger guarantee that “absent, not disabled” buys, and the one both dead-link reports were of failing.

The main menu’s backdrop is whatever scene is loaded, not a bespoke system — the client builds a scene at window load independent of any connection, so there is always something behind the links. Source-style background maps are a later idea, not this.

Escape is a stack, and the main menu is its floor

Section titled “Escape is a stack, and the main menu is its floor”
WhereEscape does
A popup is openDismisses the innermost popup, and nothing else
Overlay upCloses the overlay, leaving whatever is under it
Console up with no pause layerCloses the console and hands the game straight back — it does not pause
Main menuNothing. There is no session behind it to resume; leaving is Start Game’s job
OtherwiseToggles pause, taking the options window and the console down with the layer

MenuEscapeCoordinator reports the layer it handled, so “handled by doing nothing” is a distinct outcome from “toggled pause”, “closed a console” and “closed the overlay”, and a test can tell them apart.

One press clears one thing, and never leaves a window behind. The console is a tool you use while playing, so the backtick opens it straight from gameplay without pausing anything, and while it is up Escape means that console rather than the pause menu. But it does not outlive the menu either: pressing Escape (or clicking Resume) over a paused game puts the layer and the console away together, so one press always ends with a clean screen. Bringing it back is one backtick.

That is the whole of the rule, and both halves matter. Without the first, a console over live gameplay would be a trap — Escape would pause the game behind the console instead of getting you out of it. Without the second, leaving the menu would strand a window over a running game and make you find a second key for it.

The options window is different in exactly one way: its open flag survives. It is furniture on the pause layer, so Escape never peels it off separately — it toggles the whole layer, the window goes down with the links, and Escape back in puts it right where it was left, at the size and position it was left at. The console’s visibility is spent by that same press, because a window the player just dismissed should not reappear uninvited. The two things that close the options window outright are its close box and the Options link, which toggles.

ClientKeyRouting.Route enforces the seam: a focused, front console takes only its own toggle and chat’s open key off the shell, and an open popup takes Escape for the frame it is showing — because the shell and Halcyon receive keys through two independent paths, so a popup’s consumption has to be asked about in advance rather than reported back after the fact. Everything else about what one Escape means is decided in MenuEscapeCoordinator, with the whole client in view.

Both windows stack over the link column rather than replacing it, and over each other. Keeping the links up is what makes Options and Console toggles a player can reach twice.

The console and the options window are siblings with a front, held in ClientUiState.FrontWindow:

  • A pointer press anywhere inside a window raises it above the other. That needs Box.OnPressInside, which fires on every element in the hit path, outermost first, on press — OnClick cannot do it, because it fires on release and only on the nearest clickable ancestor, so a press that landed on a slider inside the window would never reach the window.
  • Opening a window raises it too. Opening something you can neither see nor type into would be a strange thing to ask for.
  • The front window occludes the other and owns the keyboard. HalcyonLayer appends the two roots in stack order, so occlusion is ordinary z-order hit testing rather than a second mechanism.
  • Focus follows the front. A console that loses the front releases the keyboard on the same frame, and a console that regains it re-arms its autofocus with no click — the prompt is focused again by the next build.

The whole window is the move handle. There is no title bar to grab and no strip that lights up as the pointer crosses it: the heading, the padding and every gap between the controls all drag the window, and none of them react to being pointed at. A window is furniture rather than a control, and the earlier narrow drag bar was the opposite of that — a specific, hover-lit target you had to aim for on a surface that should simply move.

Ui.DragSurface is what makes it true, and it is one piece shared by both windows rather than two copies: the options window and the console’s card placement are the same call. A plain draggable Box derives a lifted paint on hover, and a window-sized box that lifts on hover lifts whenever the pointer is anywhere on the window, so the surface pins HoverStyle and PressedStyle to its own style. The controls inside keep their own presses — press targeting honors Element.ClaimsPress and the nearest clickable ancestor before it falls back to the drag surface, so grabbing a slider drags the slider, not the window.

Eight handles, and hit zones larger than what you can see

Section titled “Eight handles, and hit zones larger than what you can see”

Resizing is available from all four edges and all four corners. There is no bottom-right grip any more; the square that used to be the only way to resize a window has been removed outright, not kept beside the new handles.

Ui.ResizeHandles is one element laid over the window, and one element rather than eight boxes on purpose: the precedence between a corner and the two edges it overlaps is then arithmetic in ResizeZones.Classify rather than an accident of declaration order, and the cursor comes out of the same call that decides what a press would do. One hit test, one answer.

It paints nothing. The whole affordance is the hit zone and the cursor — the same discoverability a desktop window has, where you learn the edge is grabbable because the pointer changes shape as you cross it.

Every band is centered on the boundary it grabs. The preference names the band’s total width, and half of it falls inside the window and half outside — the way a stroke is centered on a path rather than drawn inside it:

Total band, across the boundaryInsideOutsidePreference
Edge band8 px4 px4 pxclient.ui.windowResizeEdge
Corner square20 px on each axis10 px10 pxclient.ui.windowResizeCorner

A cursor aimed at the edge of a window naturally lands on both sides of the hairline, so a zone that reached inward only turned the outer half of every aim into a miss. Straddling costs the inside half of what it used to have and gives the same amount back on the outside: the target is the same size, it is simply where the eye puts it. The totals are deliberately much larger than the visual they sit on, which is a one-pixel border — a hairline is not a target, and a miss silently starts a window move instead, which is the most annoying possible wrong answer. Both scale with UiScale, so a handle stays the same apparent size at any magnification.

ResizeZones.Overhang is the outward half of the widest band, and it is the one number the placement needs: the frame lays out larger than the window it covers by that much on every side, and is placed one overhang up and to the left of it. It has to be, because the hit walk rejects a point outside an element before that element’s own zones are ever consulted — a frame exactly the size of its window could not answer a point beside it. That is also why the frame is placed with its own Positioned rather than stacked with the window: a Stack anchors both children to the same corner, which would put every band an overhang out of place.

The outward half is input only; nothing about the painted window changes. A point in the overhang that is on no band is a miss like any other, so it falls through to whatever is behind — the window’s own body, a window underneath, the pause menu, the world. The layering rule is simply paint order: the frame is declared after its window and before nothing else, so a window’s outward band wins over anything behind that window and loses to anything in front of it. A band that would extend past the viewport is lost, and nothing compensates for it.

A corner is a square, not the point where two thin bands cross, and being larger than the edge band is exactly what gives it precedence in the region they share — over the whole straddled zone, outward half included, so a point diagonally off the window’s corner is the corner rather than either edge. A corner resizes both axes at once, which is the gesture people actually reach for, so it should be the easier of the two to hit.

The cursor map, resolved by ResizeZones.CursorFor and delivered through the ordinary hit-test cursor walk:

HandleCursor
North, SouthResizeVertical
West, EastResizeHorizontal
North-west, south-eastResizeNwse
North-east, south-westResizeNesw

The two diagonals are not interchangeable — north-west and south-east are the same line seen from its two ends, so they share a cursor and the other pair is its mirror. Getting them the wrong way round is invisible in a screenshot and obvious under the hand, so a test pins each pairing and pins that the two diagonals differ from each other.

ResizeEdge is a [Flags] enum where a corner is the pair of edges meeting at it (NorthWest == North | West), so the resize arithmetic asks one question per axis instead of carrying eight cases. Eight cases is eight places for a sign to be wrong.

The frame takes a hit only on a handle; a point in the body is a miss, which is what lets the window underneath keep every press that is not on an edge. It also declares ClaimsPress, which matters wherever the frame ends up with a draggable ancestor.

A window’s size is its own; position never changes it

Section titled “A window’s size is its own; position never changes it”

A window’s laid-out and painted size is a function of its own size alone. Where it sits never changes it. A window positioned partly or wholly outside the viewport keeps its full size and is simply clipped by the display’s edge: the off-screen part is not drawn, and nothing about the on-screen part moves, reflows or resizes. Text does not re-wrap, rows do not reflow, and a scrollbar does not appear or disappear as a window is dragged toward an edge.

The viewport clips a window. It never constrains one.

That has to be said as a rule because it was broken in a way no amount of correct geometry could fix. The stored rectangle was right — a drag past the right edge stored the full width, and the window sprang back to its real size as soon as the offset came down — but the window was placed by padding a viewport-sized frame, and padding does not only offset a child: it deflates the envelope handed down. The child’s maximum width was literally “whatever is left between the offset and the far edge”, so pushing a window right squeezed it, one pixel of width per pixel of travel. At an 860-px card on a 1237-px display the painted width went 860 → 739 → 430 → 121 → 10 as it was dragged across, while the stored size never moved off 860. A previous pass removed every clamp in WindowGeometry and changed none of that, because the constraint was never in the geometry.

Ui.Positioned is the fix and the whole of it. It takes the box its parent offers, measures its child against BoxConstraints.Unbounded, and moves it:

// Unbounded: the child's size is a function of its own declared size and nothing else.
child = Children[0].Layout(BoxConstraints.Unbounded);
Children[0].Offset = offset;

Taking the parent’s whole box is what makes the offset absolute within the viewport, and what keeps the part of the window that is on screen reachable by the pointer. It consumes no pointer of its own, for the reason any full-viewport wrapper must not: a root that has appeared once is never unmounted, and a viewport-sized surface would swallow the pause menu’s clicks forever.

Two consequences worth stating outright. A window may be larger than the display and is painted at every pixel of its size, for the same reason it may hang off one. And a negative corner is ordinary data throughout — clip rectangles, which are intersected as they are pushed, still bound drawing correctly for a window whose rectangle runs past any edge in either direction.

The pointer is what the screen restricts, never the window

Section titled “The pointer is what the screen restricts, never the window”

A window may hang off any edge of the viewport, by any amount. The single restriction is that the grab point — the place on the window the cursor took hold of — may not leave the viewport. WindowGeometry.Apply states exactly that by clamping the pointer on the way in and applying nothing at all on the way out.

That one rule is enough, and it needs no companion rule about not losing a window. The grab point is by construction a point on the window, at a fixed offset inside it for the whole gesture, so a window’s rectangle this frame puts that point precisely at the clamped pointer. Keeping it inside the viewport therefore keeps some of the window on screen — and since the whole window is the move handle, whatever is on screen is grabbable. Reachability falls out of the geometry instead of being a second clamp that has to be kept in agreement with the first.

The clamp used to be on the rectangle, and it was wrong in the way only using it reveals: you could not push a window off the side of the screen to get it out of the way, which is one of the main things people move windows for. It also broke resizing, twice over — a window already hanging off the left could not be widened by its right edge, because the far side was hauled back into view to make the rectangle fit, and a size ceiling of “no larger than the viewport” trimmed the same edge from the other direction. There is no size ceiling any more either; a window may be larger than the display for the same reason it may hang off it.

Resizing follows the identical principle. The dragged edge or corner follows the pointer, and the pointer is what is held inside the viewport; ResizeAxis knows nothing about the viewport at all, because a second clamp there is the whole-rectangle rule sneaking back in through the resize path. Edges the handle does not name are still copied from the anchor and still cannot move, and the minimum still pushes the edge that is moving.

Recovery is a response to the viewport changing, and to nothing else. A window legitimately parked half off screen can end up wholly outside it when the display shrinks — the OS window is resized, the resolution changes, client.ui.scale changes — and the grab-point rule cannot speak for a viewport that moved after the gesture. So WindowGeometry.Recover nudges a window back by the least amount that leaves client.ui.windowGrabMargin pixels of it in view on each axis, and it runs only on the frame the viewport actually changed, plus on load.

PreferenceDefaultMeaning
client.ui.windowGrabMargin64How much of a window is kept in view when the viewport changes under it, in pixels. Clamped 16–512. Comfortably larger than the 20 px corner square, so the sliver left on screen is real drag surface rather than nothing but resize band

Running it every frame instead would be the old bug back in a slower and much harder to see form: the window would creep toward the display behind the player’s hand. WindowGeometry.Reseat is where that decision lives, as a pure function, so it can be stated without a device to run it on — and it also declines to recover a pushed position that agrees with the live one, because that push is the window’s own gesture arriving back from the preference it was just written to, one frame after the button came up.

Both gestures need the pointer in a space that does not move, so HalcyonLayer keeps this frame’s sample and hands that to the geometry rather than the window-local point a widget callback carries. It is held in logical pixels — the pointer divided by UiScale — because a window’s position becomes a Positioned offset and its size becomes a declared width, and Halcyon magnifies both where the element reads them. Mixing the two spaces is not a rounding error: at a magnification of 2 the window travels twice as far as the pointer dragging it.

WindowGeometry.Apply works in edges, not in an origin and a length. An edge the gesture does not name is copied from the anchor and is therefore incapable of moving — the direct answer to a resize that walked the far side of the window. An edge it does name is the anchor plus the pointer’s travel, held apart from its opposite by the minimum. The minimum pushes the edge that is moving, so the one standing still keeps standing still; enforced the other way, dragging a left edge rightward shoves the whole window off the screen.

A grab ends when the window is taken away. A release is the ordinary path, and losing the pointer (focus goes elsewhere) already produced one. What did not was an element being unmounted or a whole root swapped while the button was still held — the tree dropped the press silently, leaving whoever owned the geometry believing the button was down. UiTree now ends the press properly in both, which is the same signal a release gives, so every drag handler already knows what to do with it.

Out-of-bounds delivery is what makes any of this reachable. A pointer-captured element keeps receiving OnPointerDrag after the pointer has left it — it has to, since the moment a window moves the pointer is outside what it grabbed, and a hit-test-only feed would stall the drag on its first frame. The delivered point stays in the element’s own local space in both cases: the tree uses the hit path’s accumulated conversion while the pointer is inside and reconstructs absolute − Rect.Position when it is not, which is provably the same number because Halcyon has no transform stack. ResizeHandles adds Rect.Position back on to report an absolute position, since that is what the anchor rule needs.

WindowGeometry is a handful of pure static functions — Apply, ClampPointer, ClampSize, Recover, Reseat, Center, Resolve — so the arithmetic is unit-tested without a tree, a pointer or a window. The interaction on top of it is tested through a real tree at fractional UI scales (1.05, 1.1, 1.25, 1.75) and odd window sizes, because a hit band or a drag delta that is correct at scale 1 on a round-numbered window has not been tested at all. The off-viewport cases are driven the same way — dragged past each of the four edges, with the grab point asserted to be exactly under the held pointer — and two capture frames photograph a window hanging off the display, one clipped at the top-left and one at the bottom-right.

The size-independence rule is asserted against the draw list rather than against the stored geometry, and it has to be: the stored size was already correct while the painted one was collapsing, so a test that read CardSize would have proved nothing. WindowSizeIndependenceTests compares the painted window rectangle before and after a drag past each of the four edges and the four corners, at five magnifications on an odd viewport, and compares the frame’s whole picture with the window’s own origin subtracted out — every rectangle, clip and text run — so a re-wrap or a reflowed row fails on the words before it fails on any number. One of them takes both pictures mid-gesture, since releasing re-seeds the position and hid the squeeze behind a restore.

Geometry persists as preferences, written once per gesture rather than once per frame, and lives in the layer between writes so a console edit to the same keys still applies on the next frame. A stored corner may be negative, since a window may be parked off the left or the top of the screen, so the lower clamp on those preferences is negative too and a saved off-viewport position round-trips unchanged.

Resolve is what makes a saved geometry safe to trust: the -32768 sentinel means “never moved” and centers at whatever the viewport is now, and a real position is recovered only as far as the grab margin requires. That sentinel test is an exact comparison, not “at or below” — reading every negative coordinate as “never placed” makes the sentinel collide with real data, and the symptom is spectacular: a window dragged past the left edge does not stay there, it teleports to the middle of the display and stays there for as long as it is held. Exactness alone was not enough either, which is why the sentinel sits at the very floor of the range rather than at -1: at a magnification of 1 a drag moves a corner in whole pixels, so -1 was a coordinate the gesture itself could land on. A sentinel has to be a value no real position can take. The minimum size is 480×360, restated as the lower clamp on the width and height preferences (a test pins the two together, since the preference assembly does not reference the client).

PreferenceDefaultMeaning
client.ui.menuDim0.82Opacity of the flat scrim the menu lays over the whole viewport, clamped 0–1
client.ui.menuInset72Inset from the left edge to the link column, pixels
client.ui.sessionFadeclient.render.worldDissolve’s default (1)Seconds the scrim and the vignette take to lift when a session ends, clamped 0–10. 0 is instant

The scrim is one flat fill over the whole viewport, not a left-edge ramp. A gradient scrim leaves the right half of the screen at full brightness, which reads as the world competing with the menu rather than receding behind it, and it makes every panel that floats over it — settings, the console — sit on a background that changes under its own width. One opacity over everything is both simpler and calmer, and it is the only knob a direction needs to retune.

The card that reports a join — one step line, one bar, one Cancel — is a Halcyon surface like any other, built from a Ui.Frame sized to the viewport with growing Ui.Space spacers pushing it to the bottom-right corner. Spacers rather than Ui.Positioned, because it is anchored to the FAR corner and an absolute offset would have to be measured from the box’s own size every frame. It is the widget the Button disabled convention was written for. What it says, and the frame-sliced load behind it, are documented with the session lifecycle.

The first real consumer, and as of Stage C2 the only settings surface there is: the ImGui options window was removed, not left behind a toggle.

It is titled OPTIONS, matching the link that opens it — one name for one thing, wherever the player meets it. The code still says settings throughout (HalcyonSettingsScreen, ClientSettingsBuilder, the client.ui.settings* preferences) because those are the model’s names and renaming stored preference keys would be a migration for no gain. Its heading row carries a close box on the right, which takes the same route out as the Options link, so the two ways to close it cannot drift.

ClientSettingsBuilder remains the single source of truth. It builds a ClientSettings of SettingsSections, each section carrying an icon slug and search keywords and holding SettingsGroups, each group holding the typed controls (ClientUiSlider, ClientUiToggle, ClientUiChoice, …) that read and write preferences live. Halcyon renders that model; it does not own any of it.

SectionIcon slugKeywords
Controlscontrolsinput, mouse, keyboard, aim
Displaydisplayvideo, monitor, screen, window
Appearanceappearanceui, interface, theme, color, accent, look, style
Graphicsgraphicsvideo, quality, rendering, visuals
Cameracameraview, fov, projection, lens
Audioaudiosound, volume, mixer
Crosshaircrosshairreticle, aim, hud, sight
HUDhudspeed, speedometer, readout, meter, velocity
Playerplayername, profile, identity, nickname
Consoleconsolecommand, developer, log, scrollback, terminal
Storagestoragecache, disk, space, files

FontAtlas bakes printable ASCII, so there is no character an icon slug could resolve to — and picking a letter that looks a bit like a speaker reads as a typo rather than as an icon. SettingsIcons resolves each slug to a short list of CanvasShapes instead: Halcyon already fills rounded rectangles for everything else it draws, so the icons are built from the primitive that exists rather than from a font pipeline that does not.

The narrowness is the point worth stating. No strokes, no paths, no diagonals — a rounded rectangle is all there is, and a circle is one whose radius is half its side. Every slug above was chosen to be sayable that way: Controls is three slider bars with knobs on them, Audio is a level meter (a speaker cone needs a diagonal), Crosshair is literally what the preset draws, Storage is the stack-of-platters mark. Shapes are authored in fractions of the icon’s side, so one drawing serves any size, and a test walks every shipped slug asserting each shape stays inside the box it was given.

An icon that genuinely needs a curve other than a capsule’s end cap cannot be drawn here, and the honest answer for one is a real vector layer — not a staircase of little rectangles.

Browse and search are two modes of one screen

Section titled “Browse and search are two modes of one screen”

Browsing puts a category rail on the left and one section’s content on the right, its controls grouped under sub-headers, each control a card row: icon, title and a dim one-line description on the left, the control itself on the right.

The content column names itself. The selected section’s title is drawn above the scroller at PageTitleSize — the heading size, and the one piece of type on this screen allowed to be large — because “where am I” should be answerable without hunting for the highlighted row in the rail. Three sizes carry the whole screen and they are distinct rather than merely differently weighted: the page title at 22, a control’s label and a group’s sub-header at 14, a description or a chip at 11. All three are sizes the atlas bakes exactly; anything else would render as a scaled bitmap. The sub-header is set in the case it was authored in rather than shouted in capitals, and stays in Muted: an 11 px uppercase sub-header is denser than the 14 px labels under it, and density reads as weight, so the thing meant to organize the rows ended up competing with them. It separates itself by size and by air now — GroupGap is more than twice RowGap, so a sub-header belongs to the rows under it rather than floating between two sets of them.

A segmented control’s chosen option takes the tonal container with a TonalTrack border and TonalOn ink, the same tones the selected rail row wears — a choice that is made should look like the other thing on screen that is made, not like a neutral chip that happens to be a shade lighter. Because every one of those tones is derived from the seed, picking a different client.ui.themeColor moves them with it.

A rail row is an icon and a label, in one of three states: the selected row sits on a TonalContainer rounded to NavRadius with TonalOn ink, a hovered row gets a TonalWash at the same radius, and every other row is bare. The hover has to be stated rather than derived for a mechanical reason: a bare row has no fill, and lifting a transparent color toward white leaves it transparent.

The controls got the same treatment. A switch is a 40×22 pill with a 16 px round thumb inset 3 px on every side, sliding 18 px across an ease-out curve while the track crossfades between SwitchOffTrack and TonalTrack. A slider is a thick 12 px track in a 20 px lane with a prominent TonalTrack fill from the minimum edge, and a 16 px thumb that straddles it, standing SliderThumbOverhang (2 px) proud on each side. That relationship is the whole readability of the control, and it was got wrong first: the track was 16 px in the same 20 px lane, which left the knob nowhere to grow into, so it was made smaller than the track at 12 px. A knob that fits inside its own groove reads as a bead somebody dropped on the fill rather than as something a hand can grab — reported as “the slider handles are still smaller than the tracks”. Thinning the track by 4 px, rather than growing the lane and disturbing every row’s rhythm, gave the whole overhang budget to the knob; 12 px is still unmistakably a quantity rather than a hairline. SliderThumbInset is consequently zero — the inset exists only to hold a knob smaller than its track inside the capsule’s end caps, and a knob that straddles the track has no capsule to stay in.

The thumb reacts to the pointer by growing rather than by brightening — a ladder of 16 / 18 / 20 px, one SliderThumbSizeStep apart, with SliderThumbPressSize landing exactly on the 20 px lane height because SliderElement caps the painted diameter there and anything larger would be silently clipped. Size rather than value because the thumb is already near-white, and the lift-toward-white every other control reacts with is a change nobody can see on it. The two extra sizes are paint-only: travel and hit-testing still use ThumbSize, so a knob that grew under the pointer does not move the value under it. Tests pin both: the switch’s per-state colors and that its thumb is strictly mid-travel one half-duration in, and — for the slider — that the knob straddles its track, that the painted diameter is one number across the full travel, down a column of rows at different subpixel phases, and at fractional magnifications, plus the round trip from a press at the painted thumb position back to the value it was painted for.

Searching hides the rail entirely while the query is non-empty and replaces the content column with the hits. The semantics are deliberately dull: case-insensitive substring over group title, section title and both sets of keywords, returned in declaration order. No fuzzy matching, no ranking — a settings search that reorders itself as you type makes the thing you were reaching for move.

Each hit renders the real group: its own sub-header under a small category chip naming the section it came from, then the actual live controls. Nothing is flattened or copied, so a slider dragged from a search result writes the same preference the browsed one does. That is pinned by a test that drags a searched control and asserts the preference moved.

A control you cannot use loses its affordance, not just its brightness

Section titled “A control you cannot use loses its affordance, not just its brightness”

“Disabled” is a null handler everywhere in this codebase, and that half was always right: Box.ResolveStyle returns the base style for both hover and pressed when nothing is interactive, and BoxElement derives no cursor without an OnClick, so a dead control neither lifts under the pointer nor asks for the hand. What was missing was the part you can see. A locked button kept full Text ink on a full raised chip; a locked field kept its well and changed nothing but readOnly. The report was exact: “all I see is maybe this shadow looks slightly different, or maybe I’m tweaking.”

The rule now is that the affordance goes away, not that it dims a little. Ink drops to Disabled, and the surface flattens to DisabledSurface for fill and border — which is defined as Card, the row’s own value, so the chip or the well stops existing and what is left is a dim word lying flat on the row. Brightness is a thing you can only judge against a memory of the same control alive; a missing container is legible on its own.

DisabledSurface is a neutral, following the split the rest of the theme already keeps — Text, Muted, Faint, Disabled, Card and Track are fixed values and only accent tones come off ThemePalette. Deriving it from the seed would put a dead control in the same color family as a switch that is on, which is the one reading it must never have.

ClientUiSlider and ClientUiToggle already dimmed their fills and went inert, and row labels already dropped to Disabled; buttons and text fields were brought onto that existing convention rather than given a second one. Segmented rows, dropdowns and color pickers are deliberately left alone: flattening them would erase the selection they are carrying, which is information the player still needs while the control is locked.

Tests assert the difference rather than the appearance — a locked label’s color, a locked chip’s fill being the card’s value, a locked field losing its well, and that a live button asks for CursorKind.Pointer while a locked one asks for nothing and paints identically under the pointer. The disabledControl run in UiCaptureTimeline photographs the same control free and locked, so dh render --renderUi shows the two side by side; the locked frame is produced by a launch-time name override, which is where a genuinely non-editable control actually occurs.

The crosshair preview has one implementation

Section titled “The crosshair preview has one implementation”

The Crosshair section draws a live preview that updates as its controls change — the one place a preview earns its keep, because those controls cannot be evaluated without seeing the result.

It is not a second drawing of the crosshair. CrosshairGeometry.Build / CrosshairOverlay.ResolveGeometry is a pure shape layer — style, size and thickness in, a list of segments and dots out — and both renderers are thin emitters over it: the HUD strokes lines and fills circles through ImGui, the preview turns the same segments into Canvas rectangles straddling them and the same dots into inscribed squares. A test pins that the preview emits exactly the geometry the HUD draws, one-for-one and in order, for every style. An edit to the shapes therefore cannot land in one place only.

The speedometer is the first player HUD element after the crosshair

Section titled “The speedometer is the first player HUD element after the crosshair”

The HUD section owns client.hud.*, the player-facing head-up display’s own preference branch — deliberately not a corner of client.debug.*. Everything under the debug branch reports on the program and is read next to a stack trace; a speedometer is part of the game, read while moving, so it sits beside the crosshair, which is the other surface of that kind and already owns a branch.

PreferenceTypeDefaultWhat it does
client.hud.speedometerboolfalseDraws a card at the bottom center reading the player’s speed in m/s. Off by default: a new readout appearing unbidden on everyone’s screen is a change to what the game looks like.
client.hud.speedMeasureenumHorizontalWhich motion the number reports — Horizontal, Rush or Full.

The three measures are genuinely different claims rather than three roundings of one number:

  • Horizontal — horizontal speed only, in the air as well as on the ground, so a dead-vertical fall reads 0.0. What a surf or bunny-hop readout reports, and the one measure that does not change when the terrain tilts under a run.
  • Rush — whatever the engine’s own rush signal is reading: horizontal while grounded, the full magnitude once airborne. The number the speed field of view and the wind loop are driven by, so the readout and the effects agree.
  • Full — the full three-dimensional magnitude at all times.

All three go through the same SpeedRush.Speed entry point (HudSpeed.Speed selects the arguments), so there is no second magnitude implementation for the readout and the effects to drift apart in.

The number is fixed to one decimal. Zero places is too coarse on a metric scale where a walk and a sprint are eight units apart — the readout would sit on one integer through most of an acceleration. Two places puts a digit under the number’s own frame-to-frame noise, and at a few hundred frames a second that digit is a blur. One place resolves a tenth of a meter per second and stays legible in motion. It is padded to a fixed character field as well, so the card cannot breathe in and out as the speed crosses ten, and it is formatted invariant, so a screenshot says the same number to everyone reading it.

Type is the atlas’s baked title size (44 px) for the number and its body size (14 px) for the m/s suffix. Both are baked, so neither is a scaled bitmap — and that is what picks them rather than any size in between: the atlas rasterizes exactly four sizes and anything else is the nearest bitmap stretched, so the number takes the top rung of that ladder rather than a number that reads as damage to the glyphs. The card is the theme’s Card fill at reduced opacity with the strong border the loading box takes, since it floats over the world with no scrim under it, padded 20 px horizontally and 10 px vertically so it stays a plate holding a number rather than a number that has outgrown its plate. It sits bottom center, floating 8% of the viewport height off the bottom edge with the shared floating margin as a floor — a fraction rather than a pixel count because the readout belongs in the lower band of the image rather than a fixed distance off the glass.

The unit is held back by alpha, not by gray. The theme’s faint tone is what a label is on a panel, where it sits on a flat surface the whole screen is made of; this card floats over live gameplay, and a gray glyph over a bright world does not read as quiet, it reads as smudged. So the suffix is the same white the number is at 75% alpha — legible as a label, unmistakably subordinate to the number, which the size gap already says.

It records in Hud, not Tooling, and that is the whole layering decision. The Hud band is spliced between the menus and the windows, so a pause menu does not cover the readout while the console, the options window and the developer overlay’s chrome do. That follows from what the thing is: a speed is information about the world, and the world is still there behind a pause screen — often the number a player paused to read. A surface somebody deliberately opened and is typing into is the opposite case, and burning a readout through it would be nothing but damage. Crosshair was never a candidate — that band is the reticle’s alone, and it sits under the screens. The band is declared by the surface (SpeedometerOverlay.Band) and resolved through UiLayer.List, so the live client and the offscreen capture cannot photograph different stacks.

On top of the layering it is gated, but only by the one term that matters: ClientUiState.HudVisible is exactly InSessionthere is a world. The main menu and a join that has not landed draw no readout at all; pause, focus loss, a console and the options window leave it alone and let the stack decide what covers it.

SpeedometerLayout is pure — a font’s metrics in, a rectangle and two text origins out — so where the card sits, that its width does not move, and that the unit rides the number’s baseline rather than its line box are all asserted headlessly. UiCaptureTimeline.Speedometer photographs the readout at a stand, at the engine’s declared walk and sprint speeds and at a fall, then raises the pause menu over it (speedometerOverMenu) and the console over that (speedometerUnderConsole) — both halves of the layering claim, because a boundary with only its true side photographed is not a picture of a boundary.

Appearance is where the interface’s own look lives

Section titled “Appearance is where the interface’s own look lives”

Appearance sits between Display and Graphics — after where the screen is, before what the world looks like — and holds the three things that are about the interface rather than about the game: Theme (the seed color), Surface (the noise slider) and Text (the antialiasing ceiling).

Text antialiasing moved here out of Display, where it had been sitting next to resolution and vsync. It is not a display setting: nothing about it depends on the monitor’s mode, and everything about it is a judgment on how the interface’s type looks. Grouping it under Appearance is also what makes the section worth having rather than a page with one dropdown on it.

The Theme group carries a preview beside the choice: five labeled swatches — Seed, Hover, Selected, Switch on, Label — painted from the live palette, so a seed is chosen against the shades it produces rather than against its own name. It is the second group in the screen to earn one, and it earns it more emphatically than the crosshair does: the seed itself is the one color a person could have guessed, and the four derived tones are the ones they will actually be looking at.

PreferenceDefaultMeaning
client.ui.settingsWidth720Outer width of the options window, pixels (minimum 480)
client.ui.settingsRailWidth150Width of the category rail, pixels
client.ui.settingsHeight640Outer height of the options window, pixels (minimum 360)
client.ui.settingsX-1Left edge of the options window, pixels; -1 centers it
client.ui.settingsY-1Top edge of the options window, pixels; -1 centers it
client.ui.windowResizeEdge8How far into a window’s edge the resize band reaches, pixels
client.ui.windowResizeCorner20How far into a window’s corner the corner square reaches, pixels

settingsX, settingsY, settingsWidth and settingsHeight are what the drag surface and resize handles write when a gesture ends, so dragging the window and editing these from the console are the same edit through two front doors. The last two are the hit geometry rather than a record of anything, and they apply to both windows — Halcyon has no preference system of its own, so they are declared here and passed into Ui.ResizeHandles as constructor arguments.

Tunable numbers stay Preference<T> and are read at the Engine.Client call site, where the preference store lives — Halcyon itself has no engine reference and no notion of preferences, which is what keeps the layout solver pure and its tests deterministic.

Stage C4 ported the developer console and deleted the ImGui one — ClientUi.cs is gone, not toggled off. The split that made the port safe was already there: ConsoleViewModel, ConsoleLineRing, ConsoleHistory and ConsoleCompletionRanker are pure model code with no renderer in them, so the port replaced a view and reused the behavior whole. Ranking, the ten-candidate cap, subtree descent, the audience gate and the annotation strings are the same code paths a player was already using.

client.console.layout chooses where the console sits, and that is all it chooses:

ValueShape
sheetFull-width drop-sheet from the top edge, the default posture
cardA floating window: draggable from anywhere on its background, resizable from any edge or corner
dock (default)A full-height panel against the right edge

There is one BuildBody — header, log well, completion panel, input row — and a Placement/Anchor pair that decides size, corner radius and which edge it is pinned to. A fourth shape would be a case in two switches, not a fourth console.

The card is a real window, on the same footing as the options window and through the same Ui.DragSurface: any background area moves it — the header’s dead space, the padding, the gaps between rows — unless a button, a field, a completion row, a scrollbar or a popup takes the press first. Its geometry persists under client.console.card*, written once per gesture, clamped and centered by the same WindowGeometry helpers. The sheet and the dock have no resize handles, because neither has a size to change: one is as wide as the viewport, the other as tall.

Left to right: a loggers menu button, a layout menu button, slack, the filter field, the wrap/time/follow pills, and a close box hard against the right edge. No title — the prompt already says what the panel is, and an earlier two-row header left a column of dead air over the top-left corner of the log for no gain. The engine’s name and version are not shown here either; the pause menu is where a build number belongs.

The per-category chips are gone, replaced by the loggers menu, and the reason is a bound: there is no limit on how many categories a session produces, so a chip per category is a row that grows until it has squeezed the filter to nothing. A button that opens a list is the same information in a fixed width, and the list scrolls.

Both menu buttons carry a small chevron, because a bare word in a header row full of other bare words gives no sign that clicking it opens anything. Both are Popups, so opening one cannot move the header by a pixel, and both go through OnDismiss — a press outside or Escape closes the menu, and the Escape is consumed there rather than continuing on to the pause layer.

The two menus differ in one deliberate way. Clicking a logger row does not close the loggers menu: muting categories is done in threes and fours while watching the log react, and a menu that shut after each one would have to be reopened from a button the eye has to find again. A row also stays in the list while its category is hidden, since one that vanished on its own click could never be clicked back. Clicking a layout row does close its menu, because the console visibly becomes a different shape and the button the menu hangs off has moved.

Opening either menu closes the other — they drop from the same row, and two overlapping surfaces there would be unreadable.

Anchoring is done with flex spacers, not a stack alignment. The layer that hosts every root loosens its children, so a stack here would shrink-wrap the console and have no slack of its own left to anchor within — a right-docked panel would land dead center. A flex line with one growing child takes its whole bounded axis, so the edge is decided by the console rather than inherited from the layer. The log column is Flexible with a zero basis for the same class of reason: a list measures to whatever it is offered, so a log that stated its natural height would claim the entire column and leave the solver a deficit to spread — CSS-correctly — across the header and the prompt too, and a prompt shrunk by a few pixels clips its own glyphs.

The log is virtualized, and every line says where it came from

Section titled “The log is virtualized, and every line says where it came from”

The scrollback is a VirtualList, so a ring holding thousands of lines builds only the rows the well can show. Rows scrolled out of the viewport stop being elements and leave their last measured height behind for the scroll range, which is what keeps scrolling far up cheap without the list mis-stating its own length. PinToEnd follows the newest line, and every scroll is reported so following breaks the moment the user pulls away from the bottom.

Each row is three fixed columns and one flexible one: an optional timestamp, the severity tag (DBG/INF/WRN/ERR), the category, then the text. The tag and its color both come from LogFormat — the same source the terminal sink and the log file use — so a severity means the same thing, in the same color, wherever you read it. Two spellings of one severity language would be two places to change it. Fixed column widths rather than a formatted prefix string, so the message text of every row starts on the same x whatever its level and category are.

“Fixed” has to be stated to the solver, not merely declared as a width. A plain flex child shrinks (Shrink = 1), and a log row overflows all the time — a NoWrap message measures its whole text, and even a wrapping one is first measured against the full row — so FlexSolver shared the deficit across every child weighted by basis and pulled the prefix cells narrower by an amount that depended on the message and on the console’s width. The tags drifted left as a line got longer or the card got narrower. Each prefix column is therefore Flexible(grow: 0, shrink: 0): the entire deficit lands on the message column, which is the one meant to absorb it and which the well already clips. Wrapped continuation lines then indent to the message column for free, because the message is a column and every line it wraps to is drawn from that column’s left edge. The timestamp column is sized for the small text size it is actually drawn at, not the body size — sizing it for the body size left a visible hole between the clock and the tag.

Halcyon routes Tab to focus traversal because the focused element declined it. The command line therefore consumes Tab at the element, through the field’s key hook, before traversal can ever see it: Tab cycles candidates forward, Shift+Tab backward, and focus does not move.

There is no limit on how many candidates a prefix may match, and no ”… and N more” row — that hint existed only because the rest was unreachable. The panel shows CompletionMaxRows (8) rows’ worth and scrolls to everything else; Tab walks the whole set, and the view follows the highlight down.

Nothing materializes a full match list to do it:

  • ConsoleCompletionRanker.Rank returns a deferred IEnumerable<ConsoleCompletionEntry>. A query whose panel is never drawn costs nothing.
  • ConsoleCompletionCursor holds the enumerator plus a cache grown on demand. A Reset (any keystroke) realizes one window plus one window of lookahead — 16 entries — whatever the match count is. Cycling past the cache pulls another lookahead; wheeling near the bottom of the realized rows pulls another.
  • Being honest about where laziness ends: the ranker deduplicates and orders globally, so the first MoveNext scans every source. What the cursor saves is everything after that — entries never constructed, rows never built, panels never drawn.

Forward wrapping therefore only happens once the stream has genuinely run out, which is the guarantee the user walked the whole set rather than the head of it. Shift+Tab from the top is the one deliberately eager move: naming the last element means reaching it.

The view follows the highlight through ScrollView.RevealKey — the panel names the selected row’s key and the scroll element scrolls that row’s measured box into view, applied once per distinct key so the wheel is free to scroll away from it. Typing gives the panel a new key of its own (candidates<generation>), which remounts the scroll element and puts the offset back at the top, since scroll offset is element state and would otherwise outlive the match set it belonged to.

Naming a variable is the easy half. client.debug.colliders — a known path, a space typed, the caret exactly where a value goes — used to offer nothing at all, so the questions a player actually has at that moment (“what is it set to, and what else does it take?”) had no answer anywhere in the console. ConsoleValueCompletion answers them, for both spellings of a write: the bare client.fovAxis horizontal and the set client.fovAxis horizontal sugar. get and reset take no value and keep completing a path.

The order is fixed, and it is about what is in force rather than what was declared:

  1. The value in force, always first. A list whose top row is the current setting answers “what is it?” without running get.
  2. A boolean’s opposite, second — so accepting the row below the current one with a click or a Tab is the toggle.
  3. Every enum member, in declaration order, each carrying its own description.
  4. The declared default, so a number has one row worth naming besides itself: the cheapest possible undo.

Everything is deduplicated case-insensitively, which is what makes “current first” compose with the type’s own list — an enum’s current member is lifted out of its declaration slot rather than repeated at the top. Rows are tagged current, default, or current, default when they are the same string.

Ordering survives the ranker because each option is emitted at its own SortTier: one option per tier, so the declared order is the drawn order. Alphabetical ordering would be actively wrong here — Lightmap sorts above LightingOnly and neither is what the enum says.

Per-member prose comes from [ValueHelp] on the enum member itself, resolved through DigitalHeaven.Core.ValueHelp. It lives on the member because a preference’s own help line has to describe the whole setting and therefore can never say what one option does. ValueHelp sits in Core rather than the engine so LightFalloff — a Core type — can carry it too. Every enum reachable from a registered preference describes every one of its members, and a test fails the build if one does not.

Two deliberate silences:

  • A value this console cannot know is not claimed. A client console binds no world, so world.render.tonemap resolves to a declaration but not to a live value, and per-player overrides live on the pawn. Both still list every legal option and tag the default; neither pretends to know which one is in force.
  • The inline ghost waits for a first character. While the line ends in a space, nothing has been typed of the value yet, so there is no prefix to complete from — ghosting the leading row would draw the value already in force and then fight every digit typed over it. The panel still lists everything; only the preview holds off.

The lookup itself is one delegate, ConsoleVariableLookup, satisfied by GameConsole.TryResolveVariable. It resolves through the same machinery name completion already uses — the PreferenceRegistry for client.*, world.* and server.*, and PlayerVarCommands.TryResolveVar for the players.<target>.<var> move and look catalogs — rather than a second registry that could drift from the first.

The list floats, and the input row never moves

Section titled “The list floats, and the input row never moves”

The completion list is a Popup anchored to the command line, which is the only shape that satisfies the one rule that matters: the prompt does not move when candidates appear. It used to be a panel in the console’s own column, so every keystroke that changed the match count resized the footer and jumped the line you were typing on. As a popup it takes part in no layout at all — it floats over the log, and the anchor’s size is the popup’s size whether it is open or shut.

Which way it unfolds follows the placement. The dock puts the prompt at the bottom of a full-height panel, so there is nothing below to unfold into and the list grows upward. The sheet and the card hang from the top, so the list drops downward — unless the console has been dragged low enough that there is not room for it, in which case it flips up. That rule is one pure function of the layout, the surface’s bottom edge and the viewport height, tested directly rather than through a screenshot.

Alignment is Stretch: the list is exactly as wide as the command line. Width is not decoration here — a value-position candidate carries a description that is the content, and a list sized to its names would truncate the only part worth reading.

Rows are clickable, with hover and pressed paint like any other control, and a click applies that candidate through the same cursor Tab walks. The list opts out of OnDismiss deliberately: it is dismissed by its own editing rules (Escape at the field, or a keystroke that empties the match set), not by clicking away from it.

The backtick opens it from anywhere, and opening it does not pause the game. Press it while playing and the console arrives over a world that keeps running: the pointer is freed so the panel can be clicked into, gameplay input is gated so a w typed at the prompt does not also walk you forward, and nothing else about the frame changes — no dim, no links, no stopped clock. That is the console’s whole reason to exist in this shape; a tool you can only reach by stopping the thing you are debugging is a worse tool.

Closing it is the toggle key again, the menu’s Console link, or the header’s close box — all three raise literally the same event through the model, so they cannot drift into meaning different things. Escape closes it too, in the two ways the Escape stack sets out: over live gameplay it closes the console alone and hands the game back, and over a paused game it takes the console down with the pause layer. Either way one press ends with a clean screen.

The toggle is bound at screen level, on a KeyListener wrapping the whole console, so whatever inside it holds focus — the command line, the filter field, nothing at all — one press closes it. It is routed as a key (UiKey.Grave, mapped from the window’s GraveAccent), never as a typed character, and Halcyon delivers keys before characters. That ordering is the whole reason a backtick cannot land in the command line on the frame it closes the console.

What always survives a close is the scrollback and the half-typed line: both live in the model rather than in the elements, so a console reopened after any of those routes is the console you left.

The completion panel’s Escape sits on the field itself, so while the panel is up Escape dismisses it and the console stays open with its input focused. An open header menu takes Escape the same way, through Popup’s dismissal in UiTree rather than through anything the console wrote. Neither reaches the shell, because UiLayer.PopupOwnsEscape reports the surface before the key is routed and the routing withholds it for that frame — the shell and Halcyon are fed by two independent key paths, so a popup cannot consume a key on the shell’s behalf after the fact.

ClientKeyRouting.Route withholds exactly two keys from the shell while the console is visible, in front, and holding the keyboard: its own backtick, and Enter, which would otherwise open the chat prompt over the console line being typed. Withholding the toggle from the shell while the console is behind the options window would be worse than useless — the key would go to a surface nobody can see, so the shell keeps it and toggles the console back to the front instead. Chat is the one surface that withholds everything but Escape, because it is the one surface with a prompt the player types free text into while the world is still running — and Escape is kept out of that blanket precisely so a prompt can never become unclosable.

Opening the console focuses the command line with no click and no Tab. The mount latch never unmounts a root, and autofocus fires on mount, so the console stamps an open count into the input’s key: each open remounts the field and re-arms the autofocus. Nothing is lost by that, because the text lives in the controller rather than in the element.

Two controls, composable, both in the header:

  • A filter field — case-insensitive substring over the visible lines, the same dull semantics the settings search uses.
  • The loggers menu, one row per category the scrollback has actually produced (and a “No output yet.” row when it has produced none). Clicking a row hides that category, clicking it again brings it back, and the menu stays open through both. Hiding a category re-arms follow, since the visible set just changed under the viewport.

Alongside them: wrap and time pills over the two preferences, a follow pill that is also the jump-to-latest control, and a dim ghost preview of what accepting the highlighted candidate would insert (drawn after the caret, visual only).

The log is a SelectionRegion over the virtual list, and every message run inside it is selectable:

  • Drag within one line and you get exactly the characters you crossed.
  • Leave that line, up or down, and the selection escalates to whole lines — the ends stop being character-precise, because a range that kept them would copy a half-line at each end, which is never what a stack trace or a wrapped URL wanted. Dragging back onto the starting line restores the character range.
  • Click without dragging clears it. So does pressing anywhere the log has no text.
  • Hovering a message shows the I-beam, the same shape the text fields show, so the log says it is selectable before anyone tries.
  • Ctrl+C copies, through the same UiTree.Clipboard the text fields use. It is gated on the prompt having no selection of its own, so Ctrl+C never stops meaning “copy what I highlighted here”.

Copy is the message text and nothing else. The timestamp, the severity tag and the category are plain runs outside the selection, so they are not selectable and cannot be copied — a pasted line is the thing you wanted to paste into a search box or an issue, not a column layout that has to be stripped by hand. Selection anchors are the visible-line index and a character offset, never a pixel, so scrolling — or a row leaving the virtual list entirely — cannot move it. Editing the filter or muting a category renumbers the visible lines, so both clear the selection rather than leave it pointing at whatever now occupies those indices.

This replaced a click-to-highlight-a-row model outright. A row highlight has no way to express half a line and no way at all to express two, and its stale-selection behavior was the bug that motivated the rewrite.

Both text fields — the prompt and the filter — carry a hover paint as well as a focused one. Focus outranks hover, so the hover state only shows on an unfocused field, which is exactly when it is useful: it is the answer to “is this thing a text box?” before anyone has clicked it.

PreferenceDefaultMeaning
client.console.layoutdockPlacement: sheet, card or dock
client.console.wraptrueWord-wrap long lines to the panel width
client.console.timestampsfalsePrefix each line with the time it was logged
client.console.cardX-1Left edge of the card, pixels; -1 centers it
client.console.cardY-1Top edge of the card, pixels; -1 centers it
client.console.cardWidth860Outer width of the card, pixels (minimum 360)
client.console.cardHeight460Outer height of the card, pixels (minimum 220)
client.ui.consoleSheetHeight0.62Fraction of the viewport height the sheet occupies
client.ui.consoleDockWidth0.44Fraction of the viewport width the dock occupies

The split between the two prefixes is not an accident. A fraction of the viewport is a taste setting a person tunes, and it lives with the rest of the UI’s tuning under client.ui.*. A dragged rectangle is a record of what a person did, and it lives with the console’s own settings under client.console.*. Only the second kind is written by the window itself, once per gesture. The card is stored in pixels rather than fractions for the same reason the options window is: a resize handle drags a rectangle, and a size held as a proportion would change whenever the viewport did.

All three placements are reachable from the settings screen’s Console section and from the header’s own layout menu, so choosing one is never a console line about the console.

The debug HUD, the world gizmos and the DigitalHeaven overlay all paint through Halcyon — but not through widgets. A corner-pinned readout, a marker projected from a world position and an overlay window the user dragged to an arbitrary rectangle all name their own coordinates, and Halcyon’s layout has no absolute placement to name them with. That is a deliberate hole in the layout engine, not a gap to be plugged: flex layout stops being predictable the moment a child can opt out of it.

So they take the layer underneath. DrawList is the seam — a flat list of primitives with no layout in it at all — and everything above it is optional.

The frame is one recording of five lists, and the order is the layering

Section titled “The frame is one recording of five lists, and the order is the layering”

HalcyonLayer owns five lists, and UiBandStack.Compose splices four of them into the fifth, which makes it the one place UI layering is decided:

ListHoldsWhy there
ToolingThe frame-stats block, the position readout, the effects readout, the axis / entity / scene-pivot gizmos and the sound cuesSpliced at the start of the tree, under every screen. These are world-anchored or read-only: each describes the frame underneath it, and a pause menu is a scrim over that frame, so the scrim dims the description along with the thing described. A gizmo painting at full strength over the pause menu reads as the tooling having escaped the pause
CrosshairThe aiming reticle, and only the reticleSpliced directly above Tooling and directly below the screens, which is two claims and both are wanted. Above the tooling because during play a reticle says where a shot would go and no debug HUD may obscure it. Below the screens because a reticle on top of a pause menu is absurd — obviously. Declared by CrosshairOverlay.Band and resolved through UiLayer.List, like every other pinned surface
The widget tree, first bandThe pause menu, the main menu and their scrimThe screens the player opened the shell with
HudThe player’s speedometer, and only itSpliced at the seam between the two tree bands: over the menus, under the overlay’s chrome, under the windows. A speed is information about a world that is still there behind a pause screen — often the number a player paused to read — while one that punched through the console would be noise over text you are reading
OverlayThe DigitalHeaven overlay’s own backdrop, menu bar, windows and toastsSpliced at the same seam, on top of Hud. The chrome is only there because somebody deliberately opened it, so it wins against everything nobody opened — including the readouts, which used to paint straight through its menu bar. It still loses to the console and the options window, because those are the two surfaces a person raises to do something
The widget tree, second bandThe console, the options window, the demo panel — everything the player stacked on topWhere layout lives, and where the front-window stack is

The raw lists are cleared and repainted every frame, exactly like the tree’s emission — nothing retained, nothing to invalidate.

The frame-stats block is in Tooling, not beside the speedometer, and it is covered while paused. That was chosen deliberately over the two alternatives. Keeping it in Hud is what made it paint through the overlay’s own menu bar; putting the whole developer band over the screens fixed the bar but lifted the gizmos over the pause menu with it. Splitting the band is the only arrangement where both are true. Nothing here changes when a readout exists — only what covers it, which is the one thing the band model exists to keep separate.

The four raw bands are not separate recordings, and they could not be: the blur composite plans index ranges into one list before the frame is recorded, so another list handed to the backend would have no plan covering it. Instead UiTree.Draw(list, bandRoots) reports the seam index after the first bandRoots roots have painted, and DrawList.Splice(index, other) drops each band’s commands in at it — one list, one plan, one order. The whole order is four calls in UiBandStack.Compose, kept out of HalcyonLayer so it can be asserted without a Vulkan device: tooling at index zero, the crosshair right after it, then the HUD at the seam plus both of their command counts, then the chrome at that plus the HUD’s. Each offset is the preceding bands’ own counts rather than the tree’s growth, because a splice of an empty list is a no-op that still has to leave the next one landing where it belongs — an empty band must never push a later one past the windows, and that is the ordinary case rather than an edge one, since three of the four are empty in a plain frame of play. Every band must have its clips and layers closed for a splice to be legal (commands are absolute and pre-snapped, so moving them is sound, but a push whose pop sat on the other side of the seam would nest the frame wrong), and the layer’s ComposeBands is idempotent so Prepare and Record can each ask for it.

The crosshair’s placement is defense in depth, and both halves are load-bearing. It used to be a second recorded pass after the tree, which made “no developer surface covers the reticle” a fact about the stack and “no reticle lands on a pause menu” a fact about a flagClientUiState.CrosshairVisible, true only while the state is Playing with no console and no options window over the scene. That worked, and it was one regression in a predicate away from a crosshair painted on a menu. Splicing the band under the screens instead makes both facts structural, and it costs nothing during play: no screen is mounted then, so the reticle is still the last thing in the frame.

The gate stays exactly as it was, because the two answer different questions. The gate decides whether a reticle is drawn: with the pointer freed nobody is aiming, so the claim it makes is false and the mark leaves the screen entirely — no band placement can express that, since a reticle under a translucent scrim is still a reticle. The band decides what covers it if it is drawn. Composing a chat line is the deliberate exception to the gate, because the cursor stays locked and the world keeps running underneath. The speedometer sharing this neighborhood does not share the strict rule: it is gated only on there being a world, and lets the stack decide what covers it.

The developer overlay is a second tree, not a second painter

Section titled “The developer overlay is a second tree, not a second painter”

DigitalHeaven.Engine.Client.Overlay is the DigitalHeaven overlay, and it is native Halcyon widgets — the engine is the reference implementation of that design, and a future Unity host and a UGUI compile target are modeled against what is here.

The earlier port was a draw-list port: HalcyonDrawContext reimplemented the overlay’s immediate-mode DrawContext on top of DrawList, which meant the widget tree, the constraint solver and the element diff were all bypassed. Everything that cost is gone with it — there was no scrolling anywhere (overflow was clipped and the rows past the edge were unreachable), no gradient, and a control’s state lived in a static dictionary keyed by a string the caller invented. DrawContext and its Unity backend stay alive for the game mods, which still register ConfigWindow through IMGUI; nothing engine-side speaks it any more.

The overlay owns a UiTree of its own. It has to: the overlay records into UiBand.Overlay, a list spliced into the player interface’s tree over the menus and under the console and the options window, and a tree reports exactly one seam. Two trees, one band each, and the layering stays a property of UiBandStack.Compose rather than a rule two surfaces have to keep agreeing on.

EngineOverlay.Render takes one OverlayFrame — the draw list, the font metrics, the display size, the pointer, the wheel and the interface scale — and that parameter is the whole reason the overlay is testable. There is no device in it, no window, no context: a test constructs a bare DrawList, a deterministic IFontMetrics, and drives real frames, then reads the commands back.

PieceWhat it is now
Menu barA Positioned row of item boxes with a Space between the left and right groups. Right-aligned items are laid out from the right edge inward, so the first one declared is the one nearest the close box and the list is walked backward. An item’s box is round(measure(label) + 2 × client.overlay.barItemPadding), and its label is centered in that box by measurement — see below. The row sits inside the bar’s own border, so an active item’s fill cannot paint over it.
The shadow bandOne Box with a GradientFill, black at client.overlay.shadowOpacity fading to nothing over client.overlay.shadowHeight, flipped to ToTop for a bottom bar. Unity generates a 1×32 texture for this; the engine never had it at all. It spans the whole display whatever the bar’s own alignment is — it is the shadow the bar casts, and a shadow does not stop where the object above it is inset to.
WindowsA Positioned chrome box, and inside its border a DragSurface title bar, a 1 px separator, a padded body, plus Ui.ResizeHandles in a second Positioned one overhang up and to the left. Every gesture goes through WindowGeometry.Apply against an anchor captured once — the same arithmetic, and the same caution, as the console and the options window.
ToastsPositioned boxes under their own Layer opacity, sliding to a new slot over client.overlay.toastSettle instead of jumping a whole row when one below them is dismissed.
ControlsButton, Dropdown, Switch, Slider, ScrollView and a track-and-fill progress bar, from OverlayControls.

One theme, and what is allowed to follow the accent

Section titled “One theme, and what is allowed to follow the accent”

OverlayTheme is resolved once per frame out of the preference store — a value, not a bag of mutable properties five window implementations each hold a reference to. That is what killed the duplicated hex literals.

Selection follows the accent; meaning does not. An active menu toggle, a chosen row and a control’s fill take a tone off ThemePalette, so client.ui.themeColor moves the overlay with the rest of the interface. Success, error, warning, attention and progress are fixed, for the same reason Danger is not seeded: information that changed color because somebody picked a green interface would be a lie told by the theme. A test pins each half against the other.

Every metric is a client.overlay.* preference — the bar’s height, font and item padding, the band’s height and opacity, the window defaults and minimums, the cascade, the fades, the toasts — so the whole design is tunable from a console line rather than from a rebuild. The colors are not: they are either semantic or derived, and both of those are answers rather than settings.

Three STRENGTHS are the exception, and they are not colors. activeItemStrength says how far an active menu item’s fill travels from the bar’s surface toward the accent’s container tone; hoverStrength and pressStrength say how much of the white pointer tint a surface takes. Which color is used stays an answer — the accent’s own ramp, and white — while how loud it is is exactly the kind of number that has to be judged by looking at it, which means it has to apply on the next frame. Every one of them was a fixed number first, and every one of them was wrong: at full strength three open windows tinted the whole bar instead of marking three items, and the hover tint was louder than the active fill it was supposed to sit quietly beside.

The chrome wears the player windows’ grays

Section titled “The chrome wears the player windows’ grays”

The overlay’s neutrals are HalcyonSettingsTheme’s, tone for tone: the body is the options window’s surface, a title bar is its card, the outline is its border, the line under a title is its hairline, a zebra stripe is its hover gray, and the ink ramp is its text, muted and disabled. The overlay is a different product from the player’s interface and may read as more utilitarian than it — a dense bar of tooling over an arbitrary scene — but not as an unrelated one, which is the same argument HalcyonConsoleTheme already makes for borrowing the same grays. A test asserts the identity, so retuning one side without the other fails the build rather than drifting quietly.

Every one of them is opaque, and that is the substantive change. The chrome used to be white at a low alpha, and linear-light blending makes a small alpha much larger than it reads on paper: the border was authored at 0.30 and measured sRGB 149 on a rendered frame — a mid gray outlining a window whose body was 13 — and the “hairline” under a title bar, at 0.12, measured 99. An alpha also means the outline is a different color over every scene the overlay is opened on, which is exactly what a border must not be.

A label in a fixed box is centered by measurement

Section titled “A label in a fixed box is centered by measurement”

Box lays its child out against loosened constraints, so a Row with JustifyContent.Center inside a fixed-size box shrink-wraps to its content and is then placed at the box’s top-left corner: there is no slack for the justification to distribute and both alignments silently do nothing. That is what put every menu item’s label hard against the corner of its own box. OverlayChrome.CenteringInset splits the slack on both axes into a padding instead — the same trick WindowCloseButton.GlyphPadding uses — and takes the vertical extent from the font’s line height, never from half the font size, which coincides with the line box at exactly one ratio and is wrong at every other.

Because the developer surfaces are now plain draw commands, the offscreen renderer can composite them, which was structurally impossible while they went through a context that only exists next to a live window. UiCaptureTimeline.Developer walks the HUD one level at a time — hudStats, hudGizmos, hudSignal — so consecutive images differ by exactly one level, then brings the overlay up and shoots six frames of it: overlayBar (the bar, its gradient band and the wordmark over the backdrop, with nothing open), then one image per window archetype — overlayList for the scrolling list with its zebra rows, two-line items, inline actions and per-row bars, overlayForm for the labeled dropdowns, switch and sliders, overlayInfo for the key-and-value panel with its pinned footer action — then four frames with a pointer on themoverlayBarHover and overlayBarPress over a menu item, overlayControlHover and overlayControlPress over a button inside a window — then overlayWindows with all three cascaded, which is the only frame that photographs the placement and the z-order together, and finally overlayToasts after the fade out, which is the picture of the notification queue being outside the fade.

The pointer frames are aimed by KEY into the overlay’s own tree (EngineOverlay.Aim, over the shared UiCaptureAim), because the host’s other aiming resolves against the player’s tree and the overlay is a second one. They exist because hover and press were the only part of the chrome no still could show, so they were the only part nobody could judge — and the first pictures of them promptly showed a hovered item painting brighter than an item that was actually on, which is what turned client.overlay.hoverStrength and pressStrength into preferences at a tenth of their old values. UiCaptureTimeline.HudLayering does the opposite: it holds the readout at one level and moves the screens around it — hudOverPauseMenu, hudUnderConsole, hudOverMainMenu — so the band’s position is a picture rather than only an assertion. UiCaptureTimeline.Speedometer shoots the player-facing readout on the same terms — speedometer0speedometer3 across a stand, a walk, a sprint and a fall, then speedometerOverMenu and speedometerUnderConsole, which are the same pair of frames hudOverPauseMenu and hudUnderConsole are — and now the opposite claim, since the frame-stats block sits in Tooling under the pause menu while the speedometer sits in Hud over it. Photographing both is what makes the split visible rather than only asserted. See offscreen rendering.

ProjectContents
Halcyon/DigitalHeaven.HalcyonThe whole system: Primitives, Style, Layout, Text, Widgets, Elements, Input, Drawing, Animation. No dependencies at all.
Halcyon/DigitalHeaven.Halcyon.Testsxunit coverage of the solver, constraints, wrapping, reconciliation, layout, hit testing, draw emission, pixel snapping, curves, transitions, gradients, focus traversal, the text editing model, dragging, popups, list virtualization and the input controls.
Engine/DigitalHeaven.Engine.Client/UiHalcyonRenderBackend (Vulkan), HalcyonBlurTargets and HalcyonBlurGeometry (the offscreen layer composite), FontAtlas (stb_truetype), HalcyonLayer (per-frame driving, texture registration), HalcyonKeyMap and WindowClipboard (the keyboard and clipboard bindings), TextAntialiasing (the degradation predicate, the mask correction and its CPU mirror of the shader), HalcyonSettings, HalcyonDemoPanel, HalcyonSettingsScreen, HalcyonMenuScreen, HalcyonTextSpecimen and HalcyonConsoleScreen with their themes, ConsoleViewModel and the console’s model types, MenuLinks, SettingsSearch, SliderValueFormat, TonalRamp, SettingsIcons, BrandWordmark and CrosshairPreviewShapes — the player HUD’s own painters, SpeedometerOverlay with its pure SpeedometerLayout and CrosshairOverlay with its pure CrosshairGeometry, each declaring the UiBand it records into — and the developer painters that go straight to the draw list: PerformanceOverlay, PositionReadoutOverlay, AxisGizmoOverlay, EntityGizmoOverlay, ScenePivotGizmoOverlay and SoundCueOverlay.
Engine/DigitalHeaven.Engine.Client/Shadershalcyon.vert, halcyon_sdf.glsl, halcyon_rect.frag, halcyon_texture.frag, halcyon_text.glsl (the shared stem-darkening and mask correction), halcyon_text.frag, halcyon_text_subpixel.frag, halcyon_blur.frag, halcyon_composite.frag.
Engine/DigitalHeaven.Engine/Preferences/UiPreferences.csThe client.ui.* keys. They live in the engine assembly because that is one of the three the preference registry scans.
Engine/DigitalHeaven.Engine/Preferences/ConsolePreferences.csThe client.console.* keys: the placement, the two line-rendering toggles and the card’s geometry.
Engine/DigitalHeaven.Engine/Preferences/HudPreferences.csThe client.hud.* keys: whether the speedometer is drawn and which motion it measures.
Engine/DigitalHeaven.Engine/Preferences/OverlayPreferences.csThe client.overlay.* keys: the developer overlay’s metrics, durations and thresholds.
Engine/DigitalHeaven.Engine.Client.OverlayThe DigitalHeaven overlay, as widgets: EngineOverlay (the tree, the fade and the gestures), OverlayFrame, OverlayChrome and OverlayChromeModel (the bar, the band, the wordmark, the windows and the toasts), OverlayTheme, OverlayControls, OverlayWindow/OverlayWindowState/OverlayWindowSet, OverlayToastStack, OverlayContent, the three archetypes (OverlayAssetsWindow, OverlayTuningWindow, OverlaySessionWindow) and OverlayLogBridge. It references Engine.Client for the four pieces it shares with the player interface rather than copying them: ThemePalette/TonalRamp, WindowGeometry, BrandWordmark and WindowCloseButton’s glyph.

No new project was added in any stage, so CONTRIBUTING.md is unchanged — Stage D moved the developer surfaces into projects that already existed.

  • An icon pipeline, so the settings rail can render the icon slugs the model already carries.
  • IME composition, using the range the value model already reserves.
  • UI scaling and transforms above the element tree.
  • A stylesheet-and-selector authoring layer feeding the same resolved structs.