Crafty · Chapter 5 Game UI

Chapter 5 · Crafty: Building a Voxel Sandbox Game

Game UI

Taos Engine Chapter 15 covers the engine's side of UI: when to use DOM overlays versus render passes, pointer-event routing, anchoring overlays to the presentation canvas, and GPU text. This chapter walks every screen the player actually touches in Crafty: the start screen that picks a world, the main screen (the always-on HUD), the inventory hotbar and its control-button row, the inventory menu, the system menu and the settings panel inside it, and the minimap. Every one of them lives in crafty/ui/ and is built from plain DOM elements — there is no separate UI renderer. The one exception is the outline around the targeted block, which is a render pass, BlockHighlightPass, because it must line up exactly with the 3D scene.

§ 5.1

The UI Layer#

Crafty's interface is a stack of DOM elements layered over the single full-screen WebGPU <canvas>. Each widget is created imperatively, styled with an inline cssText, and appended to document.body. There is no framework, no virtual DOM, and no per-frame allocation — the game loop only writes textContent (and a few transforms) on elements that already exist.

The whole UI lives in one directory:

ModuleScreen
crafty/ui/start_screen.tsPre-game launcher: saved worlds, new world, network lobby (§5.2)
crafty/ui/hud.tsThe main screen: reticle, clock, debug overlay (§5.3)
crafty/ui/hotbar.tsThe inventory hotbar + the on-screen control buttons (§5.4)
crafty/ui/block_manager.tsThe creative inventory menu: block picker + draggable hotbar (§5.5)
crafty/ui/survival_inventory.tsThe survival inventory: 27-slot grid, hotbar row, recipe-book crafting
crafty/ui/crafting_view.ts, furnace_view.ts, chest_view.tsCrafting-table, furnace, and chest screens
crafty/ui/menu.tsThe modal primitive behind every menu (§5.6)
crafty/ui/control_panel.tsThe settings row widgets mounted inside the system menu (§5.7)
crafty/ui/minimap.tsThe north-up minimap (§5.8)
crafty/ui/chat.tsThe multiplayer chat box (see Chapter 8)

The single most important layout rule is pointer-events routing. The game runs under pointer lock: while playing, the mouse drives the camera, not a cursor. So passive overlays (the reticle, the minimap panel, the debug text) are marked pointer-events:none — clicks fall straight through them to the canvas and never steal a block-place or a camera turn. Only genuinely interactive controls (the hotbar buttons, the clickable clock, the minimap zoom buttons, menu cards) opt back in with pointer-events:auto.

Exploded stack of DOM overlay layers above the WebGPU canvas: the reticle, time clock, minimap panel, and debug text are passive (pointer-events none) while the hotbar and control buttons are interactive (pointer-events auto); a dashed click arrow falls through the passive layers down to the canvas, which holds pointer lock
'pointer-events:none', 'overflow:hidden', 'z-index:5',
// ── ...the zoom buttons sit inside it and re-enable pointer events: ──
'display:flex', 'gap:4px', 'pointer-events:auto',

Opening a menu is the one moment the model changes: the menu releases pointer lock so the OS cursor reappears and the player can click buttons; closing it re-acquires lock and drops them straight back into the game (§5.6).

§ 5.2

The Start Screen#

Before any world exists, showStartScreen() in crafty/ui/start_screen.ts puts up a full-screen launcher over the splash image and returns a promise of what the player chose:

export type StartChoice =
  | { mode: 'local'; world: SavedWorld; storage: WorldStorage | null; playerName: string }
  | {
      mode: 'network';
      playerName: string;
      serverUrl: string;
      network: NetworkClient;
      welcome: ConnectResult;
      world: WorldSummary;
    };

export async function showStartScreen(): Promise<StartChoice>

The card has a player-name field and two tabs:

The seed field defaults to empty, meaning random. A non-numeric seed is hashed (FNV-1a) into a deterministic number, and the resolved seed is written back into the field so a world worth keeping can be read straight off the form:

