---
format: typebulb/v1
name: Pacman
---

**code.tsx**

```tsx
import { App, Component, div, button, h1, canvas } from "domeleon"

const CELL_SIZE = 22;
const COLS = 19;
const ROWS = 21;
const WIDTH = COLS * CELL_SIZE;
const HEIGHT = ROWS * CELL_SIZE;
const GAME_SPEED = 4;
const SCARED_SPEED = 2.5;

const INITIAL_MAZE = [
  [1, 1, 1, 1, 1, 1, 1, 1, 1, 1, 1, 1, 1, 1, 1, 1, 1, 1, 1],
  [1, 2, 2, 2, 2, 2, 2, 2, 2, 1, 2, 2, 2, 2, 2, 2, 2, 2, 1],
  [1, 3, 1, 1, 2, 1, 1, 1, 2, 1, 2, 1, 1, 1, 2, 1, 1, 3, 1],
  [1, 2, 2, 2, 2, 2, 2, 2, 2, 2, 2, 2, 2, 2, 2, 2, 2, 2, 1],
  [1, 2, 1, 1, 2, 1, 2, 1, 1, 1, 1, 1, 2, 1, 2, 1, 1, 2, 1],
  [1, 2, 2, 2, 2, 1, 2, 2, 2, 1, 2, 2, 2, 1, 2, 2, 2, 2, 1],
  [1, 1, 1, 1, 2, 1, 1, 1, 4, 1, 4, 1, 1, 1, 2, 1, 1, 1, 1],
  [4, 4, 4, 1, 2, 1, 4, 4, 4, 4, 4, 4, 4, 1, 2, 1, 4, 4, 4],
  [1, 1, 1, 1, 2, 1, 4, 1, 1, 4, 1, 1, 4, 1, 2, 1, 1, 1, 1],
  [4, 4, 4, 4, 2, 4, 4, 1, 4, 4, 4, 1, 4, 4, 2, 4, 4, 4, 4],
  [1, 1, 1, 1, 2, 1, 4, 1, 1, 1, 1, 1, 4, 1, 2, 1, 1, 1, 1],
  [4, 4, 4, 1, 2, 1, 4, 4, 4, 4, 4, 4, 4, 1, 2, 1, 4, 4, 4],
  [1, 1, 1, 1, 2, 1, 4, 1, 1, 1, 1, 1, 4, 1, 2, 1, 1, 1, 1],
  [1, 2, 2, 2, 2, 2, 2, 2, 2, 1, 2, 2, 2, 2, 2, 2, 2, 2, 1],
  [1, 2, 1, 1, 2, 1, 1, 1, 2, 1, 2, 1, 1, 1, 2, 1, 1, 2, 1],
  [1, 3, 2, 1, 2, 2, 2, 2, 2, 4, 2, 2, 2, 2, 2, 1, 2, 3, 1],
  [1, 1, 2, 1, 2, 1, 2, 1, 1, 1, 1, 1, 2, 1, 2, 1, 2, 1, 1],
  [1, 2, 2, 2, 2, 1, 2, 2, 2, 1, 2, 2, 2, 1, 2, 2, 2, 2, 1],
  [1, 2, 1, 1, 1, 1, 1, 1, 2, 1, 2, 1, 1, 1, 1, 1, 1, 2, 1],
  [1, 2, 2, 2, 2, 2, 2, 2, 2, 2, 2, 2, 2, 2, 2, 2, 2, 2, 1],
  [1, 1, 1, 1, 1, 1, 1, 1, 1, 1, 1, 1, 1, 1, 1, 1, 1, 1, 1],
];

type Dir = 'up' | 'down' | 'left' | 'right';

const VECTORS: Record<Dir, { x: number, y: number }> = {
  up: { x: 0, y: -1 },
  down: { x: 0, y: 1 },
  left: { x: -1, y: 0 },
  right: { x: 1, y: 0 }
};

const OPPOSITE: Record<Dir, Dir> = {
  up: 'down',
  down: 'up',
  left: 'right',
  right: 'left'
};

type GhostState = 'home' | 'exiting' | 'chase';

interface Ghost { 
  x: number;
  y: number;
  color: string;
  direction: Dir;
  scared: boolean;
  eaten: boolean;
  spawnTimer: number;
  role: string;
  state: GhostState;
}

interface ScorePopup {
  x: number;
  y: number;
  score: number;
  life: number;
}

class PacManGame extends Component {
  #ctx: CanvasRenderingContext2D | null = null;
  #obs: ResizeObserver | null = null;
  #loopId = 0;
  #lastTime = 0;
  #scale = 1;
  #mouth = 0.15;
  #mouthDir = 1;
  #touchStartX = 0;
  #touchStartY = 0;
  #powerTimer = 0;
  #ghostCombo = 0;
  #scorePopups: ScorePopup[] = [];

  maze: number[][] = [];
  pac = { x: 9, y: 15, dir: 'right' as Dir, next: 'right' as Dir };
  ghosts: Ghost[] = [];
  score = 0;
  gameOver = false;
  won = false;
  powerMode = false;

  constructor() {
    super();
    this.restart();
  }

  restart() {
    this.maze = INITIAL_MAZE.map(row => [...row]);
    this.pac = { x: 9, y: 15, dir: 'right', next: 'right' };
    this.ghosts = [
      { x: 9, y: 7, color: '#ff0000', direction: 'left', scared: false, eaten: false, spawnTimer: 0, role: 'blinky', state: 'chase' },
      { x: 9, y: 9, color: '#ffb8ff', direction: 'up', scared: false, eaten: false, spawnTimer: 3, role: 'pinky', state: 'home' },
      { x: 8, y: 9, color: '#00ffff', direction: 'up', scared: false, eaten: false, spawnTimer: 6, role: 'inky', state: 'home' },
      { x: 10, y: 9, color: '#ffb852', direction: 'up', scared: false, eaten: false, spawnTimer: 9, role: 'clyde', state: 'home' },
    ];
    this.score = 0;
    this.themeReset();
    this.update();
  }

  themeReset() {
    this.gameOver = false;
    this.won = false;
    this.powerMode = false;
    this.#powerTimer = 0;
    this.#ghostCombo = 0;
    this.#lastTime = 0;
    this.#mouth = 0.15;
    this.#mouthDir = 1;
    this.#scorePopups = [];
  }

  canMove(x: number, y: number) {
    const cy = Math.round(y);
    const cx = Math.round(x);
    if (cy < 0 || cy >= ROWS) return true; 
    if (cx < 0 || cx >= COLS) return true;
    return this.maze[cy][cx] !== 1;
  }

  #handleKey = (e: KeyboardEvent) => {
    if ((this.gameOver || this.won) && e.key === 'Enter') {
      return this.restart();
    }
    const dir = e.key.replace('Arrow', '').toLowerCase() as Dir;
    if (VECTORS[dir]) {
      e.preventDefault();
      this.pac.next = dir;
    }
  };

  #handleTouchStart = (e: TouchEvent) => {
    this.#touchStartX = e.touches[0].clientX;
    this.#touchStartY = e.touches[0].clientY;
  };

  #handleTouchEnd = (e: TouchEvent) => {
    const dx = e.changedTouches[0].clientX - this.#touchStartX;
    const dy = e.changedTouches[0].clientY - this.#touchStartY;
    const absX = Math.abs(dx);
    const absY = Math.abs(dy);

    if (Math.max(absX, absY) > 20) { // Threshold
      if (absX > absY) {
        this.pac.next = dx > 0 ? 'right' : 'left';
      } else {
        this.pac.next = dy > 0 ? 'down' : 'up';
      }
    }
  };

  #updateLogic(dt: number) {
    const p = this.pac;
    const d = GAME_SPEED * dt;

    if (p.next === OPPOSITE[p.dir]) {
      p.dir = p.next;
    }

    const gx = Math.round(p.x);
    const gy = Math.round(p.y);

    // Turn point logic
    if (p.next !== p.dir && Math.abs(p.x - gx) < 0.2 && Math.abs(p.y - gy) < 0.2) {
      if (this.canMove(gx + VECTORS[p.next].x, gy + VECTORS[p.next].y)) {
        p.x = gx;
        p.y = gy;
        p.dir = p.next;
      }
    }

    p.x += VECTORS[p.dir].x * d;
    p.y += VECTORS[p.dir].y * d;

    // Wall collision
    if (!this.canMove(p.x + VECTORS[p.dir].x * 0.5, p.y + VECTORS[p.dir].y * 0.5)) {
      p.x = Math.round(p.x);
      p.y = Math.round(p.y);
    }

    // Tunnel wrap
    if (p.x < -0.8) {
      p.x = COLS - 0.2;
    } else if (p.x > COLS - 0.2) {
      p.x = -0.8;
    }

    // Eating pellets
    const fx = Math.round(p.x);
    const fy = Math.round(p.y);
    if (Math.abs(p.x - fx) < 0.3 && Math.abs(p.y - fy) < 0.3 && fy >= 0 && fy < ROWS) {
      const cell = this.maze[fy][fx];
      if (cell === 2 || cell === 3) {
        this.maze[fy][fx] = 4;
        this.score += cell === 2 ? 10 : 50;
        if (cell === 3) {
          this.powerMode = true;
          this.#powerTimer = 7000;
          this.#ghostCombo = 0;
          this.ghosts.forEach(g => {
            if (!g.eaten) {
              g.scared = true;
              g.direction = OPPOSITE[g.direction];
            }
          });
        }
      }
    }

    this.won = !this.maze.some(r => r.some(c => c === 2 || c === 3));
    this.#updateGhosts(dt);
  }

  #updateGhosts(dt: number) {
    for (const g of this.ghosts) {
      if (g.eaten) {
        g.x = 9; g.y = 9;
        if ((g.spawnTimer -= dt) <= 0) { 
          g.eaten = false;
          g.state = 'home';
          g.spawnTimer = 2;
        }
        continue;
      }

      // House logic
      if (g.state === 'home') {
        g.y = 9 + Math.sin(Date.now() / 200) * 0.2;
        if ((g.spawnTimer -= dt) <= 0) g.state = 'exiting';
        continue;
      }

      if (g.state === 'exiting') {
        if (Math.abs(g.x - 9) > 0.1) g.x += (9 - g.x) * 0.1;
        else {
          g.x = 9;
          g.y -= GAME_SPEED * dt;
          if (g.y <= 7) { g.y = 7; g.state = 'chase'; g.direction = 'left'; }
        }
        continue;
      }

      const dist = (g.scared ? SCARED_SPEED : GAME_SPEED) * dt;

      // Move decision at tile center
      const nextX = g.x + VECTORS[g.direction].x * dist;
      const nextY = g.y + VECTORS[g.direction].y * dist;

      const crossedCenter = 
        (VECTORS[g.direction].x > 0 && Math.floor(g.x) !== Math.floor(nextX)) ||
        (VECTORS[g.direction].x < 0 && Math.ceil(g.x) !== Math.ceil(nextX)) ||
        (VECTORS[g.direction].y > 0 && Math.floor(g.y) !== Math.floor(nextY)) ||
        (VECTORS[g.direction].y < 0 && Math.ceil(g.y) !== Math.ceil(nextY));

      if (crossedCenter) {
        const gx = Math.round(nextX);
        const gy = Math.round(nextY);
        g.x = gx;
        g.y = gy;

        // Determine target
        let tx = Math.round(this.pac.x), ty = Math.round(this.pac.y);
        if (g.role === 'pinky') { tx += VECTORS[this.pac.dir].x * 4; ty += VECTORS[this.pac.dir].y * 4; }
        else if (g.role === 'inky') { const b = this.ghosts[0]; tx = b.x + (tx - b.x) * 2; ty = b.y + (ty - b.y) * 2; }
        else if (g.role === 'clyde' && Math.hypot(gx - tx, gy - ty) < 8) { tx = 0; ty = ROWS; }

        // Pick best available direction (cannot reverse)
        const valid = (Object.keys(VECTORS) as Dir[]).filter(d => 
          d !== OPPOSITE[g.direction] && this.canMove(gx + VECTORS[d].x, gy + VECTORS[d].y) 
        );

        if (valid.length) {
           valid.sort((a, b) => {
             const getD = (dir: Dir) => Math.hypot(gx + VECTORS[dir].x - tx, gy + VECTORS[dir].y - ty);
             return g.scared ? getD(b) - getD(a) : getD(a) - getD(b);
           });
           g.direction = valid[0];
        } else if (!this.canMove(gx + VECTORS[g.direction].x, gy + VECTORS[g.direction].y)) {
           g.direction = OPPOSITE[g.direction];
        }
      } else {
        g.x = nextX;
        g.y = nextY;
      }

      // Wall safety
      if (!this.canMove(g.x + VECTORS[g.direction].x * 0.4, g.y + VECTORS[g.direction].y * 0.4)) {
        g.x = Math.round(g.x);
        g.y = Math.round(g.y);
      }

      // Tunnel wrap (Ghost)
      if (g.x < -0.8) {
        g.x = COLS - 0.2;
      } else if (g.x > COLS - 0.2) {
        g.x = -0.8;
      }

      if (Math.hypot(g.x - this.pac.x, g.y - this.pac.y) < 0.6) {
        if (this.powerMode && g.scared) {
          const s = Math.min(200 * Math.pow(2, this.#ghostCombo++), 1600);
          this.score += s;
          this.#scorePopups.push({ x: g.x * CELL_SIZE + 11, y: g.y * CELL_SIZE, score: s, life: 60 });
          g.eaten = true;
          g.scared = false;
          g.spawnTimer = 3;
        } else {
          this.gameOver = true;
        }
      }
    }
  }

  #loop = (t: number) => {
    const dt = Math.min((t - (this.#lastTime || t)) / 1000, 0.1);
    this.#lastTime = t;

    // Mouth animation
    this.#mouth += dt * 1.8 * this.#mouthDir;
    if (this.#mouth >= 0.3) {
      this.#mouth = 0.3;
      this.#mouthDir = -1;
    } else if (this.#mouth <= 0.01) {
      this.#mouth = 0.01;
      this.#mouthDir = 1;
    }

    if (this.powerMode && (this.#powerTimer -= dt * 1000) <= 0) {
      this.powerMode = false;
      this.ghosts.forEach(g => g.scared = false);
    }

    this.#scorePopups = this.#scorePopups.filter(p => {
      p.y -= 0.5;
      return --p.life > 0;
    });

    if (!this.gameOver && !this.won) {
      this.#updateLogic(dt);
    }

    this.#draw();
    this.update();
    this.#loopId = requestAnimationFrame(this.#loop);
  };

  #draw() {
    const ctx = this.#ctx;
    if (!ctx) return;

    ctx.fillStyle = '#000';
    ctx.fillRect(0, 0, WIDTH, HEIGHT);

    this.maze.forEach((row, y) => {
      row.forEach((cell, x) => {
        const cx = x * 22 + 11;
        const cy = y * 22 + 11;

        if (cell === 1) {
          ctx.strokeStyle = '#2121de';
          ctx.lineWidth = 3;
          ctx.beginPath();
          (Object.keys(VECTORS) as Dir[]).forEach(d => {
            const v = VECTORS[d];
            if (this.maze[y + v.y]?.[x + v.x] === 1) {
              ctx.moveTo(cx, cy);
              ctx.lineTo(cx + v.x * 11, cy + v.y * 11);
            }
          });
          ctx.stroke();
        } else if (cell === 2 || cell === 3) {
          ctx.fillStyle = '#ffb8ae';
          ctx.beginPath();
          const radius = cell === 2 ? 2 : Math.sin(Date.now() / 200) * 2 + 6;
          ctx.arc(cx, cy, radius, 0, Math.PI * 2);
          ctx.fill();
        }
      });
    });

    this.#drawPacman(ctx);
    this.ghosts.forEach(g => this.#drawGhost(ctx, g));

    ctx.font = 'bold 14px Arial';
    ctx.textAlign = 'center';
    this.#scorePopups.forEach(p => {
      ctx.fillStyle = `rgba(0, 255, 255, ${p.life / 60})`;
      ctx.fillText(p.score + '', p.x, p.y);
    });
  }

  #drawPacman(ctx: CanvasRenderingContext2D) {
    const p = this.pac;
    const px = p.x * 22 + 11;
    const py = p.y * 22 + 11;
    const angles: Record<Dir, number> = { right: 0, down: 0.5, left: 1, up: 1.5 };

    ctx.fillStyle = '#ff0';
    ctx.beginPath();

    const mouthOffset = 4 * (this.#mouth / 0.3);
    const vx = px - VECTORS[p.dir].x * mouthOffset;
    const vy = py - VECTORS[p.dir].y * mouthOffset;

    ctx.moveTo(vx, vy);
    ctx.arc(
      px,
      py,
      11,
      (angles[p.dir] + this.#mouth) * Math.PI,
      (angles[p.dir] - this.#mouth + 2) * Math.PI
    );
    ctx.fill();
  }

  #drawGhost(ctx: CanvasRenderingContext2D, g: Ghost) {
    const gx = g.x * 22;
    const gy = g.y * 22;

    const isWhiteFlash = this.#powerTimer < 2000 && Math.floor(Date.now() / 150) % 2 === 0;

    if (!g.eaten) {
      ctx.fillStyle = g.scared 
        ? (isWhiteFlash ? '#fff' : '#2121de') 
        : g.color;

      ctx.beginPath();
      ctx.arc(gx + 11, gy + 11, 11, Math.PI, 0);
      ctx.lineTo(gx + 22, gy + 22);

      for (let i = 3; i > 0; i--) {
        ctx.quadraticCurveTo(
          gx + (22 / 3) * (i - 0.5),
          gy + 18,
          gx + (22 / 3) * (i - 1),
          gy + 22
        );
      }
      ctx.fill();
    }

    // Color state for facial features
    const featureColor = g.scared ? (isWhiteFlash ? '#ff0000' : '#fff') : '#fff';
    const pupilColor = g.scared ? (isWhiteFlash ? '#fff' : '#ff0000') : '#2121de';

    // Eyes
    ctx.fillStyle = featureColor;
    [6, 14].forEach(ox => {
      ctx.beginPath();
      ctx.arc(gx + ox, gy + 8, 3.5, 0, Math.PI * 2);
      ctx.fill();
    });

    // Pupils (moving or scared static)
    ctx.fillStyle = pupilColor;
    const v = VECTORS[g.direction];
    [6, 14].forEach(ox => {
      ctx.beginPath();
      const px = g.scared ? gx + ox : gx + ox + v.x * 1.5;
      const py = g.scared ? gy + 8 : gy + 8 + v.y * 1.5;
      ctx.arc(px, py, 1.8, 0, Math.PI * 2);
      ctx.fill();
    });

    // Scared mouth
    if (g.scared && !g.eaten) {
      ctx.strokeStyle = featureColor;
      ctx.lineWidth = 1;
      ctx.beginPath();
      for (let i = 0; i < 10; i++) {
        const mx = gx + 6 + i * 1.1;
        const my = gy + 16 + (i % 2 === 0 ? 0 : 2);
        if (i === 0) ctx.moveTo(mx, my);
        else ctx.lineTo(mx, my);
      }
      ctx.stroke();
    }
  }

  initCanvas(el: HTMLCanvasElement) {
    this.#ctx = el.getContext('2d');
    window.addEventListener('keydown', this.#handleKey);
    el.addEventListener('touchstart', this.#handleTouchStart, { passive: true });
    el.addEventListener('touchend', this.#handleTouchEnd, { passive: true });

    this.#obs = new ResizeObserver(() => {
      const wrapper = el.closest('.maze-wrapper');
      if (!wrapper) return;
      const b = wrapper.getBoundingClientRect();
      this.#scale = Math.min((b.width - 10) / WIDTH, (b.height - 10) / HEIGHT);
      this.update();
    });

    this.#obs.observe(el.closest('.maze-wrapper')!);
    this.#loopId = requestAnimationFrame(this.#loop);

    return () => {
      window.removeEventListener('keydown', this.#handleKey);
      el.removeEventListener('touchstart', this.#handleTouchStart);
      el.removeEventListener('touchend', this.#handleTouchEnd);
      cancelAnimationFrame(this.#loopId);
      this.#obs?.disconnect();
    };
  }

  view() {
    return div({ class: 'game-container' },
      div({ class: 'header' },
        h1('PAC-MAN'),
        div({ class: 'score' }, `Score: ${this.score}`)
      ),
      div({ class: 'maze-wrapper' },
        div({ 
          class: 'maze-container', 
          style: { transform: `scale(${this.#scale})` } 
        },
          canvas({ 
            width: WIDTH, 
            height: HEIGHT, 
            onMounted: el => this.initCanvas(el as HTMLCanvasElement) 
          }),
          (this.gameOver || this.won) && div({ class: 'overlay' },
            div({ class: 'message' }, this.gameOver ? 'GAME OVER' : 'YOU WIN!'),
            button({ onClick: () => this.restart() }, 'Play Again')
          )
        )
      ),
      div({ class: 'instructions' }, 'Use Arrow Keys or Swipe to Move')
    );
  }
}

