Skip to content
495 changes: 495 additions & 0 deletions docs/superpowers/plans/2026-05-30-boss-fight-hud.md

Large diffs are not rendered by default.

142 changes: 142 additions & 0 deletions docs/superpowers/specs/2026-05-30-boss-fight-hud-design.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,142 @@
# Boss-Fight HUD Bar — design

**Date:** 2026-05-30
**Status:** draft — awaiting user review
**Context:** Slay the Cert dungeon game (`public/dungeon/`).

## Problem

The top of the boss fight is a loose scatter: the boss name floats at y=30, the
back-to-menu door sits free at (28,28), and the audio toggles float top-right as
text+emoji buttons. There's no run context (which floor, which domain). This feature
gathers the top into a single **HUD bar** — a themed strip holding the door, run progress,
and audio controls — and moves the boss name just beneath it.

Canvas is 960×720 (center x=480).

## Design

### The bar

A full-width strip across the top: a rectangle `(480, 22)` sized `960×44`, filled with a
**darkened shade of the active boss's `environmentColor`** (RGB × ~0.5), plus a subtle 2px
bottom border (a slightly lighter shade) at y≈43 so it frames rather than distracts. The
bar renders at depth 900; all its contents at depth 1000. Per-boss this reads as a cohesive
extension of the scene (Tool-Smith forge `0x4e4e1b`, Memory-Kraken depths `0x1b2d4e`, etc.).

### Left cluster