function parseSeed(raw: string): number
{
  const trimmed = raw.trim();
  if (trimmed.length === 0) {
    return Math.floor(Math.random() * 0x7fffffff);
  }
  const n = Number(trimmed);
  if (Number.isFinite(n)) {
    return Math.floor(n);
  }
  // Non-numeric: hash the string so "alpha" -> deterministic seed.
  let h = 2166136261 >>> 0;
  for (let i = 0; i < trimmed.length; i++) {
    h = Math.imul(h ^ trimmed.charCodeAt(i), 16777619) >>> 0;
  }
  return h & 0x7fffffff;
}

The promise resolves in one of two ways, and both hand main everything it needs so nothing is redone:

Name, last seed, and server URL are sticky in localStorage. The URL is written only after a successful handshake, so its presence means "this player has played multiplayer before" — and the launcher auto-connects to it on open. If the player then picks a local world instead, finish() closes that lobby socket so the server can release the slot:

function finish(choice: StartChoice): void
{
  localStorage.setItem(LS_NAME, getName());
  // Single-player mode doesn't use the auto-connected lobby socket. Close it
  // so the server can release the player slot.
  if (choice.mode === 'local' && activeNetwork !== null) {
    activeNetwork.disconnect();
    activeNetwork = null;
  }
  overlay.remove();
  resolve(choice);
}

The launcher removes its own overlay when it resolves, so the game starts with a clean document.body.

§ 5.3

The Main Screen#

Screen mockup showing the HUD's anchored regions: crosshair centered, hotbar bottom-center, debug overlay, and chat — each region is a DOM element positioned with CSS, transparent to mouse events

The "main screen" is the always-on gameplay HUD, assembled once by createHud() in crafty/ui/hud.ts. It returns a struct of element handles the game writes to each frame:

export interface HudElements {
  fps: HTMLDivElement;
  stats: HTMLDivElement;
  biome: HTMLDivElement;
  pos: HTMLDivElement;
  weather: HTMLDivElement;
  reticle: HTMLDivElement;
  target: HTMLDivElement;
  /** Time-of-day clock — always visible, not part of the debug overlay. */
  time: HTMLDivElement;
}

Two elements are visible by default:

Everything else — fps, stats (draws/tris/chunks), biome, pos, weather, and target (the block under the reticle) — forms a debug overlay that is display:none until the player presses X. The fps, stats, and weather boxes flow down a flex column on the right edge, tucked below the minimap panel so the two never collide; biome, pos, and target stack up from the bottom-right corner:

// Top-right debug column — fps/stats/weather flow vertically with a gap so a
// multi-line box (e.g. the stats box) never overlaps the box below it. Sits
// below the minimap panel; right-aligned so each box hugs the right edge.
const topRight = document.createElement('div');
topRight.style.cssText = [
  'position:fixed', 'top:248px', 'right:12px',
  'display:flex', 'flex-direction:column', 'align-items:flex-end',
  'gap:6px', 'pointer-events:none',
].join(';');

Using a flex column rather than hand-placed top offsets matters because stats and weather are multi-line (white-space:pre): their heights change with their content, and fixed offsets would let a tall box overlap the one below it.

Because the elements are created once, the per-frame cost of the HUD is just a handful of textContent assignments — no reconciliation, no garbage. The bottom edge of the main screen is owned by the inventory hotbar and its control-button row, which we look at next.

§ 5.4

The Inventory Hotbar#

The hotbar (crafty/ui/hotbar.ts, createHotbar()) is the row of nine slots centered along the bottom of the screen, plus a row of action buttons beneath it. In creative mode the slots start from a default loadout; survival starts empty and fills as the player picks things up:

// Default hotbar layout, used in creative mode. Survival starts empty (all
// slots BlockType.NONE) and fills in as the player picks blocks up.
const DEFAULT_HOTBAR: BlockType[] = [
  BlockType.DIRT,
  BlockType.IRON,
  BlockType.STONE,
  BlockType.SAND,
  BlockType.TRUNK,
  BlockType.SPRUCE_PLANKS,
  BlockType.GLASS,
  BlockType.TORCH,
  BlockType.WATER,
];