new App({ root: new PacManGame(), id: 'app' });
```
**styles.css**

```css
:root {
  --bg-color: #000;
  --wall-color: #2121de;
  --text-color: #fff;
}

html[data-theme="light"] {
  --bg-color: #1a1a2e;
  --wall-color: #4040ff;
}

* {
  box-sizing: border-box;
}

html, body, #app {
  margin: 0;
  padding: 0;
  height: 100%;
  overflow: hidden;
}

body {
  display: flex;
  justify-content: center;
  align-items: center;
  background: var(--bg-color);
  font-family: 'Arial', sans-serif;
}

.game-container {
  display: flex;
  flex-direction: column;
  align-items: center;
  justify-content: center;
  height: 100vh;
  width: 100vw;
  padding: 10px;
  gap: 10px;
}

.header {
  text-align: center;
  flex-shrink: 0;
}

.header h1 {
  color: #ffff00;
  margin: 0;
  font-size: clamp(1.5rem, 5vw, 2.5rem);
  text-shadow: 2px 2px 4px rgba(0,0,0,0.5);
}

.score {
  color: var(--text-color);
  font-size: clamp(1rem, 3vw, 1.2rem);
  margin-top: 5px;
}

.maze-wrapper {
  display: flex;
  justify-content: center;
  align-items: center;
  width: 100%;
  flex: 1;
  min-height: 0;
  overflow: hidden; /* Ensure it doesn't spill out */
  touch-action: none;
}

