Rogue Survivor · Build Log · Week 2

Real Menus

Week 1 was the AI cleanup. This week the game got a real UI: SurvivorButton, SurvivorContainer, SurvivorModal, and a focus manager for keyboard navigation. Plain boxes, no cropped corners, hard offset shadows like this site. The playable build is up top.

4
UI components, all canvas-drawn
16
Playwright UI tests passing
1
scale factor for every menu length
0
fixed pixel widths

Play it

The week 2 build, frozen in time. Tab through the menus, open How to Play, pause mid-run. Same game underneath.

Click the game once so it captures your keyboard. Tab / arrows move focus, Enter activates, Escape backs out.

The components

Everything lives in survivor-ui.js, drawn with the same Canvas 2D the game uses. No DOM, no framework.

SurvivorButton

A plain box with a hard offset shadow. Five states: default, hovered, focused, pressed, disabled. Pressed buttons shift down-right and their shadow shrinks, like being pushed in. Focus gets the purple border, the keyboard-focus indicator.

// Pressed buttons shift down-right, shadow shrinks: pushed in.
canvasContext.fillStyle = t.shadow
if (pressed) canvasContext.fillRect(x + 2, y + 2, w, h)
else canvasContext.fillRect(x + s, y + s, w, h)   // s = scaled 6px

SurvivorContainer

A panel with an optional title. Content draws through a callback that receives the padded content bounds, so callers never do box math by hand.

this.container.draw((content) => {
  // content: { x, y, width, height } inside the padding
  this.buttons.forEach((b) => b.draw())
})

SurvivorModal

Dim backdrop, centered container, body lines, button stack. Opening registers the modal's buttons and traps focus inside; closing unregisters them and restores whatever was focused before.

open(focusManager) {
  focusManager.registerAll(this.buttons)
  focusManager.enterModalMode(this.buttons, 0)  // trap
  this.visible = true
}
close() {
  this.buttons.forEach((b) => this.focusManager.unregister(b.id))
  this.focusManager.exitModalMode()             // restore focus
  this.visible = false
}

SurvivorFocusManager

Ported from Classic Scoundrel's FocusManager: a registry of focusables, Tab and arrow cycling with wraparound, Enter / Space activation, disabled skipping, modal trapping. Gamepads get the same treatment: d-pad moves focus, A activates, B is Escape.

handleKeyDown(event) {
  if (event.key === 'Tab') { this.focusNext(); return true }
  if (event.key === 'ArrowDown') { this.focusNext(); return true }
  if (event.key === 'Enter' && !event.repeat) {
    this.getCurrentFocus()?.activate()
    return true
  }
  return false  // unhandled: the game's own keys run
}

One scale factor

The design decision worth calling out. My first pass mixed game-scaled fonts with fixed 440px panels. On an ultrawide monitor the title spilled right out of its box. Classic Scoundrel solves this with Dymensions: lay out UI in a virtual resolution, map it onto the canvas with a single scale. I did the same with the game's own scale: every menu length is a fraction of gameSize, and panels measure their own text with measureText to size themselves. Zero fixed pixels, on phones and ultrawides alike.

SurvivorDimensions

Every value is a fraction of gameSize, the same unit the game world is drawn in. Menus and game can never drift apart.

const SurvivorDimensions = {
  fontBase: 0.02,   // the game's own base font fraction
  buttonHMul: 1.9,  // button height = fontBase * 1.9
  shadow: 0.006,    // hard offset shadow, like the site
  // ...no fixed-pixel widths anywhere.
}

const fitPanelWidth = ({ title, titlePx, lines, linePx,
                         buttonLabels, labelPx }) => {
  let need = 0
  if (title) need = Math.max(need, measureTextWidth(title, titlePx))
  lines.forEach((l) => need = Math.max(need, measureTextWidth(l, linePx)))
  buttonLabels.forEach((l) =>
    need = Math.max(need, measureTextWidth(l, labelPx) / 0.62))
  return clamp(need + padding, minW, maxW)
}

In the game

Start menu, How to Play modal, pause panel, game over panel. All screenshots from the real build.

Rogue Survivor start menu: a titled panel with Start Game and How to Play buttons
Start menu. Start Game is focused by default.
How to Play modal with controls text and a Close button
How to Play modal. Focus is trapped on Close until it closes.
Pause panel with Resume and Restart buttons over the dimmed frozen game
Pause panel over the frozen game. Resume or Restart.
Game over panel showing score, time, level reached, and a Play Again button
Game over panel with run stats and Play Again.
No formal win condition. But a happy accident: the shield orbits faster every level. Survive long enough and it becomes a blur nothing gets through. Practically invulnerable, completely unplanned. I love it.