The bar itself is the Litecraft hotbar.png sprite drawn with image-rendering:pixelated, scaled up to 1.6× on roomy screens and down on narrow phones so the whole row always fits. A second copy of the sprite's 24×24 selector cell is a separate overlay element that slides over the active slot with a short CSS transition. Each slot draws its icon on a per-slot <canvas> with drawItemIcon (crafty/game/items.ts), which blits a block's side-face tile out of the texture atlas — or, for a tool, its procedurally shaded icon — and tools additionally get a durability bar when worn.

Selection has three input paths, all funnelling through updateSelection() (which repositions the selector overlay and fires the change callback):

The Control Button Row#

Beneath the bar, createHotbar lays out a centered, wrapping flex row of buttons. Hidden buttons simply collapse out of the row, so it stays centered whatever subset is showing:

ButtonKeyAction
💡FToggle flashlight (desktop only)
🏃RToggle run (desktop only)
🗺MToggle the minimap (§5.8)
🎒EOpen the inventory menu (§5.5)
⚙ESCOpen the system menu (§5.6)
💬TOpen chat (multiplayer only)
🕐LOpen the time-of-day popover under the clock

The buttons exist so touch devices — which have no physical keyboard for the hotkeys — can reach every action, and the game hides the ones that don't apply (chat in single-player, flashlight/run on mobile). Touch reliability is handled by also firing on touchend, since some mobile browsers delay or drop the synthetic click:

chatBtn.addEventListener('touchend', (e) => {
  e.preventDefault();
  onChatToggle?.();
}, { passive: false });

The hotbar exposes getSelected(), getSelectedSlot(), setSelectedSlot(), refresh(), and setOnSelectionChanged() so the game loop knows which block to place and the inventory menus can stay in sync.

§ 5.5

The Inventory Menu#

Categorized grid of block thumbnails with the selected block flowing down into a hotbar slot — clicking a tile assigns that block type to the active slot

Pressing E (or the 🎒 button) opens the inventory menu, a menu card (the same modal primitive as the system menu, §5.6) whose body depends on the game mode. In survival it shows crafty/ui/survival_inventory.ts — a 27-slot grid, the hotbar row, and a 2×2 recipe-book crafting area. In creative it shows the block manager, createBlockManager() in crafty/ui/block_manager.ts: a full picker of everything placeable over the new_inventory.png background sprite, with a scrollable grid on top and a draggable copy of the nine hotbar slots below.

The creative palette is hand-curated in crafty/config/creative_palette.ts and organized into tabs — Building, Colored, Natural, Functional, Redstone — plus two non-block tabs, Tools and Food, that the picker appends. Only blocks a player would actually place appear; texture-tile artifacts of the block catalog (furnace fronts, door halves, crop growth stages, lit/powered twins) are left out. The grid shows the active tab, or, when the search box has text, every palette entry whose name matches. In survival the same component shows only what the player has gathered, with a stack-count badge on each slot, and the tab bar is hidden:

function gridItems(): Array<{ type: BlockType; count: number | null }> {
  if (inventory && inventory.mode === 'survival') {
    return inventory.items()
      .filter(it => isBlockItem(it.type))
      .map(it => ({ type: it.type, count: it.count }));
  }
  let items = palette;
  if (searchText) {
    items = items.filter(e => getItemName(e.type).toLowerCase().includes(searchText));
  } else {
    items = items.filter(e => e.category === activeCategory);
  }
  return items.map(e => ({ type: e.type, count: null }));
}

Because a tab can hold more blocks than fit, the grid lives in a clipped viewport (21 columns × 6 visible rows) over a tall content div whose inventory_cell.png background tiles one grid cell per slot. One subtlety: the wheel event over the grid is stopped so it scrolls the grid instead of bubbling up to the window-level handler that cycles the hotbar selection:

invScroll.addEventListener('wheel', (e) => { e.stopPropagation(); }, { passive: true });

There are three ways to assign blocks:

The drop handler distinguishes copy from swap by whether the drag originated in the hotbar (dragHotbarIdx):

div.addEventListener('drop', (e) => {
  e.preventDefault();
  div.style.boxShadow = '';
  if (!dragBlock) {
    return;
  }
  if (dragHotbarIdx !== null && dragHotbarIdx !== i) {
    // swap two hotbar slots
    [hotbarSlots[i], hotbarSlots[dragHotbarIdx]] = [hotbarSlots[dragHotbarIdx], hotbarSlots[i]];
  }
  else if (dragHotbarIdx === null) {
    hotbarSlots[i] = dragBlock;
  }
  syncHotbar();
  onHotbarChanged();
  dragBlock = null; dragHotbarIdx = null;
});

Because the inventory menu and the on-screen hotbar share the same hotbarSlots array, syncHotbar() redraws the in-panel row and onHotbarChanged() refreshes the live bar. refreshSlotHighlight() outlines the active slot so the player can see which slot a clicked block will land in. Finally, the whole panel is scaled with a CSS transform to fit the window width (between 0.3× and 1.6×), so the pixel-exact sprite layout never needs recomputing for different screens.

§ 5.6

The System Menu#

The system menu is the pause screen, opened with ESC (or the ⚙ button). It, the inventory menu, and the crafting-table, furnace, and chest screens are all instances of one small modal primitive, createMenu() in crafty/ui/menu.ts:

export interface MenuConfig {
  /** Heading shown at the top of the card. */
  title: string;
  /** Optional key code (e.g. 'KeyE') that toggles the menu open/closed. ESC always closes. */
  toggleKey?: string;
  /** When true, the menu auto-opens on pointer-lock release (pause-on-ESC behavior). */
  autoOpenOnPointerLockLoss?: boolean;
  /** When true, prepend a "Resume Game (ESC)" button under the title. */
  showResumeButton?: boolean;
  onOpen?: () => void;
  onClose?: () => void;
}

The game creates the system menu (with a Resume button) and the inventory menu (toggled with E) up front. Opening the inventory menu closes the system menu so only one card is ever up, and swaps its body for the current game mode:

crafty/main.tsTypeScript
const systemMenu = createMenu(canvas, hud.reticle, {
  title: 'CRAFTY',
  showResumeButton: true,
  onOpen: trackMenuActivity,
  onClose: trackMenuActivity,
});
const invMenu = createMenu(canvas, hud.reticle, {
  title: 'INVENTORY',
  toggleKey: 'KeyE',
  onOpen: () => {
    trackMenuActivity();
    systemMenu.close();
    refreshInventoryView();
  },
  onClose: trackMenuActivity,
});

A menu is a dimmed full-screen overlay holding a centered card (exposed as menu.card for callers to fill). Opening it hides the reticle and exits pointer lock so the cursor is usable; closing it (via ESC, the × button, or the Resume button) re-acquires lock so the player drops straight back into the game. A 200 ms debounce stops the key press that opened the menu from immediately closing it:

function toggle(): void {
  if (isOpen()) {
    if (performance.now() - menuOpenedAt < 200) {
      return;
    }
    close();
    try { void canvas.requestPointerLock(); } catch { /* unsupported / touch */ }
  } else {
    open();
    if (document.pointerLockElement === canvas) {
      document.exitPointerLock();
    }
  }
}

For a true pause-on-release menu, the primitive also offers an autoOpenOnPointerLockLoss flag: when set, the menu pops itself open whenever the browser breaks pointer lock (the user pressed ESC, or alt-tabbed) and dismisses itself when the user clicks back into the canvas:

if (config.autoOpenOnPointerLockLoss) {
  document.addEventListener('pointerlockchange', () => {
    if (_suppressAutoOpen) {
      return;
    }
    if (document.pointerLockElement === canvas) {
      close();
    } else {
      open();
    }
  });
}