.maze-container {
  display: flex;
  justify-content: center;
  align-items: center;
  transform-origin: center center;
}

.canvas-wrapper canvas {
  display: block;
  background: black;
}

.overlay {
  position: absolute;
  inset: 0;
  background: rgba(0, 0, 0, 0.85);
  display: flex;
  flex-direction: column;
  justify-content: center;
  align-items: center;
  gap: 20px;
  z-index: 100;
  border-radius: 2px;
}

.message {
  color: #ffff00;
  font-size: 2rem;
  font-weight: bold;
  text-shadow: 2px 2px 4px rgba(0,0,0,0.5);
}

.overlay button {
  padding: 10px 30px;
  font-size: 1.2rem;
  background: #ffff00;
  color: #000; /* Fixed unreadable text */
  border: none;
  border-radius: 5px;
  cursor: pointer;
  font-weight: bold;
  transition: transform 0.2s;
}

.overlay button:hover {
  transform: scale(1.1);
}

.instructions {
  color: var(--text-color);
  font-size: 0.9rem;
  opacity: 0.7;
  flex-shrink: 0;
  margin-bottom: 5px;
}

```
**index.html**

```html
<div id="app"></div>
```
**config.json**

```json
{
  "dependencies": {
    "domeleon": "^0.5.2"
  },
  "description": "Classic pacman gmae"
}
```
**notes.md**

````md
## Using Domeleon

Domeleon is a compact abstraction over any virtual DOM, including Preact (the default), React, and Vue. A Domeleon Component is a class representing application state rather than a wrapper around a DOM element. Components may define a pure view function of their state, and compose into hierarchies that can be updated, serialized, validated, or routed.

### Example

**HTML**
```html
<div id="app"></div>
```

**Code**
```ts
import { App, Component, div, button, formField, inputRange, canvas } from "domeleon"
import { inputNumber } from "domeleon/maskito"

