fuzzyworld

herd / commit

Refactor UI from canvas to DOM

82e8c064ff8702e5a669540e779575a2ed2890ec · Pavel Safronov · 2026-05-17 21:49:03 -0700 · parents 68f7d1087158

Refactor UI from canvas to DOM

Replaces all canvas-drawn UI (level select, HUD, end screen) with a
DOM overlay so layout is handled by the browser engine — no more text
overlap on different screen sizes.

- index.html: full DOM structure for level select grid, HUD (counter,
  timer bar, shrink warning, level name, menu button), and end screen;
  CSS uses clamp() for responsive sizing
- GameState.ts: strip all canvas UI methods; expose public state fields
  (phase, inPen, elapsed, bestTimes, etc.) plus advance() and
  setDogTarget() for the UI layer; render() now draws game world only
- UI.ts (new): manages all DOM — builds level grid, wires events,
  syncs every element to state each frame via update()
- main.ts: create UI instance; call ui.update() in render callback;
  canvas pointerdown simplified to dog-target-only

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
 index.html            | 162 ++++++++++++++++++++++-
 src/game/GameState.ts | 355 ++++++++------------------------------------------
 src/game/UI.ts        | 188 ++++++++++++++++++++++++++
 src/main.ts           |  14 +-
 4 files changed, 405 insertions(+), 314 deletions(-)

diff --git a/index.html b/index.html
index 4e70401..eb15a58 100644
--- a/index.html
+++ b/index.html
@@ -8,11 +8,171 @@
     <style>
       *, *::before, *::after { box-sizing: border-box; margin: 0; padding: 0; }
       html, body { width: 100%; height: 100%; overflow: hidden; background: #2d5a1b; }
-      canvas { display: block; }
+
+      canvas { display: block; position: absolute; top: 0; left: 0; }
+
+      /* ── UI overlay ───────────────────────────────────────────────── */
+      #ui {
+        position: absolute; top: 0; left: 0; width: 100%; height: 100%;
+        font-family: system-ui, sans-serif;
+        color: #fff;
+        pointer-events: none;
+      }
+
+      .screen {
+        position: absolute; top: 0; left: 0; width: 100%; height: 100%;
+        display: flex; flex-direction: column; align-items: center; justify-content: center;
+        gap: 18px;
+        pointer-events: all;
+      }
+
+      .hidden { display: none !important; }
+
+      /* ── Level select ─────────────────────────────────────────────── */
+      #screen-level-select h1 {
+        font-size: clamp(2rem, 6vw, 3.5rem);
+        font-weight: bold;
+        text-shadow: 0 2px 8px rgba(0,0,0,0.35);
+      }
+
+      #screen-level-select .tagline {
+        color: rgba(255,255,255,0.75);
+        font-size: clamp(0.85rem, 2.4vw, 1.1rem);
+        margin-top: -6px;
+      }
+
+      #level-grid {
+        display: grid;
+        grid-template-columns: repeat(5, 1fr);
+        gap: 10px;
+        padding: 0 20px;
+        max-width: 860px;
+        width: 100%;
+      }
+
+      .level-btn {
+        background: rgba(255,255,255,0.15);
+        border: none; border-radius: 10px;
+        color: #fff;
+        cursor: pointer;
+        display: flex; flex-direction: column; align-items: center; justify-content: center;
+        padding: 10px 6px; min-height: 88px; gap: 3px;
+        pointer-events: all;
+        transition: background 0.15s;
+        font-family: inherit;
+      }
+      .level-btn:hover:not(.locked) { background: rgba(255,255,255,0.28); }
+      .level-btn.locked { background: rgba(255,255,255,0.06); cursor: default; }
+
+      .level-btn .lv-lock { font-size: clamp(1.2rem, 2.5vw, 1.6rem); color: rgba(255,255,255,0.25); line-height: 1; }
+      .level-btn .lv-num  { font-size: clamp(1rem, 2.5vw, 1.5rem); font-weight: bold; line-height: 1; }
+      .level-btn .lv-name { font-size: clamp(0.6rem, 1.1vw, 0.72rem); color: rgba(255,255,255,0.65); text-align: center; line-height: 1.2; }
+      .level-btn.locked .lv-name { color: rgba(255,255,255,0.2); }
+      .level-btn .lv-best { font-size: clamp(0.55rem, 1vw, 0.68rem); color: rgba(255,255,255,0.45); }
+
+      #level-select-footer {
+        display: flex; flex-direction: column; align-items: center; gap: 3px;
+        margin-top: 4px;
+      }
+      #level-select-footer a {
+        color: rgba(255,255,255,0.45); font-size: 11px; text-decoration: none; pointer-events: all;
+      }
+      #level-select-footer a:hover { color: rgba(255,255,255,0.75); }
+
+      /* ── HUD ──────────────────────────────────────────────────────── */
+      #hud {
+        position: absolute; top: 0; left: 0; width: 100%; height: 100%;
+        pointer-events: none;
+      }
+
+      #hud-counter {
+        position: absolute; top: 12px; left: 12px;
+        background: rgba(0,0,0,0.45); border-radius: 8px;
+        padding: 7px 14px; font-size: 16px; font-weight: bold; white-space: nowrap;
+      }
+      #hud-counter small {
+        font-size: 11px; font-weight: normal; color: rgba(255,255,255,0.45); margin-left: 4px;
+      }
+
+      #hud-timer {
+        position: absolute; top: 14px; left: 50%; transform: translateX(-50%);
+        display: flex; flex-direction: column; align-items: center; gap: 4px;
+      }
+      #timer-track {
+        width: 160px; height: 10px;
+        background: rgba(0,0,0,0.4); border-radius: 5px; overflow: hidden;
+      }
+      #timer-fill { height: 100%; border-radius: 4px; transition: background-color 0.5s; }
+      #timer-text { font-size: 12px; }
+
+      #hud-shrink {
+        position: absolute; top: 12px; right: 12px;
+        background: rgba(0,0,0,0.45); border-radius: 8px;
+        padding: 7px 14px; font-size: 13px; white-space: nowrap;
+        transition: background 0.3s;
+      }
+      #hud-shrink.urgent { background: rgba(220,50,50,0.85); }
+
+      #hud-level-name {
+        position: absolute; bottom: 10px; left: 12px;
+        color: rgba(255,255,255,0.6); font-size: 11px;
+      }
+
+      #btn-menu {
+        position: absolute; bottom: 12px; right: 12px;
+        background: rgba(0,0,0,0.45); color: #fff;
+        border: none; border-radius: 8px; padding: 6px 16px;
+        font-size: 13px; cursor: pointer; font-family: inherit; pointer-events: all;
+      }
+      #btn-menu:hover { background: rgba(0,0,0,0.65); }
+
+      /* ── End screen ───────────────────────────────────────────────── */
+      #screen-end { background: rgba(0,0,0,0.55); gap: 14px; text-align: center; cursor: pointer; }
+
+      #end-title    { font-size: clamp(2rem, 7vw, 3.5rem); font-weight: bold; }
+      #end-sub      { font-size: clamp(1rem, 3.5vw, 1.4rem); }
+      #end-time     { color: rgba(255,255,255,0.7); font-size: clamp(0.85rem, 2.5vw, 1.05rem); }
+      #end-record   { color: #ffd700; font-size: clamp(0.85rem, 2.4vw, 1rem); font-weight: bold; }
+      #end-subtitle { color: rgba(255,255,255,0.6); font-style: italic; font-size: clamp(0.75rem, 2.2vw, 0.95rem); max-width: 480px; padding: 0 16px; }
     </style>
   </head>
   <body>
     <canvas id="game"></canvas>