The overlay also swallows the browser context menu and keeps wheel events from leaking to the window, since right-click and scrolling are UI gestures inside the crafting and inventory screens. The system menu's card holds an exit button and, below it, the settings panel.

§ 5.7

The Settings Panel#

The settings panel is built in crafty/main.ts from the row widgets in crafty/ui/control_panel.ts and appended into the system-menu card. Unlike the engine's developer-facing control-panel kit (see Taos Engine Chapter 15), these widgets are themed for the game's light settings card and share one set of style constants, so every row — a fixed-width label on the left, controls on the right, wrapping below the label on narrow phones — lines up in a column:

WidgetBuilds
createTabbedSettings()A row of tab buttons over one visible content panel; tab(name) creates or returns a page
createSettingsSection(title, children)An uppercase heading over a vertical stack of rows
createSettingsToggle(label, initial, onChange)A single ON/OFF button row
createSettingsSelector(label, initial, options, onChange)A labeled <select> over a string-valued enum
createSettingsSlider(...)A labeled slider with a readout
createVolumeControl(volume, muted, onVolume, onMute)Mute button + 0–100 slider + percentage
createTimeControl(hours, paused, onPause, onScrub)Pause button + 24-hour scrub slider + HH:MM
createMoonPhaseControl(phase, onScrub)A moon-phase scrub slider
createFullscreenControl()A fullscreen toggle, or null where the Fullscreen API is unavailable
createControlPanel(effects, labels, groups, onChange, container, extras?)Grouped sections of ON/OFF effect buttons

The panel is organized into three tabs so it reads as grouped pages rather than one long scroll:

crafty/main.tsTypeScript
// Tabbed settings: Game / Graphics / Audio, so the panel reads as grouped
// pages instead of one long scroll. Each tab() returns a content panel to
// append sections to; the first created tab (Game) shows by default.
const settingsTabs = createTabbedSettings();
setPanel.appendChild(settingsTabs.root);
const gameTab = settingsTabs.tab('Game');
const graphicsTab = settingsTabs.tab('Graphics');
const audioTab = settingsTabs.tab('Audio');

createControlPanel owns the effect state it is handed: each button flips effects[key] itself and then calls onChange(key), so the game's callback only has to apply the side effect. It also has a safety net for the extras map — a control keyed by a title with no matching effect group still gets its own section instead of silently disappearing:

// Safety net: any `extras` keyed by a title with no matching effect group
// still renders as its own section (otherwise those controls would silently
// vanish — which is exactly how the Reversed-Z selector went missing).
if (extras) {
  for (const [title, controls] of Object.entries(extras)) {
    if (!rendered.has(title)) {
      container.appendChild(createSettingsSection(title, controls));
    }
  }
}

The time control is instantiated twice — once in the clock popover (§5.3) and once on the Game tab. Both write the same sunAngle, and each control's setPaused() keeps the other's pause glyph in step without re-firing its callback. While the player drags either slider, isScrubbing() reports true and the frame loop stops pushing the cycle's time into it, so the thumb never fights the pointer.

Persistence#

Every change calls persistSettings(), which snapshots the panel into a GameSettings record (crafty/config/game_settings.ts):

export interface GameSettings {
  effects: Record<EffectKey, boolean>;
  aoMethod: AOMethod;
  aaMethod: AAMethod;
  /** Master volume, 0–1. */
  masterVolume: number;
  /** Background-music bus volume, 0–1. */
  musicVolume: number;
  /** Sound-effects bus volume, 0–1. */
  effectsVolume: number;
  /** Global mute. */
  muted: boolean;
  /** Day-night cycle frozen (time-of-day paused). */
  timePaused: boolean;
  // ── ... reversedZ, renderDistance, shadowCascades, grass density/height/wave,
  //        disableHunger, disableDamage, disableItemDamage ──
}