class Counter extends Component {
  count: number
  role: string
  #canvasEl: HTMLCanvasElement
  #canvasCtx: CanvasRenderingContext2D

  constructor(initial: number, role: string) {
    super()
    this.count = initial
    this.role = role
  }

  view() {
    return div({ class: "counter" },
      formField ({ // databound input
        target: this,
        inputFn: inputRange, // inputSelect, inputText, inputTextArea, inputCheckbox etc.
        prop: () => this.count,
        label: this.role,
        fieldAttrs: { class: "field-class"},
        labelAttrs: { class: "label-class"},
        inputProps: { attrs: { min: 100, max: 250, step: 1 } }
      }),      
      button({ onClick: () => this.inc(+1) }, "+"), // always use closure to bind to preserve "this"
      button({ onClick: () => this.inc(-1) }, "-"),
      this.count,
      canvas({
        width: 400,
        height: 20,
        onMounted: el => {
          this.#canvasEl = el as HTMLCanvasElement
          this.#canvasCtx = this.#canvasEl.getContext("2d")
          return () => { // dispose
            this.#canvasEl = undefined
            this.#canvasCtx = undefined
          }
        }
      })
    )
  }
  
  inc(x: number) {
    this.count+=x
    this.update() // triggers render on app
  }

  // `onRendered` called after VDOM has created/updated DOM elements
  // useful for operations that require raw DOM access, such as canvas operations
  override onRendered () { 
     this.drawOnCanvas()
  }

  drawOnCanvas() {    
    const canvas = this.#canvasEl
    const cc = this.#canvasCtx
    cc.clearRect(0, 0, canvas.width, canvas.height)
    const barWidth = (this.count / 250) * canvas.width
    cc.fillStyle = "#4a90e2"
    cc.fillRect(0, 0, barWidth, canvas.height)
  }
}

class Game extends Component {
  wins = new Counter(10, "wins")
  losses = new Counter(5, "losses")

  view() {
    return div(
      this.wins.view(),
      this.losses.view()
    )
  }
}

new App({ root: new Game(), id: "app"})
```
````