+
+    <div id="ui">
+      <!-- Level select -->
+      <div id="screen-level-select" class="screen">
+        <h1>Herd</h1>
+        <p class="tagline">Tap a level to begin</p>
+        <div id="level-grid"></div>
+        <footer id="level-select-footer">
+          <a href="https://gitlab.com/PavelSafronov/herd" target="_blank" rel="noopener">gitlab.com/PavelSafronov/herd</a>
+          <a href="https://www.flaticon.com/authors/icongeek26/outline?author_id=296&type=standard" target="_blank" rel="noopener" style="font-size:10px;opacity:0.6">Icon by Icongeek26 via Flaticon</a>
+        </footer>
+      </div>
+
+      <!-- HUD (visible during PLAYING / WIN / FAIL) -->
+      <div id="hud" class="hidden">
+        <div id="hud-counter"></div>
+        <div id="hud-timer" class="hidden">
+          <div id="timer-track"><div id="timer-fill"></div></div>
+          <span id="timer-text"></span>
+        </div>
+        <div id="hud-shrink" class="hidden"></div>
+        <div id="hud-level-name"></div>
+        <button id="btn-menu">☰ Menu</button>
+      </div>
+
+      <!-- End screen -->
+      <div id="screen-end" class="screen hidden">
+        <h2 id="end-title"></h2>
+        <p id="end-sub"></p>
+        <p id="end-time" class="hidden"></p>
+        <p id="end-record" class="hidden">★ New best time!</p>
+        <p id="end-subtitle" class="hidden"></p>
+      </div>
+    </div>
+
     <script type="module" src="/src/main.ts"></script>
   </body>
 </html>
diff --git a/src/game/GameState.ts b/src/game/GameState.ts
index 2822bfc..479032d 100644
--- a/src/game/GameState.ts
+++ b/src/game/GameState.ts
@@ -5,55 +5,77 @@ import { updateBoids } from '../ai/Boids.ts'
 import { LEVELS, type LevelConfig } from '../levels/LevelConfig.ts'
 import type { Canvas } from './Canvas.ts'
 
-type Phase = 'LEVEL_SELECT' | 'PLAYING' | 'WIN' | 'FAIL'
+export type Phase = 'LEVEL_SELECT' | 'PLAYING' | 'WIN' | 'FAIL'
 