Settings are JSON-encoded into localStorage under the crafty.game.settings key. loadGameSettings() merges the stored record over the shipped defaults and validates every field — enum strings against their allowed lists, numbers against their ranges — so a stale or hand-edited entry cannot break the game. Saving is wrapped in try/catch: if storage is blocked or full, settings simply don't persist. One setting, reversed-Z depth, is baked into pipelines when the render context is created, so changing it reloads the page.

§ 5.8

The Minimap#

Minimap
Minimap

The minimap (crafty/ui/minimap.ts) is a north-up overhead map fixed to the top-right corner, hidden until the player presses M (or the 🗺 button). It paints the highest non-air block of every surrounding column in that block's color, scrolls smoothly with the player, and overlays a facing arrow plus markers for networked players.

Lazy, cached terrain sampling. Walking the voxel world every frame for a window up to 128 columns wide would be far too expensive, so terrain is sampled lazily and cached per world column (the cache is zoom-independent — it stores the top block's height and packed color):

/** Returns topY*0x1000000 + sRGB, or undefined for an empty/unloaded column. */
private sampleColumn(wx: number, wz: number, maxY: number): number | undefined {
  const topY = this.world.getTopBlockY(wx, wz, maxY);
  if (topY <= 0) {
    return undefined;
  }
  const bt = this.world.getBlockType(wx, topY - 1, wz);
  if (isBlockWater(bt)) {
    return topY * 0x1000000 + WATER_RGB;
  }
  if (bt === BlockType.GRASS) {
    return topY * 0x1000000 + GRASS_RGB;
  }
  const [lr, lg, lb] = getBlockColor(bt);
  // ── ... getBlockColor returns linear RGB; convert each channel back to sRGB bytes ──
}

Water and grass are special-cased to fixed colors: the atlas grass tile is grayscale (biome-tinted only at render time) and the water tile averages to a murky color, both of which would read poorly on a flat map. Every other block is converted from the engine's linear HDR color back to sRGB bytes via linearToSrgb, the inverse of the transfer used by getBlockColor.

To keep first-open and teleports from stalling a frame, each refresh samples at most SAMPLE_BUDGET (3000) new columns; if it runs out it stays "dirty" and fills the remainder over subsequent frames. A west-neighbor height comparison adds cheap relief shading so terrain reads as 3D.

Smooth scrolling. The cached raster is snapped to integer columns, but the panel blits it with a sub-block fractional offset so the map glides under a centered player instead of jumping column by column:

const originX = center - (this.radius + (playerX - cx)) * scale;
const originZ = center - (this.radius + (playerZ - cz)) * scale;
ctx.imageSmoothingEnabled = false;
ctx.drawImage(this.offscreen, originX, originZ, this.size * scale, this.size * scale);

Overlays. The cheap overlay layer redraws every frame so the markers stay smooth even when the terrain raster doesn't change. It draws a white facing arrow at center (rotated by the player's yaw), and — when networking is active — a dot per remote player, with off-map players clamped to the panel edge as a triangle that points the way and a name + distance label (this ties into remote-player tracking from Chapter 8).

Zoom and invalidation. Three radii (64/32/16 blocks, opening at 32) are cycled with the −/+ buttons (re-enabled for pointer events even though the panel is click-through). The widest is capped at 64 because anything further out is mostly unstreamed terrain. When the local player edits a block, or a chunk re-meshes from streaming or a remote edit, the affected columns are dropped from the cache so the map refreshes:

const ZOOM_LEVELS = [64, 32, 16];
// ── ... ──
/** Re-sample a single column on the next refresh (call on local block edits). */
invalidateColumn(wx: number, wz: number): void {
  this.cache.delete(`${Math.floor(wx)},${Math.floor(wz)}`);
  this.dirty = true;
}

A soft cache cap bounds memory on long treks, and setVisible() / toggle() let the player hide the panel entirely.

§ 5.9

Summary#

Crafty's UI is deliberately low-tech and built entirely from the DOM:

Further reading: