HUD

SkillCommunication

Putting numbers, buttons, messages and panels on top of a game — a resource counter, a price, a build button, a toast, a dialog, floating +5s. Use when adding a HUD, an overlay, a score or resource display, a shop button, a notification, a modal; when a tap on the game is being swallowed by the interface; or when the HUD freezes or shows stale numbers after switching tabs.

Available today. Use it from your connected AI after setup.

Connect ahel once, and every AI you use reads what you have installed.

Then ask your AI: use the HUD skill

What this skill tells your AI

The instructions your AI receives, as published by plausibleventures/lattice in skills/hud/SKILL.md and read by ahel’s review.

The whole overlay of a game HUD is a few dozen DOM nodes that change a few times a second. A virtual DOM would be more code than the UI it manages, so there is not one.

The package ships no stylesheet at all. It writes structure, pointer-events, and --lattice-* custom properties, and holds no opinion about anything else. So your <style> block is the HUD's art direction — that is a feature, and it is why there is no theme to fight.


The whole thing

import { fmtCompact } from '@latticekit/core';
import { browserFrames, createLoop } from '@latticekit/loop';
import { createOverlay, drive, el, interactive, roll, setText, toasts } from '@latticekit/ui';

interface Model {
  readonly coin: number;
  readonly price: number;
  readonly affordable: boolean;
  readonly objective: string;
}

export function createHud(read: () => Model, onBuy: () => void) {
  const now = (): number => performance.now();
  const loop = createLoop({ clock: { now }, frames: browserFrames() });
  const ui = createOverlay({ now });   // the SAME clock. Two clocks in one HUD is the bug below

  const coin = roll(ui, { format: fmtCompact });
  const objective = el('p', { class: 'objective' });
  const buy = interactive(el('button', { class: 'buy' }, 'Build'));
  buy.addEventListener('click', onBuy);

  ui.mount(el('div', { class: 'hud' }, objective, 'Coin ', coin.node, buy), { interactive: true });

  // State on UPDATE. If `render` never runs — a hidden tab — every number here is still right.
  ui.every(() => {
    const m = read();
    coin.set(m.coin);
    setText(objective, m.objective);              // writes only on change, and says whether it did
    setText(buy, `Build · ${Math.ceil(m.price)}`);
    buy.classList.toggle('is-affordable', m.affordable);
    buy.toggleAttribute('disabled', !m.affordable);
  });

  drive(ui, loop);      // update → ui.tick, render → ui.repaint. Never the other way
  loop.start();

  toasts(ui).show('The light is lit', 'good');
  return { ui, loop };
}

Five lines, five decisions already made:

the linethe decision
createOverlaythe root is pointer-events: none inline, and there is no stylesheet — a tap that is not on a node you named reaches the world
rollthe number animates on paint and is correct on update: if render never runs the text is still right
ui.everythe state cadence is the loop's update. This package starts no timer and no rAF loop
drivethe pairing it is fatal to cross is a function body, not a comment
fmtCompactformatting comes from @latticekit/core. This package has no fmt and never will

The two cadences, and why one of them is not optional

ui.every / tick()ui.paint / repaint()
driven bythe loop's update — wall timethe loop's renderrequestAnimationFrame
in a hidden tabruns0 Hz
put hereanything whose absence makes the HUD wronganything whose absence makes it plainer
examplesprices, affordability, disabled buttons, build timers, toast expiry, the day/night paletteeased numbers, re-projected floats

There is no third registration point and no way to put a state update inside render. That is not tidiness. A HUD updated in the render callback freezes in a background tab while the canvas keeps showing its last painted frame, so the game looks alive with prices, timers and affordability marks that stopped twenty minutes ago.

And the fix for that is not a setInterval of your own. update already is the interval. A second clock beside the loop's is a HUD polling while the simulation settles — which is how a one-shot dialog reopens blank after a confirm, and how the obvious recovery (press confirm again) overwrote the company name a player had just typed. That is a true story and it is why drive exists as a function rather than as a paragraph of advice.


The pointer contract

The overlay root is pointer-events: none, set inline. Interactivity is granted to nodes, never by selector: interactive(node) writes pointer-events: auto inline on exactly the node it is given, and it inherits from there to its children.

If a tap should reach the world, do nothing. If it should not, name the node.

ui.mount(node) writes the inline none for you, so the guarantee survives a game stylesheet that says .lattice-layer > * { pointer-events: auto } — that rule targets your node, and your node has an inline declaration that outranks it.

When a tap goes missing anyway:

import { auditOverlay } from '@latticekit/ui';
import type { Overlay } from '@latticekit/ui';