- **Door** — the existing `mountMenuButton`, unchanged in behavior, nudged to the bar's
vertical center (~`(28, 22)`). `mountMenuButton` gains an optional `{ x, y }` position
(defaulting to today's `28,28`) so the HUD can place it.
- **Run label** — `Floor 1/5 · MCP` at ~`(64, 22)`, origin `(0, 0.5)`, monospace, parchment
tone (`#f5e4b3`). Floor = `floorsCleared + 1` / `bossOrder.length`. In isolated/debug
fights there is no campaign, so the label gracefully drops the floor part and shows just
the domain short (e.g. `MCP`).

### Right cluster

The **same** audio toggles, **icon-only**. `mountAudioToggles` gains an `iconOnly?: boolean`
(and a `y?` anchor) option: when set, the buttons render just the glyph (🎵/🔇 for BGM,
🔊/🔇 for SFX) with no "BGM"/"SFX" text, right-aligned inside the bar (~`bgm x=938`,
`sfx x=904`, vertically centered). All mute-state, registry, `sound.mute` sync, persistence,
and `onBgmToggle` logic is reused unchanged — the Hub keeps its labeled version (no
`iconOnly`).

### Boss name

Moves from `(480, 30)` to just below the bar at ~`(480, 66)`, same 24px parchment style,
still centered above the question bubble (bubble body starts ~y=130).

### Structure

A new `src/ui/bossHud.ts` exporting `mountBossHud(scene, opts)` composes the whole strip:
the bar rect + border, `mountMenuButton` (door), the run label, and `mountAudioToggles`
(icon-only). `BossFightScene.create()` replaces its separate `mountMenuButton` and
`mountAudioToggles` calls with a single `mountBossHud(...)`, and moves the boss-name text
down.

```
mountBossHud(scene, {
boss, // BossDefinition — for environmentColor + domainShort
campaign, // Campaign | undefined (from registry) — for the floor label
onExit, // () => void → BossFightScene.exitToHub
onBgmToggle, // (muted: boolean) => void → start/stop ProceduralBGM
}): void
```

### Pure seams (unit-tested)

Two pure helpers fall out and are unit-tested (no Phaser):

- `darken(color: number, factor: number): number` — channel-wise multiply with 0–255 clamp.
Lives in `bossHud.ts`.
```ts
export function darken(color: number, factor: number): number {
const clamp = (v: number) => Math.max(0, Math.min(255, Math.round(v)));
const r = clamp(((color >> 16) & 0xff) * factor);
const g = clamp(((color >> 8) & 0xff) * factor);
const b = clamp((color & 0xff) * factor);
return (r << 16) | (g << 8) | b;
}
```
- `formatRunLabel(campaign, domainShort): string` — the with/without-campaign branch.
```ts
export function formatRunLabel(
campaign: { floorsCleared: number; bossOrder: string[] } | undefined,
domainShort: string,
): string {
if (!campaign) return domainShort;
return `Floor ${campaign.floorsCleared + 1}/${campaign.bossOrder.length} · ${domainShort}`;
}
```

### Data: `domainShort`

Add `domainShort: string` to the `BossDefinition` interface (`src/types.ts`) and to each of
the five bosses in `src/config.ts`:

| boss | domain | domainShort |
|---|---|---|
| the-orchestrator | domain-1-agentic | `Agentic` |
| the-compiler-king | domain-2-claude-code | `Claude Code` |
| the-grammarian | domain-3-prompt-engineering | `Prompting` |
| the-tool-smith | domain-4-mcp | `MCP` |
| the-memory-kraken | domain-5-context | `Context` |

## Scope / non-goals

- **In:** the HUD bar (themed strip), run label (floor + domain short), icon-only audio
reuse, boss-name reposition, `domainShort` data, the `bossHud.ts` composer, and the two
pure helpers.
- **Out:** hero/boss HP readouts (they stay where they are, near the sprites); a settings
menu; any change to audio behavior or persistence; animating the bar; touching the Hub's
audio toggles.

## Files touched

- **Create** `src/ui/bossHud.ts` — `mountBossHud`, `darken`, `formatRunLabel`.
- **Create** `src/ui/bossHud.test.ts` — unit tests for `darken` + `formatRunLabel`.
- **Modify** `src/ui/audioToggles.ts` — add `iconOnly?: boolean` + `y?: number` options.
- **Modify** `src/ui/inFightNav.ts` — `mountMenuButton` accepts optional `{ x, y }`.
- **Modify** `src/types.ts` — `domainShort` on `BossDefinition`.
- **Modify** `src/config.ts` — `domainShort` on each boss.
- **Modify** `src/scenes/BossFightScene.ts` — call `mountBossHud(...)` (replacing the
separate door + audio mounts); move the boss-name text below the bar.

## Testing

- **Unit:** `darken` (e.g. `darken(0x4e4e1b, 0.5) === 0x27270e`; clamps; `factor 0` → black)
and `formatRunLabel` (with a 5-boss campaign at floor 0 → `"Floor 1/5 · MCP"`; `undefined`
campaign → `"MCP"`).
- **Unit (light):** `mountMenuButton`'s existing click test still passes with the new
optional position arg.
- **Manual:** in a real run, the bar spans the top in a darker boss-themed tone; door +
`Floor N/5 · <domain>` on the left, icon-only SFX/BGM on the right (toggles still mute and
persist); boss name sits just below the bar; no overlap with the question bubble. Check a
couple of bosses for color cohesion, and a `?demo`/debug-isolated fight for the no-floor
fallback. Tune final pixel positions against the running dev server.

## Branch / merge plan

Standalone feature on `feat/boss-hud` off `main`. Its own PR.
5 changes: 5 additions & 0 deletions public/dungeon/src/config.ts
Original file line number Diff line number Diff line change
Expand Up @@ -36,6 +36,7 @@ export const BOSSES: BossDefinition[] = [
id: 'the-orchestrator',
name: 'The Orchestrator',
domain: 'domain-1-agentic',
domainShort: 'Agentic',
theme: 'Throne hall with chess-piece attendants',
taunts: {
correct: [
Expand All @@ -51,6 +52,7 @@ export const BOSSES: BossDefinition[] = [
id: 'the-compiler-king',
name: 'The Compiler-King',
domain: 'domain-2-claude-code',
domainShort: 'Claude Code',
theme: 'Iron workshop; command sigils',
taunts: {
correct: ['Your config compiles.', 'The build succeeds.', 'Compilation: 0 errors.'],
Expand All @@ -62,6 +64,7 @@ export const BOSSES: BossDefinition[] = [
id: 'the-grammarian',
name: 'The Grammarian',
domain: 'domain-3-prompt-engineering',
domainShort: 'Prompting',
theme: 'Library of carved stone scrolls',
taunts: {
correct: ['Precise.', 'Your tags are sacred.', 'Structure holds.'],
Expand All @@ -73,6 +76,7 @@ export const BOSSES: BossDefinition[] = [
id: 'the-tool-smith',
name: 'The Tool-Smith',
domain: 'domain-4-mcp',
domainShort: 'MCP',
theme: 'Forge surrounded by schemas-as-runes',
taunts: {
correct: ['The schema holds.', 'Well-forged.', 'Your tools cut true.'],
Expand All @@ -84,6 +88,7 @@ export const BOSSES: BossDefinition[] = [
id: 'the-memory-kraken',
name: 'The Memory-Kraken',
domain: 'domain-5-context',
domainShort: 'Context',
theme: 'Flooded archive; sinking context-shelves',
taunts: {
correct: ['You remember.', 'The tide holds.', 'Context preserved.'],
Expand Down
54 changes: 28 additions & 26 deletions public/dungeon/src/scenes/BossFightScene.ts
Original file line number Diff line number Diff line change
Expand Up @@ -25,9 +25,9 @@ import type {
SessionLog,
SpellId,
} from '../types';
import { REGISTRY_BGM_MUTED, mountAudioToggles } from '../ui/audioToggles';
import { REGISTRY_BGM_MUTED } from '../ui/audioToggles';
import { mountBossHud } from '../ui/bossHud';
import { attachRectHover, attachTextHover } from '../ui/buttonHover';
import { mountMenuButton } from '../ui/inFightNav';
import { NarratorDispatcher } from '../ui/narrator/NarratorDispatcher';
import { NarratorOverlay } from '../ui/narrator/NarratorOverlay';
import { LinePool } from '../ui/narrator/linePool';
Expand Down Expand Up @@ -194,14 +194,9 @@ export class BossFightScene extends Phaser.Scene {
// before everything else so it sits at the bottom of the z-stack.
renderBackdrop(this, this.boss.id);

// Boss name at top center
this.add
.text(480, 30, this.boss.name, {
fontSize: '24px',
color: '#f5e4b3',
fontFamily: 'monospace',
})
.setOrigin(0.5);
// (Boss name now lives centered in the top HUD bar — see mountBossHud.
// The old top-center banner floated on the brick wall and clipped the top
// of the question scroll.)

// --- Speech bubble (question area) ---
// Filled rectangle body
Expand Down Expand Up @@ -241,12 +236,12 @@ export class BossFightScene extends Phaser.Scene {
.setOrigin(0.5, 0);

// --- Hero sprite (left) ---
this.heroSprite = this.add.image(120, 330, 'hero').setScale(3);
this.heroSprite = this.add.image(85, 330, 'hero').setScale(3);

// Hero HP hearts below sprite. Slightly smaller than before so the
// label stack under the hero is a compact 2-line group.
this.heroHpText = this.add
.text(120, 405, '', {
.text(85, 405, '', {
fontSize: '12px',
color: '#8bc34a',
fontFamily: 'monospace',
Expand All @@ -256,7 +251,7 @@ export class BossFightScene extends Phaser.Scene {
// Hero name label \u2014 intentionally tiny so it reads as a subtitle
// rather than competing with the HP text above it.
this.add
.text(120, 420, 'WARLOCK', {
.text(85, 420, 'WARLOCK', {
fontSize: '9px',
color: '#808090',
fontFamily: 'monospace',
Expand All @@ -266,12 +261,19 @@ export class BossFightScene extends Phaser.Scene {
// --- Boss sprite (right) ---
const bossKey = `boss-${this.boss.id}`;
const bossTexKey = this.textures.exists(bossKey) ? bossKey : 'hero';
this.bossSprite = this.add.image(840, 330, bossTexKey).setScale(4);
// Boss is scaled up (6 vs hero's 3) for presence; y nudged up so its feet
// ground on the same floor line as the hero rather than sinking lower.
this.bossSprite = this.add.image(875, 306, bossTexKey).setScale(6);

// (Boss name now lives centered in the top HUD bar — see mountBossHud /
// bossHud.ts. The bar is a themed strip above the brick wall and question
// scroll, so the name clips neither; a side/overhead plate in the scene
// body caught the near-full-width scroll's corner.)

// Boss HP hearts below boss sprite \u2014 match the hero HP scale so both
// sides read as symmetric status lines.
this.bossHpText = this.add
.text(840, 405, '', {
.text(875, 405, '', {
fontSize: '12px',
color: '#ff6b6b',
fontFamily: 'monospace',
Expand All @@ -280,12 +282,12 @@ export class BossFightScene extends Phaser.Scene {

// Taunt text below boss
this.tauntText = this.add
.text(840, 450, '', {
.text(875, 450, '', {
fontSize: '12px',
color: '#d0c090',
fontFamily: 'monospace',
fontStyle: 'italic',
wordWrap: { width: 260 },
wordWrap: { width: 160 },
align: 'center',
})
.setOrigin(0.5, 0);
Expand Down Expand Up @@ -401,21 +403,21 @@ export class BossFightScene extends Phaser.Scene {
this.events.once('shutdown', () => this.bgm.stop());
this.events.once('destroy', () => this.bgm.stop());

// Mute toggles (top-right). onBgmToggle starts/stops our procedural
// BGM as the user flips the control; SFX mute flows through Phaser's
// sound manager automatically.
mountAudioToggles(this, {
// Top HUD bar: themed strip with the back-to-menu door + "Floor N/M ·
// <domain>" on the left and icon-only SFX/BGM on the right. onBgmToggle
// starts/stops our procedural BGM; SFX mute flows through Phaser's sound
// manager automatically.
mountBossHud(this, {
boss: this.boss,
bossName: this.boss.name,
campaign: this.registry.get('campaign') as Campaign | undefined,
onExit: () => this.exitToHub(),
onBgmToggle: (muted) => {
if (muted) this.bgm.stop();
else this.bgm.start(this.boss.id, this.sound as unknown as { context?: AudioContext });
},
});

// Back-to-menu control (top-left). Leaving flushes a resumable mid-fight
// save, so the Hub offers "Continue (… mid-fight)". No-op save for
// isolated/demo runs — those just abandon the throwaway fight.
mountMenuButton(this, () => this.exitToHub());

// Install Feel Pack — hit-stop, shake grading, squash-stretch, stagger-back, ambient dust.
installFeelPack(this, { heroSprite: this.heroSprite, bossSprite: this.bossSprite });

Expand Down
4 changes: 4 additions & 0 deletions public/dungeon/src/types.ts
Original file line number Diff line number Diff line change
Expand Up @@ -37,10 +37,14 @@ export interface Bank {
}

// Boss types
// Short display label for a domain, paired 1:1 with `domain`. Closed set of 5.
export type DomainShort = 'Agentic' | 'Claude Code' | 'Prompting' | 'MCP' | 'Context';

export interface BossDefinition {
id: string;
name: string;
domain: string;
domainShort: DomainShort;
theme: string;
taunts: { correct: string[]; wrong: string[] };
environmentColor: number; // hex for Phaser fillRect, etc.
Expand Down
29 changes: 22 additions & 7 deletions public/dungeon/src/ui/audioToggles.ts
Original file line number Diff line number Diff line change
Expand Up @@ -43,6 +43,13 @@ function savePreference(key: string, muted: boolean): void {

interface MountOptions {
onBgmToggle?: (muted: boolean) => void;
/**
* Render glyph-only buttons (no "BGM"/"SFX" text), vertically centered and
* right-anchored tighter for a compact HUD bar (changes layout, not just text).
*/
iconOnly?: boolean;
/** Vertical anchor for the buttons. Defaults to 18 (top-right corner). */
y?: number;
}

/**
Expand Down Expand Up @@ -73,26 +80,34 @@ export function mountAudioToggles(
// subsequent play() calls respect it.
scene.sound.mute = sfxMuted();

const iconOnly = opts.iconOnly === true;
const y = opts.y ?? 18;

const makeButton = (x: number, label: () => string): Phaser.GameObjects.Text => {
const t = scene.add.text(x, 18, label(), {
const t = scene.add.text(x, y, label(), {
fontSize: '14px',
color: '#c0c0d0',
fontFamily: 'monospace',
backgroundColor: '#1a1a2a',
padding: { x: 8, y: 4 },
padding: { x: iconOnly ? 5 : 8, y: 4 },
});
t.setOrigin(1, 0);
t.setOrigin(1, iconOnly ? 0.5 : 0);
t.setInteractive({ useHandCursor: true });
t.setScrollFactor(0);
t.setDepth(1000);
return t;
};

const bgmLabel = (): string => (bgmMuted() ? '\uD83D\uDD07 BGM' : '\uD83C\uDFB5 BGM');
const sfxLabel = (): string => (sfxMuted() ? '\uD83D\uDD07 SFX' : '\uD83D\uDD0A SFX');
// '\uD83D\uDD07' = \uD83D\uDD07 muted; '\uD83C\uDFB5' = \uD83C\uDFB5 BGM; '\uD83D\uDD0A' = \uD83D\uDD0A SFX.
const label = (icon: string, name: string, muted: boolean): string => {
const glyph = muted ? '\uD83D\uDD07' : icon;
return iconOnly ? glyph : `${glyph} ${name}`;
};
const bgmLabel = (): string => label('\uD83C\uDFB5', 'BGM', bgmMuted());
const sfxLabel = (): string => label('\uD83D\uDD0A', 'SFX', sfxMuted());

const bgm = makeButton(935, bgmLabel);
const sfx = makeButton(855, sfxLabel);
const bgm = makeButton(iconOnly ? 938 : 935, bgmLabel);
const sfx = makeButton(iconOnly ? 904 : 855, sfxLabel);

bgm.on('pointerdown', () => {
const next = !bgmMuted();
Expand Down
Loading
Loading