-const PEN_SHRINK_RATE = 0.1 // 10% per interval
+const PEN_SHRINK_RATE = 0.1
 const MIN_PEN_FRACTION = 0.05
 
 export class GameState {
-  private phase: Phase = 'LEVEL_SELECT'
-  private levelIndex = 0
-  private config!: LevelConfig
+  phase: Phase = 'LEVEL_SELECT'
+  levelIndex = 0
+  config!: LevelConfig
 
   private sheep: Sheep[] = []
   private dog!: Dog
   private pen!: Pen
   private obstacles: Array<{ x: number; y: number; r: number }> = []
 
-  private scale = 1          // min(W,H) / BASE_DIM — all pixel constants multiply by this
-  private elapsed = 0        // seconds since level start
-  private endMessageTimer = 0
+  scale = 1
+  elapsed = 0
+  endMessageTimer = 0
 
-  // pen shrink tracking
-  private lastShrinkAt = 0
+  lastShrinkAt = 0
   private penW0 = 0
   private penH0 = 0
 
-  private unlockedUpTo = 0   // highest level index the player may enter
+  unlockedUpTo = 0
+  bestTimes: (number | null)[] = Array(LEVELS.length).fill(null)
+  isNewRecord = false
+  inPen = 0  // sheep count inside pen, cached every update() tick
 
   private canvas: Canvas
+
   constructor(canvas: Canvas) {
     this.canvas = canvas
-    const saved = localStorage.getItem('herd_progress')
-    if (saved !== null) this.unlockedUpTo = parseInt(saved, 10)
+    const savedProgress = localStorage.getItem('herd_progress')
+    if (savedProgress !== null) this.unlockedUpTo = parseInt(savedProgress, 10)
+    const savedTimes = localStorage.getItem('herd_times')
+    if (savedTimes !== null) this.bestTimes = JSON.parse(savedTimes) as (number | null)[]
   }
 
-  get currentDog() { return this.dog }
+  get sheepCount(): number { return this.sheep.length }
 
   goToMenu() {
     if (this.phase !== 'LEVEL_SELECT') this.phase = 'LEVEL_SELECT'
   }
 
+  // Advances past WIN/FAIL screen (0.5 s debounce to prevent accidental double-tap).
+  advance() {
+    if (this.phase === 'WIN') {
+      if (this.endMessageTimer < 0.5) return
+      if (this.levelIndex + 1 < LEVELS.length) {
+        this.startLevel(this.levelIndex + 1)
+      } else {
+        this.phase = 'LEVEL_SELECT'
+      }
+    } else if (this.phase === 'FAIL') {
+      if (this.endMessageTimer < 0.5) return
+      this.startLevel(this.levelIndex)
+    }
+  }
+
+  setDogTarget(x: number, y: number) {
+    this.dog.setTarget(x, y)
+  }
+
   startLevel(index: number) {
     this.levelIndex = index
     this.config = LEVELS[index]!
     const cfg = this.config
     const { width: W, height: H } = this.canvas
 
-    // Reference dimension: all boids/entity constants were tuned at minDim=600.
     this.scale = Math.min(W, H) / 600
 
-    // resolve pen rect from fractions
     const pen = cfg.penRect
     this.pen = new Pen(
       pen.x * W, pen.y * H,
@@ -63,13 +85,11 @@ export class GameState {
     this.penW0 = this.pen.w
     this.penH0 = this.pen.h
 
-    // resolve static obstacles
     const minDim = Math.min(W, H)
     this.obstacles = cfg.obstacles.map(o => ({
       x: o.x * W, y: o.y * H, r: o.r * minDim,
     }))
 
-    // spawn sheep
     this.sheep = []
     const overrideMap = new Map(cfg.sheepOverrides.map(o => [o.index, o]))
     let sheepIdx = 0
@@ -86,15 +106,13 @@ export class GameState {
       }
     }
 
-    // place dog at a neutral position away from the pen
     this.dog = new Dog(W * 0.5, H * 0.75, (cfg.dogSpeed ?? 160) * this.scale)
-    if (cfg.sheepSpeedMult) {
-      // speed mult is applied in boids via a per-level constant injected below
-    }
 
     this.elapsed = 0
     this.lastShrinkAt = 0
     this.endMessageTimer = 0
+    this.isNewRecord = false
+    this.inPen = 0
     this.phase = 'PLAYING'
   }
 
@@ -110,19 +128,15 @@ export class GameState {
     const cfg = this.config
     const { width: W, height: H } = this.canvas
 
-    // activate dynamic obstacles
     const minDim = Math.min(W, H)
     for (const dobs of cfg.dynamicObstacles) {
       if (this.elapsed >= dobs.appearsAtSeconds) {
-        const key = `${dobs.x},${dobs.y}`
         if (!this.obstacles.some(o => Math.abs(o.x - dobs.x * W) < 1)) {
           this.obstacles.push({ x: dobs.x * W, y: dobs.y * H, r: dobs.r * minDim })
         }
-        void key
       }
     }
 
-    // pen shrink
     if (cfg.penShrinksEverySeconds !== null) {
       const intervals = Math.floor(this.elapsed / cfg.penShrinksEverySeconds)
       if (intervals > this.lastShrinkAt) {
@@ -131,7 +145,6 @@ export class GameState {
         const factor = Math.pow(1 - PEN_SHRINK_RATE, shrinks)
         const newW = Math.max(this.penW0 * MIN_PEN_FRACTION, this.pen.w * factor)
         const newH = Math.max(this.penH0 * MIN_PEN_FRACTION, this.pen.h * factor)
-        // shrink toward center
         this.pen.x += (this.pen.w - newW) / 2
         this.pen.y += (this.pen.h - newH) / 2
         this.pen.w = newW
@@ -139,49 +152,45 @@ export class GameState {
       }
     }
 
-    // update entities
     this.dog.update(dt)
 
     const speedMult = cfg.sheepSpeedMult ?? 1
     updateBoids(this.sheep, this.dog, this.pen, W, H, this.obstacles, dt, speedMult, this.scale)
 
-    // count sheep in pen
-    const inPen = this.sheep.filter(s => this.pen.contains(s.pos)).length
+    this.inPen = this.sheep.filter(s => this.pen.contains(s.pos)).length
 
-    // check win
-    if (inPen >= cfg.winThreshold) {
+    if (this.inPen >= cfg.winThreshold) {
       this.phase = 'WIN'
       if (this.levelIndex + 1 > this.unlockedUpTo) {
         this.unlockedUpTo = this.levelIndex + 1
         localStorage.setItem('herd_progress', String(this.unlockedUpTo))
       }
+      const prev = this.bestTimes[this.levelIndex]
+      if (prev === null || this.elapsed < prev) {
+        this.bestTimes[this.levelIndex] = this.elapsed
+        this.isNewRecord = true
+        localStorage.setItem('herd_times', JSON.stringify(this.bestTimes))
+      }
       return
     }
 
-    // check fail (timer)
     if (cfg.timeLimitSeconds !== null && this.elapsed >= cfg.timeLimitSeconds) {
       this.phase = 'FAIL'
     }
 
-    // check lose — pen too small for last-pasture
     if (cfg.penShrinksEverySeconds !== null && this.pen.w * this.pen.h < (30 * this.scale) ** 2) {
       this.phase = 'FAIL'
     }
   }
 
-  render(alpha: number) {
+  render(_alpha: number) {
     const ctx = this.canvas.ctx
     const { width: W, height: H } = this.canvas
 
-    // field + pre-baked grass texture
     this.canvas.drawField()
 
-    if (this.phase === 'LEVEL_SELECT') {
-      this.drawLevelSelect(ctx, W, H)
-      return
-    }
+    if (this.phase === 'LEVEL_SELECT') return
 
-    // obstacles
     for (const obs of this.obstacles) {
       ctx.fillStyle = '#8b7355'
       ctx.beginPath()
@@ -192,33 +201,21 @@ export class GameState {
       ctx.stroke()
     }
 
-    // pen
-    const inPen = this.sheep.filter(s => this.pen.contains(s.pos)).length
-    this.pen.draw(ctx, Math.min(1, inPen / this.config.winThreshold), this.scale)
+    this.pen.draw(ctx, Math.min(1, this.inPen / this.config.winThreshold), this.scale)
 
-    // dark mode overlay
     if (this.config.darkMode) {
       this.applyDarkMode(ctx, W, H)
     }
 
-    // sheep
     for (const s of this.sheep) {
       if (this.config.darkMode) {
         const dSq = s.pos.distanceSqTo(this.dog.pos)
-        if (dSq > (120 * this.scale) ** 2) continue // invisible outside torch radius
+        if (dSq > (120 * this.scale) ** 2) continue
       }
       s.draw(ctx, this.scale)
     }
 
-    // dog
     this.dog.draw(ctx, this.scale)
-
-    // HUD
-    this.drawHUD(ctx, W, H, inPen, alpha)
-
-    if (this.phase === 'WIN' || this.phase === 'FAIL') {
-      this.drawEndScreen(ctx, W, H)
-    }
   }
 
   private applyDarkMode(ctx: CanvasRenderingContext2D, W: number, H: number) {
@@ -232,11 +229,9 @@ export class GameState {
     ctx.save()
     ctx.globalCompositeOperation = 'source-atop'
 
-    // fill dark overlay first
     ctx.fillStyle = 'rgba(0,0,0,0.88)'
     ctx.fillRect(0, 0, W, H)
 
-    // punch out torch circle
     ctx.globalCompositeOperation = 'destination-out'
     ctx.fillStyle = gradient
     ctx.beginPath()
@@ -245,250 +240,4 @@ export class GameState {
 
     ctx.restore()
   }
-
-  // Returns the menu button rect so handleTap can hit-test it consistently.
-  private menuButtonRect(W: number, H: number) {
-    const bW = 76, bH = 28
-    return { x: W - bW - 12, y: H - bH - 12, w: bW, h: bH }
-  }
-
-  private drawHUD(ctx: CanvasRenderingContext2D, W: number, H: number, inPen: number, _alpha: number) {
-    const cfg = this.config
-
-    // sheep counter
-    const hasSlack = cfg.winThreshold < this.sheep.length
-    const counterW = hasSlack ? 185 : 130
-    ctx.fillStyle = 'rgba(0,0,0,0.45)'
-    ctx.beginPath()
-    roundRect(ctx, 12, 12, counterW, 38, 8)
-    ctx.fill()
-
-    ctx.fillStyle = '#fff'
-    ctx.font = 'bold 16px system-ui'
-    ctx.textAlign = 'left'
-    ctx.fillText(`🐑 ${inPen} / ${cfg.winThreshold}`, 20, 36)
-
-    if (hasSlack) {
-      ctx.fillStyle = 'rgba(255,255,255,0.45)'
-      ctx.font = '11px system-ui'
-      ctx.fillText(`(${this.sheep.length} total)`, 108, 36)
-    }
-
-    // timer bar
-    if (cfg.timeLimitSeconds !== null) {
-      const remaining = Math.max(0, cfg.timeLimitSeconds - this.elapsed)
-      const fraction = remaining / cfg.timeLimitSeconds
-      const barW = 160, barH = 10, barX = W / 2 - barW / 2, barY = 14
-
-      ctx.fillStyle = 'rgba(0,0,0,0.4)'
-      ctx.beginPath()
-      roundRect(ctx, barX - 2, barY - 2, barW + 4, barH + 4, 5)
-      ctx.fill()
-
-      const barColor = fraction > 0.4 ? '#5dde5d' : fraction > 0.2 ? '#f0a020' : '#e03030'
-      ctx.fillStyle = barColor
-      ctx.beginPath()
-      roundRect(ctx, barX, barY, barW * fraction, barH, 4)
-      ctx.fill()
-
-      // time remaining text
-      ctx.fillStyle = '#fff'
-      ctx.font = '12px system-ui'
-      ctx.textAlign = 'center'
-      ctx.fillText(`${Math.ceil(remaining)}s`, W / 2, barY + barH + 14)
-    }
-
-    // pen shrink warning for level 10
-    if (cfg.penShrinksEverySeconds !== null) {
-      const nextShrink = (this.lastShrinkAt + 1) * cfg.penShrinksEverySeconds
-      const shrinkIn = Math.max(0, nextShrink - this.elapsed)
-      ctx.fillStyle = shrinkIn < 8 ? 'rgba(220,50,50,0.85)' : 'rgba(0,0,0,0.45)'
-      ctx.beginPath()
-      roundRect(ctx, W - 155, 12, 143, 38, 8)
-      ctx.fill()
-      ctx.fillStyle = '#fff'
-      ctx.font = '13px system-ui'
-      ctx.textAlign = 'right'
-      ctx.fillText(`Pen shrinks in ${Math.ceil(shrinkIn)}s`, W - 18, 36)
-    }
-
-    // level title
-    ctx.fillStyle = 'rgba(255,255,255,0.6)'
-    ctx.font = '11px system-ui'
-    ctx.textAlign = 'left'
-    ctx.fillText(`Level ${cfg.id}: ${cfg.title}`, 12, H - 10)
-
-    // menu button (bottom-right)
-    const mb = this.menuButtonRect(W, H)
-    ctx.fillStyle = 'rgba(0,0,0,0.45)'
-    ctx.beginPath()
-    roundRect(ctx, mb.x, mb.y, mb.w, mb.h, 8)
-    ctx.fill()
-    ctx.fillStyle = '#fff'
-    ctx.font = '13px system-ui'
-    ctx.textAlign = 'center'
-    ctx.fillText('☰ Menu', mb.x + mb.w / 2, mb.y + mb.h / 2 + 5)
-  }
-
-  private drawEndScreen(ctx: CanvasRenderingContext2D, W: number, H: number) {
-    ctx.fillStyle = 'rgba(0,0,0,0.55)'
-    ctx.fillRect(0, 0, W, H)
-
-    const win = this.phase === 'WIN'
-    const title = win ? 'Level Complete!' : 'Time\'s Up!'
-    const sub = win
-      ? (this.levelIndex + 1 < LEVELS.length ? 'Tap to continue →' : 'You finished the game!')
-      : 'Tap to try again'
-
-    ctx.fillStyle = win ? '#7eff7e' : '#ff7e7e'
-    ctx.font = `bold ${Math.round(W * 0.07)}px system-ui`
-    ctx.textAlign = 'center'
-    ctx.fillText(title, W / 2, H / 2 - 20)
-
-    ctx.fillStyle = '#fff'
-    ctx.font = `${Math.round(W * 0.035)}px system-ui`
-    ctx.fillText(sub, W / 2, H / 2 + 30)
-
-    if (win && this.config.subtitle) {
-      ctx.fillStyle = 'rgba(255,255,255,0.6)'
-      ctx.font = `italic ${Math.round(W * 0.022)}px system-ui`
-      ctx.fillText(this.config.subtitle, W / 2, H / 2 + 65)
-    }
-  }
-
-  private drawLevelSelect(ctx: CanvasRenderingContext2D, W: number, H: number) {
-    ctx.fillStyle = '#fff'
-    ctx.font = `bold ${Math.round(W * 0.06)}px system-ui`
-    ctx.textAlign = 'center'
-    ctx.fillText('Herd', W / 2, H * 0.22)
-
-    ctx.fillStyle = 'rgba(255,255,255,0.75)'
-    ctx.font = `${Math.round(W * 0.028)}px system-ui`
-    ctx.fillText('Tap a level to begin', W / 2, H * 0.31)
-
-    const cols = 5, rows = 2
-    const cellW = Math.min(160, W / (cols + 0.5))
-    const cellH = 88
-    const startX = W / 2 - (cols * cellW * 1.2) / 2
-    const startY = H * 0.38
-
-    LEVELS.forEach((level, i) => {
-      const col = i % cols
-      const row = Math.floor(i / cols)
-      const cx = startX + col * cellW * 1.2 + cellW / 2
-      const cy = startY + row * (cellH + 20)
-      const locked = i > this.unlockedUpTo
-
-      ctx.fillStyle = locked ? 'rgba(255,255,255,0.06)' : 'rgba(255,255,255,0.15)'
-      ctx.beginPath()
-      roundRect(ctx, cx - cellW / 2, cy - cellH / 2, cellW, cellH, 10)
-      ctx.fill()
-
-      if (locked) {
-        ctx.fillStyle = 'rgba(255,255,255,0.2)'
-        ctx.font = `${Math.round(cellW * 0.30)}px system-ui`
-        ctx.textAlign = 'center'
-        ctx.fillText('🔒', cx, cy + 8)
-      } else {
-        ctx.fillStyle = '#fff'
-        ctx.font = `bold ${Math.round(cellW * 0.22)}px system-ui`
-        ctx.textAlign = 'center'
-        ctx.fillText(`${level.id}`, cx, cy + 5)
-      }
-
-      ctx.fillStyle = locked ? 'rgba(255,255,255,0.2)' : 'rgba(255,255,255,0.6)'
-      ctx.font = `${Math.round(cellW * 0.11)}px system-ui`
-      ctx.fillText(level.title, cx, cy + cellH / 2 - 6)
-    })
-
-    void rows
-
-    // footer
-    ctx.font = '11px system-ui'
-    ctx.textAlign = 'center'
-
-    // repo link
-    ctx.fillStyle = 'rgba(255,255,255,0.5)'
-    ctx.fillText('gitlab.com/PavelSafronov/herd', W / 2, H - 30)
-
-    // attribution
-    ctx.fillStyle = 'rgba(255,255,255,0.35)'
-    ctx.fillText('Icon by Icongeek26 via Flaticon', W / 2, H - 12)
-  }
-
-  handleTap(x: number, y: number) {
-    if (this.phase === 'LEVEL_SELECT') {
-      const W = this.canvas.width
-      const H = this.canvas.height
-
-      // footer link hit-test (bottom ~48px of screen)
-      if (y > H - 48) {
-        if (y > H - 24) {
-          window.open('https://www.flaticon.com/authors/icongeek26/outline?author_id=296&type=standard', '_blank')
-        } else {
-          window.open('https://gitlab.com/PavelSafronov/herd', '_blank')
-        }
-        return
-      }
-
-      // hit test level buttons
-      const cols = 5
-      const cellW = Math.min(160, W / (cols + 0.5))
-      const cellH = 88
-      const startX = W / 2 - (cols * cellW * 1.2) / 2
-      const startY = H * 0.38
-
-      for (let i = 0; i < LEVELS.length; i++) {
-        const col = i % cols
-        const row = Math.floor(i / cols)
-        const cx = startX + col * cellW * 1.2 + cellW / 2
-        const cy = startY + row * (cellH + 20)
-        if (Math.abs(x - cx) < cellW / 2 && Math.abs(y - cy) < cellH / 2) {
-          if (i <= this.unlockedUpTo) this.startLevel(i)
-          return
-        }
-      }
-      return
-    }
-
-    if (this.phase === 'WIN') {
-      if (this.endMessageTimer < 0.5) return // debounce
-      if (this.levelIndex + 1 < LEVELS.length) {
-        this.startLevel(this.levelIndex + 1)
-      } else {
-        this.phase = 'LEVEL_SELECT'
-      }
-      return
-    }
-
-    if (this.phase === 'FAIL') {
-      if (this.endMessageTimer < 0.5) return
-      this.startLevel(this.levelIndex)
-      return
-    }
-
-    // PLAYING — check menu button first, then forward to dog
-    const { width: W2, height: H2 } = this.canvas
-    const mb2 = this.menuButtonRect(W2, H2)
-    if (x >= mb2.x && x <= mb2.x + mb2.w && y >= mb2.y && y <= mb2.y + mb2.h) {
-      this.phase = 'LEVEL_SELECT'
-      return
-    }
-    this.dog.setTarget(x, y)
-  }
-}
-
-
-function roundRect(ctx: CanvasRenderingContext2D, x: number, y: number, w: number, h: number, r: number) {
-  ctx.beginPath()
-  ctx.moveTo(x + r, y)
-  ctx.lineTo(x + w - r, y)
-  ctx.arcTo(x + w, y, x + w, y + r, r)
-  ctx.lineTo(x + w, y + h - r)
-  ctx.arcTo(x + w, y + h, x + w - r, y + h, r)
-  ctx.lineTo(x + r, y + h)
-  ctx.arcTo(x, y + h, x, y + h - r, r)
-  ctx.lineTo(x, y + r)
-  ctx.arcTo(x, y, x + r, y, r)
-  ctx.closePath()
 }
diff --git a/src/game/UI.ts b/src/game/UI.ts
new file mode 100644
index 0000000..d165cf6
--- /dev/null
+++ b/src/game/UI.ts
@@ -0,0 +1,188 @@
+import type { GameState } from './GameState.ts'
+import { LEVELS } from '../levels/LevelConfig.ts'
+
+export function formatTime(seconds: number): string {
+  const m = Math.floor(seconds / 60)
+  const s = Math.floor(seconds % 60)
+  return `${m}:${s.toString().padStart(2, '0')}`
+}
+
+export class UI {
+  private state: GameState
+
+  private screenLevelSelect: HTMLElement
+  private levelGrid: HTMLElement
+  private hud: HTMLElement
+  private hudCounter: HTMLElement
+  private hudTimer: HTMLElement
+  private timerFill: HTMLElement
+  private timerText: HTMLElement
+  private hudShrink: HTMLElement
+  private hudLevelName: HTMLElement
+  private screenEnd: HTMLElement
+  private endTitle: HTMLElement
+  private endSub: HTMLElement
+  private endTime: HTMLElement
+  private endRecord: HTMLElement
+  private endSubtitle: HTMLElement
+
+  private levelBtns: HTMLButtonElement[] = []
+
+  constructor(state: GameState, canvasEl: HTMLCanvasElement) {
+    this.state = state
+
+    this.screenLevelSelect = this.el('#screen-level-select')
+    this.levelGrid          = this.el('#level-grid')
+    this.hud                = this.el('#hud')
+    this.hudCounter         = this.el('#hud-counter')
+    this.hudTimer           = this.el('#hud-timer')
+    this.timerFill          = this.el('#timer-fill')
+    this.timerText          = this.el('#timer-text')
+    this.hudShrink          = this.el('#hud-shrink')
+    this.hudLevelName       = this.el('#hud-level-name')
+    this.screenEnd          = this.el('#screen-end')
+    this.endTitle           = this.el('#end-title')
+    this.endSub             = this.el('#end-sub')
+    this.endTime            = this.el('#end-time')
+    this.endRecord          = this.el('#end-record')
+    this.endSubtitle        = this.el('#end-subtitle')
+
+    this.buildLevelGrid()
+    this.wireEvents(canvasEl)
+  }
+
+  private el(selector: string): HTMLElement {
+    return document.querySelector(selector) as HTMLElement
+  }
+
+  private buildLevelGrid() {
+    this.levelGrid.innerHTML = ''
+    this.levelBtns = []
+    LEVELS.forEach((level, i) => {
+      const btn = document.createElement('button')
+      btn.className = 'level-btn locked'
+      btn.innerHTML = `
+        <span class="lv-lock">🔒</span>
+        <span class="lv-num">${level.id}</span>
+        <span class="lv-name">${level.title}</span>
+        <span class="lv-best"></span>
+      `
+      btn.addEventListener('click', () => {
+        if (i <= this.state.unlockedUpTo) this.state.startLevel(i)
+      })
+      this.levelGrid.appendChild(btn)
+      this.levelBtns.push(btn)
+    })
+  }
+
+  private wireEvents(canvasEl: HTMLCanvasElement) {
+    this.el('#btn-menu').addEventListener('click', () => this.state.goToMenu())
+
+    this.screenEnd.addEventListener('click', () => this.state.advance())
+
+    canvasEl.addEventListener('pointerdown', (e: PointerEvent) => {
+      if (this.state.phase !== 'PLAYING') return
+      const rect = canvasEl.getBoundingClientRect()
+      this.state.setDogTarget(e.clientX - rect.left, e.clientY - rect.top)
+    })
+
+    window.addEventListener('keydown', (e: KeyboardEvent) => {
+      if (e.key === 'Escape') this.state.goToMenu()
+    })
+  }
+
+  update() {
+    const { phase } = this.state
+    const onGame = phase === 'PLAYING' || phase === 'WIN' || phase === 'FAIL'
+
+    this.screenLevelSelect.classList.toggle('hidden', phase !== 'LEVEL_SELECT')
+    this.hud.classList.toggle('hidden', !onGame)
+    this.screenEnd.classList.toggle('hidden', phase !== 'WIN' && phase !== 'FAIL')
+
+    if (phase === 'LEVEL_SELECT') this.updateLevelGrid()
+    if (onGame)                   this.updateHUD()
+    if (phase === 'WIN' || phase === 'FAIL') this.updateEndScreen()
+  }
+
+  private updateLevelGrid() {
+    LEVELS.forEach((_, i) => {
+      const btn = this.levelBtns[i]!
+      const locked = i > this.state.unlockedUpTo
+      const best = this.state.bestTimes[i]
+
+      btn.classList.toggle('locked', locked)
+
+      const lockEl = btn.querySelector('.lv-lock') as HTMLElement
+      const numEl  = btn.querySelector('.lv-num')  as HTMLElement
+      lockEl.classList.toggle('hidden', !locked)
+      numEl.classList.toggle('hidden', locked)
+
+      const bestEl = btn.querySelector('.lv-best') as HTMLElement
+      bestEl.textContent = best !== null ? formatTime(best) : ''
+    })
+  }
+
+  private updateHUD() {
+    const { config: cfg, inPen, sheepCount, elapsed, lastShrinkAt } = this.state
+    if (!cfg) return
+
+    const hasSlack = cfg.winThreshold < sheepCount
+    this.hudCounter.innerHTML = `🐑 ${inPen} / ${cfg.winThreshold}${
+      hasSlack ? `<small>(${sheepCount} total)</small>` : ''
+    }`
+
+    if (cfg.timeLimitSeconds !== null) {
+      this.hudTimer.classList.remove('hidden')
+      const remaining = Math.max(0, cfg.timeLimitSeconds - elapsed)
+      const fraction  = remaining / cfg.timeLimitSeconds
+      const color = fraction > 0.4 ? '#5dde5d' : fraction > 0.2 ? '#f0a020' : '#e03030'
+      this.timerFill.style.width = `${fraction * 100}%`
+      this.timerFill.style.backgroundColor = color
+      this.timerText.textContent = `${Math.ceil(remaining)}s`
+    } else {
+      this.hudTimer.classList.add('hidden')
+    }
+
+    if (cfg.penShrinksEverySeconds !== null) {
+      this.hudShrink.classList.remove('hidden')
+      const nextShrink = (lastShrinkAt + 1) * cfg.penShrinksEverySeconds
+      const shrinkIn   = Math.max(0, nextShrink - elapsed)
+      this.hudShrink.textContent = `Pen shrinks in ${Math.ceil(shrinkIn)}s`
+      this.hudShrink.classList.toggle('urgent', shrinkIn < 8)
+    } else {
+      this.hudShrink.classList.add('hidden')
+    }
+
+    this.hudLevelName.textContent = `Level ${cfg.id}: ${cfg.title}`
+  }
+
+  private updateEndScreen() {
+    const { phase, levelIndex, elapsed, bestTimes, isNewRecord, config: cfg } = this.state
+    const win = phase === 'WIN'
+
+    this.endTitle.textContent = win ? 'Level Complete!' : "Time's Up!"
+    this.endTitle.style.color = win ? '#7eff7e' : '#ff7e7e'
+    this.endSub.textContent = win
+      ? (levelIndex + 1 < LEVELS.length ? 'Tap to continue →' : 'You finished the game!')
+      : 'Tap to try again'
+
+    if (win) {
+      const best    = bestTimes[levelIndex]
+      const timeStr = formatTime(elapsed)
+      const bestStr = best !== null ? formatTime(best) : null
+      this.endTime.textContent = `Time: ${timeStr}${bestStr ? `  •  Best: ${bestStr}` : ''}`
+      this.endTime.classList.remove('hidden')
+    } else {
+      this.endTime.classList.add('hidden')
+    }
+
+    this.endRecord.classList.toggle('hidden', !(win && isNewRecord))
+
+    if (win && cfg?.subtitle) {
+      this.endSubtitle.textContent = cfg.subtitle
+      this.endSubtitle.classList.remove('hidden')
+    } else {
+      this.endSubtitle.classList.add('hidden')
+    }
+  }
+}
diff --git a/src/main.ts b/src/main.ts
index 8b6f1b4..789b82d 100644
--- a/src/main.ts
+++ b/src/main.ts
@@ -1,21 +1,15 @@
 import { Canvas } from './game/Canvas.ts'
 import { GameLoop } from './game/GameLoop.ts'
 import { GameState } from './game/GameState.ts'
+import { UI } from './game/UI.ts'
 
 const canvas = new Canvas('game')
 const state = new GameState(canvas)
+const ui = new UI(state, canvas.el)
+
 const loop = new GameLoop(
   (dt) => state.update(dt),
-  (_alpha) => { canvas.clear(); state.render(_alpha) },
+  (alpha) => { canvas.clear(); state.render(alpha); ui.update() },
 )
 
-canvas.el.addEventListener('pointerdown', (e: PointerEvent) => {
-  const rect = canvas.el.getBoundingClientRect()
-  state.handleTap(e.clientX - rect.left, e.clientY - rect.top)
-})
-
-window.addEventListener('keydown', (e: KeyboardEvent) => {
-  if (e.key === 'Escape') state.goToMenu()
-})
-
 loop.start()