export function whyIsMyTapGone(ui: Overlay): readonly string[] {
  return auditOverlay(ui);   // one English sentence per problem
}

It catches a node granted auto by a stylesheet rather than by interactive(), and a transform, filter or will-change on the root or a layer — which silently re-parents every position: fixed descendant and leaves a scrim covering less than the viewport.


Latches, because driving UI from a poll is the natural mistake

import { acknowledge, panel, toasts } from '@latticekit/ui';
import type { Overlay } from '@latticekit/ui';

export function wireMessages(ui: Overlay, read: () => { naming: boolean; storage: string }): void {
  const namer = panel(ui, { modal: true });
  const toast = toasts(ui);

  ui.every(() => {
    const s = read();
    if (s.naming) namer.openOnce();                          // correct at ANY poll rate
    if (s.storage === 'not-persistent') {
      toast.once('storage-not-persistent', 'This browser may not keep your progress');
    }
  });
}

export async function saveStopped(ui: Overlay): Promise<void> {
  await acknowledge(ui, {
    title: 'Saving has stopped',
    body: 'A newer version of the game wrote this save. Your progress is safe, but nothing from now on is being recorded.',
    confirmText: 'I understand',
  });
}

Panel.openOnce() and ToastHost.once(key, …) are the same idea at two sizes, and both exist because the natural way to drive UI from a game — check a condition on every update — is a poll, and a poll without a latch either repeats or reopens.

once keys on the condition, never the rendered text. A message carrying a byte count or an attempt number changes on every rediscovery and defeats a latch keyed on it — a deduplication that stops deduplicating in precisely the case it was written for.

The choice between a toast and an acknowledge is not how alarming the message sounds. It is what the player loses by missing it. Storage that may not persist is a toast, because they can do nothing about it and must not be blocked at the door. A save that has stopped being written is an acknowledge, because everything they do from now on is unrecorded and a dismissible notice about that is a notice designed to be missed.

acknowledge works before the first tick() — a message about a session that is not running must not depend on the session running — and its promise never settles if the overlay is destroyed unacknowledged, because a continuation written after "the player agreed" must not run when they did not.


Day and night reaching the HUD

import { DAY, NIGHT, createPalette, paletteVars } from '@latticekit/draw';
import { applyPalette } from '@latticekit/ui';
import type { Overlay } from '@latticekit/ui';

const palette = createPalette(DAY);
let pushedRev = -1;

export function dusk(ui: Overlay, t: number): void {
  palette.lerp(NIGHT, DAY, t);
  if (palette.rev === pushedRev) return;      // `lerp` quantizes, so most updates are no-ops
  pushedRev = palette.rev;
  // `paletteVars` is the bridge. `applyPalette` takes a bag of name → CSS string, NOT
  // draw's Palette object — passing the object straight in is a type error, and it is a
  // type error on purpose: the two are different things with the same word for a name.
  applyPalette(ui, paletteVars(palette));     // from UPDATE, never from render
}

applyPalette writes the palette onto the root as CSS custom properties, guarded per key, and returns whether anything moved. Three properties make that correct rather than merely cheap:

  1. It is change-guarded per key, so pushing on every update is wasteful rather than wrong. Quantize t on your side — 1/64 is beyond what anyone can see over a dusk.
  2. Smoothing is a CSS transition, not a JavaScript tween. transition: background-color 1.2s linear in your sheet runs on the compositor, needs no frame callback, and degrades to an instant jump in a hidden tab, which is correct because nobody is looking.
  3. It does not invalidate thumbnails, unlike setBrand. A shop card is a portrait of the building, not a photograph of it at this hour.

Write it from update. A palette pushed from render stops in a backgrounded tab, and the player comes back to a night world under a noon HUD.

Set the daylight values as CSS fallbacksvar(--lattice-ink, #1b2436) — so the first paint is right before a single frame has run.


Legibility is a number, and the number is 4.5

Contrast ratio 4.5:1 for HUD text against whatever is behind it — 3:1 only at 24 px, or 19 px bold. Those are the floors to design to, and you read the ratio off the looking harness rather than judging it by eye: look.mjs's legibility row prints one ratio per DOM text node.

The eye is the wrong instrument here, and that is the finding rather than an aside. An agent that drove a browser itself, screenshotted, and found and fixed four real defects that way still shipped a HUD whose five rows measured 1.58, 1.97, 1.76, 2.16 and 2.53. It saw none of them, because low contrast reads as atmosphere rather than as a defect — over a world that is beautiful, dim lettering looks like a deliberate hush. The harness caught all five in one run.

The two floors are not the same bar, so do not read a green row as a comfortable HUD. look.mjs fails below 3, the weaker floor, and reports everything between 3 and AA without failing it: in that same HUD a sixth node measured 3.1 — a passing row, still under the floor for the size it was set at, and now named in the row's own detail. A passing legibility row says nothing is invisible; it does not say anything is easy to read, and the advisory is the sentence that tells you which of the two you got.

The floor is 3 rather than 4.5 on measurement, not on taste, and it is worth knowing why before you argue with it. Every text node in fourteen games — this kit's eleven exhibits and three games built blind by three agents — was measured at four points of a day cycle. Two exhibits that pass every row live between 3 and 4.5: one reads its own frame time at 3.26, and another puts its entire label row at 4.16. A harness that failed those would be red on games nobody thinks are broken, and a row that is red when things are right is a row an agent stops reading. Design to 4.5 anyway. The harness's floor is where a HUD is broken; this section's number is where a HUD is good, and the gap between them is yours to close rather than the harness's to enforce.

And the trap is the section directly above. A HUD styled from --lattice-* takes both its ink and its ground from the world's palette, so its contrast is a function of the hour — legible at noon and failing at dusk with no line of code changing. That is exactly what those five rows were: color: color-mix(in srgb, var(--lattice-warn) 70%, #fff) over background: color-mix(in srgb, var(--lattice-night) 62%, transparent) — two colors that move together, with the canvas showing through the gap between them. Measured twice across that game's four-minute cycle, with nothing changed in between: the title went 1.58 → 1.10 and the clear button 2.16 → 1.11, while two other rows went 2.53 → 14.1. Same build, different hour.

Two ways out, and the first is usually right:

  1. Anchor one side. An opaque panel background that does not come from the palette, or a fixed ink. One end held still turns a ratio that moves all day into one with a worst case you can compute once.
  2. Check both ends. If both sides must move, measure at both ends of the cycle rather than at whichever hour was on screen — the looking reference carries the flag that forces the phase.

art's fills-versus-outlines table is this same failure one layer down: ink chosen as a fill makes a building unreadable, this makes text unreadable, and no compiler sees either.


What ui does not have, so you stop looking for it

No button. No toggle. No segmented control. No slider. The package ships roll, panel, toasts, floats, thumbnails and acknowledge, and an exhibit's one control is usually none of those. Two separate games hand-wrote two different missing primitives — one an <input type="range"> with about 35 lines of vendor-pseudo-element CSS, one a raise/cut toggle. Write a plain element, call interactive() on it, style it in your sheet. That is the intended path, not a workaround.

roll animates toward its target, which is wrong for a number under test. A 200-unit jump takes about 10 seconds to settle, so a diagnostic readout displays a wrong number for the whole interval somebody is looking at it. Use roll.snap(v) — or a plain setText — for a frame counter, a live object count, or anything a reviewer is reading. Keep the roll for gold.

acknowledge deliberately refuses to become a dialog system: two buttons is a choice, not an acknowledgement. A real dialog is panel({ modal: true }) plus el directly, and it comes to about nine lines. What panel({ modal: true }) gives you free is a scrim, a focus trap and Escape; doing it on the canvas instead gives you two rectangles you hit-test yourself, a world that still takes taps behind the dialog, and nothing at all for a keyboard-only player.


Small things that are load-bearing

  • The complete list of CSS properties this package ever writes inline is position, inset, left, top, z-index, pointer-events and display — plus custom properties. Nothing decorative: no color, no font, no radius, no shadow. That list is a test, and it is the boundary between "primitives" and "a look you have to fight".
  • setText writes only on change and returns whether it did. That guard replaced 37 hand-written lastX fields in one game and costs 22 ns when nothing moved.
  • Everything returns a disposer or a handle with destroy(), and everything is registered on the overlay, so ui.destroy() is a complete teardown. A game that hot-reloads twice must not end up with two overlays driving one canvas.
  • A busy HUD costs about 4.5 µs of an 8 ms frame. If your HUD is your performance problem, measure again — it is almost certainly the canvas.

What this skill does not cover

you wantread
the loop drive needs, and the one-clock rulestarting
the palette the HUD is readingart
taps on the world rather than on a buttoninput
what the numbers meaneconomy
the status a message is reportingsaving
running the harness that prints those ratiosthe lattice skill's references/looking.md

Long form, on disk: node_modules/@latticekit/ui/README.md — including the full list of class names your stylesheet may hold on to.

Signals

GitHub stars
38
Forks
5
Last commit
Aug 2026
Advanced
Catalog kind
skill
Gateway key
hud
Source
github.com/plausibleventures/lattice