7

Hopfield Memory Recall

click to corrupt · drag Y for # stored patterns

A Hopfield network on a -neuron bipolar grid (). At init we Hebbian-store a small library of canned glyphs (heart, star, , smiley, …): for , . The Lyapunov energy is non-increasing under asynchronous Glauber updates — pick a random neuron, replace it with the sign of its local field, repeat. Each frame the simulator runs such updates and plots below the grid; you can watch the network walk downhill on the energy landscape and snap into the nearest attractor. **Click on the grid** to flip cells under your cursor (paint corruption into the current state), or **click outside the grid** to slam-replace it with a fresh -flipped copy of a stored pattern. After consecutive no-flip updates the state is declared converged, the recalled pattern is identified by overlap (or flagged spurious if none qualify), and after a brief hold the network is re-seeded with another corruption. **The pedagogy**: drag the mouse upward to crank the number of stored patterns toward the maximum of 10. The classical Amit–Gutfreund–Sompolinsky capacity for orthogonal patterns is , but the canned glyphs here share a lot of structure (mostly empty borders, lots of correlated central pixels), so cross-talk wrecks recall well before that — past about the network often falls into spurious mixed states that don't match any single stored memory. The HUD shows relative to so you can see where you are on the saturation curve, and the status line announces whether the converged state was a clean recall or a spurious attractor.

idle
541 lines · vanilla
view source
// Hopfield network as associative memory on a 12x12 binary grid (N = 144 neurons,
// bipolar states s_i in {-1,+1}). Patterns are stored via the Hebbian rule
//   W_ij = (1/N) * sum_p xi_i^p xi_j^p   for i != j,  W_ii = 0.
// Dynamics are asynchronous Glauber updates: pick a random neuron and set
//   s_i <- sign(sum_j W_ij s_j).
// The Lyapunov energy E = -1/2 sum_ij W_ij s_i s_j is non-increasing.
// Capacity ~ 0.14 N: above that, the network falls into spurious mixed states
// instead of stored patterns. The user controls the number of stored patterns
// via mouseY to demonstrate this.
//
// Sandbox contract: vanilla worker + OffscreenCanvas, no DOM.

const N_SIDE = 12;
const N = N_SIDE * N_SIDE;          // 144
const MAX_PATTERNS = 10;
const MIN_PATTERNS = 3;
const UPDATES_PER_FRAME = 24;       // async Glauber steps per draw frame
const CONVERGE_STEPS = 60;          // no flips for this many updates -> converged
const HOLD_FRAMES = 90;             // pause on the recalled pattern
const ENERGY_HIST = 360;            // energy graph ring buffer
const CORRUPT_RADIUS = 1.5;         // brush radius in grid cells

// Palette — neuro-deep-purple background, warm accents.
const BG       = '#0e0a18';
const PANEL    = '#171127';
const PANEL_EDGE = 'rgba(255,255,255,0.05)';
const GRID_LINE = 'rgba(255,255,255,0.06)';
const ON_COLOR  = '#f4c97a';        // warm gold for s=+1
const OFF_COLOR = '#221830';        // muted near-bg for s=-1
const HOVER_TINT = 'rgba(244,201,122,0.18)';
const TEXT     = '#e9e4f5';
const MUTED    = 'rgba(233,228,245,0.55)';
const ACCENT   = '#c084fc';         // purple-400
const ACCENT_DIM = 'rgba(192,132,252,0.55)';
const ENERGY_LINE = '#22d3ee';      // cyan-400
const ENERGY_FILL = 'rgba(34,211,238,0.15)';
const FLASH    = 'rgba(244,201,122,0.35)';

// --- runtime state ---
let W = 0, H = 0;
let gridBox = { x: 0, y: 0, w: 0, h: 0, cell: 0 };
let energyBox = { x: 0, y: 0, w: 0, h: 0 };
let hudBox = { x: 0, y: 0, w: 0, h: 0 };
let s;                  // Int8Array length N, bipolar +/-1
let weights;            // Float32Array N*N
let patterns = [];      // Array<Int8Array>, each length N
let nStored = 4;        // current number of stored patterns
let lastNStored = -1;   // detect changes to rebuild
let energyHist;         // Float32Array ring buffer of energies
let energyIdx = 0;
let energyCount = 0;
let stableCount = 0;    // # consecutive no-flip Glauber attempts
let holdLeft = 0;       // post-convergence pause frames
let recalledIdx = -1;   // index of pattern that current state matches (or -1)
let lastClickFrame = -1000;
let mouseDownPrev = false;
let frameCount = 0;
let rng;                // Mulberry32 PRNG for repeatable patterns

// ---------- PRNG (so canned patterns are stable across reloads) ----------

function mulberry32(seed) {
  let t = seed >>> 0;
  return function () {
    t = (t + 0x6D2B79F5) >>> 0;
    let x = t;
    x = Math.imul(x ^ (x >>> 15), x | 1);
    x ^= x + Math.imul(x ^ (x >>> 7), x | 61);
    return ((x ^ (x >>> 14)) >>> 0) / 4294967296;
  };
}

// ---------- canned glyph patterns (rendered on a 12x12 bipolar grid) ----------
//
// Each glyph is an Array<string> of length 12, '#' = +1, '.' = -1.
// We define a small library; if more are requested we synthesise pseudo-random
// orthogonal-ish noise patterns to fill up to MAX_PATTERNS.

const GLYPHS = [
  // 0: heart
  [
    '............',
    '...##....##.',
    '..####..####',
    '.###########',
    '.###########',
    '.###########',
    '..#########.',
    '...#######..',
    '....#####...',
    '.....###....',
    '......#.....',
    '............',
  ],
  // 1: star
  [
    '............',
    '......#.....',
    '......#.....',
    '.....###....',
    '############',
    '.##########.',
    '..########..',
    '..########..',
    '.###.##.###.',
    '.##......##.',
    '.#........#.',
    '............',
  ],
  // 2: greek omega
  [
    '............',
    '....####....',
    '..########..',
    '.###....###.',
    '.##......##.',
    '##........##',
    '##........##',
    '##........##',
    '.##......##.',
    '.#.#....#.#.',
    'X##.X..X.##X',  // legs — placeholder; sanitised below
    '............',
  ],
  // 3: smiley face
  [
    '............',
    '...######...',
    '..########..',
    '.##.####.##.',
    '.##.####.##.',
    '.##########.',
    '.##########.',
    '.##.####.##.',
    '..##....##..',
    '..########..',
    '...######...',
    '............',
  ],
  // 4: arrow up
  [
    '......#.....',
    '.....###....',
    '....#####...',
    '...#######..',
    '..#########.',
    '.###########',
    '.....###....',
    '.....###....',
    '.....###....',
    '.....###....',
    '.....###....',
    '.....###....',
  ],
  // 5: checker / cross
  [
    '............',
    '.##......##.',
    '..##....##..',
    '...##..##...',
    '....####....',
    '.....##.....',
    '.....##.....',
    '....####....',
    '...##..##...',
    '..##....##..',
    '.##......##.',
    '............',
  ],
  // 6: yin-yang-ish spiral seed
  [
    '............',
    '...#####....',
    '..##...##...',
    '.##.....##..',
    '.##..#..##..',
    '.##..#..##..',
    '..#..#..##..',
    '..##.#..##..',
    '...#######..',
    '....######..',
    '.....####...',
    '............',
  ],
];

// Sanitise glyph 2 (used a placeholder); turn 'X' into '#'.
for (let i = 0; i < GLYPHS.length; i++) {
  for (let r = 0; r < GLYPHS[i].length; r++) {
    GLYPHS[i][r] = GLYPHS[i][r].replace(/X/g, '#');
  }
}

function glyphToPattern(glyph) {
  const p = new Int8Array(N);
  for (let y = 0; y < N_SIDE; y++) {
    const row = glyph[y] || '............';
    for (let x = 0; x < N_SIDE; x++) {
      p[y * N_SIDE + x] = row[x] === '#' ? 1 : -1;
    }
  }
  return p;
}

function randomPattern(seedRng) {
  // Pseudo-random bipolar pattern, biased slightly sparse so it looks visually
  // distinct from glyphs. Each cell is +1 with prob 0.5.
  const p = new Int8Array(N);
  for (let i = 0; i < N; i++) p[i] = seedRng() < 0.5 ? -1 : 1;
  return p;
}

// ---------- network construction ----------

function buildPatterns(k) {
  // Always start with the canned glyphs in order, then pad with PRNG patterns.
  const out = [];
  const prng = mulberry32(0x1f3a5c11);
  for (let i = 0; i < k; i++) {
    if (i < GLYPHS.length) out.push(glyphToPattern(GLYPHS[i]));
    else out.push(randomPattern(prng));
  }
  return out;
}

function buildWeights(pats) {
  const w = new Float32Array(N * N);
  for (let p = 0; p < pats.length; p++) {
    const xp = pats[p];
    for (let i = 0; i < N; i++) {
      const xi = xp[i];
      if (xi === 0) continue;
      for (let j = i + 1; j < N; j++) {
        const v = xi * xp[j];
        w[i * N + j] += v;
        w[j * N + i] += v;
      }
    }
  }
  // Diagonal stays zero by construction. Apply Hebbian normalisation.
  const inv = 1 / N;
  for (let k = 0; k < w.length; k++) w[k] *= inv;
  return w;
}

function rebuildNetwork() {
  patterns = buildPatterns(nStored);
  weights = buildWeights(patterns);
  lastNStored = nStored;
}

// ---------- dynamics ----------

function localField(i) {
  let h = 0;
  const row = i * N;
  for (let j = 0; j < N; j++) h += weights[row + j] * s[j];
  return h;
}

function totalEnergy() {
  // E = -1/2 sum_ij W_ij s_i s_j. Use upper triangle, then *2 cancels the 1/2.
  let e = 0;
  for (let i = 0; i < N; i++) {
    const si = s[i];
    const row = i * N;
    for (let j = i + 1; j < N; j++) {
      e -= weights[row + j] * si * s[j];
    }
  }
  return e;
}

function glauberStep() {
  // One async update on a random neuron. Returns true if it flipped.
  const i = (Math.random() * N) | 0;
  const h = localField(i);
  const target = h >= 0 ? 1 : -1;
  if (s[i] !== target) {
    s[i] = target;
    return true;
  }
  return false;
}

function classifyState() {
  // Return index of stored pattern (or its negation) within Hamming
  // similarity > 0.92, else -1. Negations of stored patterns are also
  // attractors of the Hebbian network.
  let bestIdx = -1, bestOverlap = 0.92;
  for (let p = 0; p < patterns.length; p++) {
    let sum = 0;
    const xp = patterns[p];
    for (let i = 0; i < N; i++) sum += xp[i] * s[i];
    const m = Math.abs(sum) / N;
    if (m > bestOverlap) {
      bestOverlap = m;
      bestIdx = p;
    }
  }
  return bestIdx;
}

// ---------- corruption ----------

function corruptFromPattern(pIdx, flipFrac) {
  const src = patterns[pIdx];
  s = new Int8Array(N);
  for (let i = 0; i < N; i++) {
    s[i] = (Math.random() < flipFrac) ? -src[i] : src[i];
  }
  resetEnergyAfterCorrupt();
}

function corruptRandom() {
  s = new Int8Array(N);
  for (let i = 0; i < N; i++) s[i] = Math.random() < 0.5 ? -1 : 1;
  resetEnergyAfterCorrupt();
}

function resetEnergyAfterCorrupt() {
  stableCount = 0;
  holdLeft = 0;
  recalledIdx = -1;
  energyIdx = 0;
  energyCount = 0;
  energyHist.fill(0);
  pushEnergy(totalEnergy());
}

function pushEnergy(e) {
  energyHist[energyIdx] = e;
  energyIdx = (energyIdx + 1) % ENERGY_HIST;
  if (energyCount < ENERGY_HIST) energyCount++;
}

// ---------- layout ----------

function layout() {
  const pad = 12;
  const hudH = 56;
  hudBox = { x: pad, y: pad, w: W - pad * 2, h: hudH };

  const energyH = Math.max(70, Math.min(120, ((H - hudH - pad * 4) * 0.22) | 0));
  energyBox = {
    x: pad,
    y: H - pad - energyH,
    w: W - pad * 2,
    h: energyH,
  };

  const gridAreaY = hudBox.y + hudBox.h + pad;
  const gridAreaH = energyBox.y - pad - gridAreaY;
  const gridAreaW = W - pad * 2;
  const side = Math.max(60, Math.min(gridAreaW, gridAreaH));
  const cell = Math.floor(side / N_SIDE);
  const realSide = cell * N_SIDE;
  gridBox = {
    x: pad + ((gridAreaW - realSide) / 2) | 0,
    y: gridAreaY + ((gridAreaH - realSide) / 2) | 0,
    w: realSide,
    h: realSide,
    cell,
  };
}

// ---------- input ----------

function handleInput(input) {
  // mouseY (top -> bottom) controls nStored: top of screen = MAX, bottom = MIN.
  // The user can "crank Y" to overload the network.
  const yFrac = clamp(input.mouseY / H, 0, 1);
  const target = MIN_PATTERNS + Math.round((1 - yFrac) * (MAX_PATTERNS - MIN_PATTERNS));
  if (target !== nStored) {
    nStored = target;
    rebuildNetwork();
    // Re-seed with a fresh corruption so the user sees the new attractor landscape.
    corruptFromPattern(0, 0.30);
  }

  // Click anywhere to corrupt-from-pattern-0 with stronger flips. Clicking
  // inside the grid also paints flips directly on the current state at
  // that cell — this is the "draw your own corruption" UX.
  const down = input.mouseDown;
  if (down) {
    const inGrid = pointInGrid(input.mouseX, input.mouseY);
    if (inGrid) {
      paintFlipsAt(input.mouseX, input.mouseY);
      // Painting interrupts convergence hold.
      stableCount = 0;
      holdLeft = 0;
      recalledIdx = -1;
    } else if (!mouseDownPrev) {
      // Outside the grid: full corruption from a random stored pattern.
      const which = (Math.random() * Math.min(patterns.length, 3)) | 0;
      corruptFromPattern(which, 0.35);
      lastClickFrame = frameCount;
    }
  }
  mouseDownPrev = down;
}

function pointInGrid(px, py) {
  return px >= gridBox.x && px < gridBox.x + gridBox.w
      && py >= gridBox.y && py < gridBox.y + gridBox.h;
}

function paintFlipsAt(px, py) {
  const cell = gridBox.cell;
  const cx = (px - gridBox.x) / cell;
  const cy = (py - gridBox.y) / cell;
  const r = CORRUPT_RADIUS;
  const x0 = Math.max(0, Math.floor(cx - r));
  const x1 = Math.min(N_SIDE - 1, Math.ceil(cx + r));
  const y0 = Math.max(0, Math.floor(cy - r));
  const y1 = Math.min(N_SIDE - 1, Math.ceil(cy + r));
  for (let yy = y0; yy <= y1; yy++) {
    for (let xx = x0; xx <= x1; xx++) {
      const dx = (xx + 0.5) - cx;
      const dy = (yy + 0.5) - cy;
      if (dx * dx + dy * dy <= r * r) s[yy * N_SIDE + xx] = -s[yy * N_SIDE + xx];
    }
  }
}

function clamp(v, lo, hi) { return v < lo ? lo : v > hi ? hi : v; }

// ---------- drawing ----------

function roundRect(ctx, x, y, w, h, r) {
  const rr = Math.min(r, w / 2, h / 2);
  ctx.beginPath();
  ctx.moveTo(x + rr, y);
  ctx.lineTo(x + w - rr, y);
  ctx.arcTo(x + w, y, x + w, y + rr, rr);
  ctx.lineTo(x + w, y + h - rr);
  ctx.arcTo(x + w, y + h, x + w - rr, y + h, rr);
  ctx.lineTo(x + rr, y + h);
  ctx.arcTo(x, y + h, x, y + h - rr, rr);
  ctx.lineTo(x, y + rr);
  ctx.arcTo(x, y, x + rr, y, rr);
  ctx.closePath();
}

function drawPanel(ctx, x, y, w, h) {
  ctx.fillStyle = PANEL;
  roundRect(ctx, x, y, w, h, 8);
  ctx.fill();
  ctx.strokeStyle = PANEL_EDGE;
  ctx.lineWidth = 1;
  ctx.stroke();
}

function drawHUD(ctx) {
  const b = hudBox;
  drawPanel(ctx, b.x, b.y, b.w, b.h);

  // Title row
  ctx.fillStyle = TEXT;
  ctx.font = '13px ui-sans-serif, system-ui, sans-serif';
  ctx.textBaseline = 'top';
  ctx.fillText('Hopfield network · 12×12 = 144 neurons', b.x + 12, b.y + 8);

  // Stored count meter
  const meterX = b.x + 12;
  const meterY = b.y + 30;
  const meterW = Math.min(220, b.w - 24);
  const meterH = 10;
  ctx.fillStyle = 'rgba(255,255,255,0.06)';
  roundRect(ctx, meterX, meterY, meterW, meterH, 5);
  ctx.fill();
  // Capacity threshold: P_max ~ 0.138 N ≈ 20, but visually the spurious
  // states kick in well before that on a 144-cell grid with structured
  // (non-orthogonal) glyphs. Show a danger zone past ~5.
  const dangerFrac = Math.min(1, Math.max(0, (nStored - 5) / (MAX_PATTERNS - 5 + 1e-6)));
  if (dangerFrac > 0) {
    ctx.fillStyle = 'rgba(244,114,182,0.45)';
    roundRect(ctx, meterX, meterY, meterW * dangerFrac, meterH, 5);
    ctx.fill();
  }
  ctx.fillStyle = ACCENT;
  const filled = (nStored - MIN_PATTERNS) / (MAX_PATTERNS - MIN_PATTERNS);
  roundRect(ctx, meterX, meterY, Math.max(8, meterW * filled), meterH, 5);
  ctx.fill();

  ctx.fillStyle = MUTED;
  ctx.font = '11px ui-sans-serif, system-ui, sans-serif';
  ctx.fillText(`stored patterns: ${nStored}  (drag Y to change)`, meterX, meterY + meterH + 4);

  // Right-side status
  const rightX = b.x + b.w - 12;
  ctx.textAlign = 'right';
  ctx.fillStyle = TEXT;
  ctx.font = '12px ui-sans-serif, system-ui, sans-serif';
  let status;
  if (holdLeft > 0) {
    status = (recalledIdx >= 0 && recalledIdx < GLYPHS.length)
      ? `recalled stored pattern #${recalledIdx + 1}`
      : 'converged to spurious attractor';
  } else {
    status = `relaxing… (${stableCount}/${CONVERGE_STEPS} stable)`;
  }
  ctx.fillText(status, rightX, b.y + 8);
  ctx.fillStyle = MUTED;
  ctx.font = '11px ui-sans-serif, system-ui, sans-serif';
  const E = energyCount > 0 ? energyHist[(energyIdx - 1 + ENERGY_HIST) % ENERGY_HIST] : 0;
  ctx.fillText(`E = ${E.toFixed(3)}`, rightX, b.y + 26);
  ctx.fillText(`P/N ≈ ${(nStored / N).toFixed(3)}   (critical ≈ 0.138)`, rightX, b.y + 42);
  ctx.textAlign = 'left';
}

function drawGrid(ctx) {
  const b = gridBox;
  // Panel background
  drawPanel(ctx, b.x - 8, b.y - 8, b.w + 16, b.h + 16);

  const cell = b.cell;
  for (let i = 0; i < N; i++) {
    const x = i % N_SIDE;
    const y = (i / N_SIDE) | 0;
    const px = b.x + x * cell;
    const py = b.y + y * cell;
    if (s[i] > 0) {
      ctx.fillStyle = ON_COLOR;
      ctx.fillRect(px + 1, py + 1, cell - 2, cell - 2);
    } else {
      ctx.fillStyle = OFF_COLOR;
      ctx.fillRect(px + 1, py + 1, cell - 2, cell - 2);
    }
  }

  // Convergence flash overlay
  if (holdLeft > 0) {
    const t = holdLeft / HOLD_FRAMES;
    ctx.fillStyle = `rgba(244,201,122,${0.06 + 0.05 * t})`;
    roundRect(ctx, b.x - 2, b.y - 2, b.w + 4, b.h + 4, 4);
    ctx.fill();
  }

  // Lattice lines (subtle)
  ctx.strokeStyle = GRID_LINE;
  ctx.lineWidth = 1;
  ctx.beginPath();
  for (let i = 0; i <= N_SIDE; i++) {
    ctx.moveTo(b.x + i * cell + 0.5, b.y);
    ctx.lineTo(b.x + i * cell + 0.5, b.y + b.h);
    ctx.moveTo(b.x, b.y + i * cell + 0.5);
    ctx.lineTo(b.x + b.w, b.y + i * cell + 0.5);
  }
  ctx.stroke();
}

function drawEnergy(ctx) {
  const b = energyBox;
  drawPanel(ctx, b.x, b.y, b.w, b.h);

  // Label
  ctx.fillStyle = MUTED;
  ctx.font = '11px ui-sans-serif, system-ui, sans-serif';
  ctx.textBaseline = 'top';
  ctx.fillText('energy  E(t) = -½ Σ Wᵢⱼ sᵢsⱼ', b.x + 10, b.y + 6);

  if (energyCount < 2) return;

  // Compute current y range from the buffer.
  let lo = Infinity, hi = -Infinity;
  for (let i = 0; i < energyCount; i++) {
    const v = energyHist[i];
    if (v < lo) lo = v;
    if (v > hi) hi = v;
  }
  if (hi - lo < 1e-3) { hi += 1e-2; lo -= 1e-2; }
  const padTop = 22;
  const padBot = 10;
  const innerY = b.y + padTop;
  const innerH = b.h - padTop - padBot;

  const xFor = (i) => b.x + 10 + (i / Math.max(1, energyCount - 1)) * (b.w - 20);
  const yFor = (v) => innerY + (1 - (v - lo) / (hi - lo)) * innerH;

  // Filled area
  ctx.beginPath();
  // Iterate in chronological order through the ring buffer.
  const startIdx = energyCount < ENERGY_HIST ? 0 : energyIdx;
  for (let k = 0; k < energyCount; k++) {
    const idx = (startIdx + k) % ENERGY_HIST;
    const x = xFor(k);
    const y = yFor(energyHist[idx]);
    if (k === 0) ctx.moveTo(x, y);
    else ctx.lineTo(x, y);
  }
  // Close to baseline
  const lastX = xFor(energyCount - 1);
  ctx.lineTo(lastX, innerY + innerH);
  ctx.lineTo(b.x + 10, innerY + innerH);
  ctx.closePath();
  ctx.fillStyle = ENERGY_FILL;
  ctx.fill();

  // Line
  ctx.beginPath();
  for (let k = 0; k < energyCount; k++) {
    const idx = (startIdx + k) % ENERGY_HIST;
    const x = xFor(k);
    const y = yFor(energyHist[idx]);
    if (k === 0) ctx.moveTo(x, y);
    else ctx.lineTo(x, y);
  }
  ctx.strokeStyle = ENERGY_LINE;
  ctx.lineWidth = 1.6;
  ctx.stroke();

  // Current value marker
  const lastY = yFor(energyHist[(energyIdx - 1 + ENERGY_HIST) % ENERGY_HIST]);
  ctx.fillStyle = ENERGY_LINE;
  ctx.beginPath();
  ctx.arc(lastX, lastY, 2.5, 0, Math.PI * 2);
  ctx.fill();
}

// ---------- lifecycle ----------

function init({ width, height }) {
  W = width; H = height;
  energyHist = new Float32Array(ENERGY_HIST);
  rng = mulberry32(0xdeadbeef);
  rebuildNetwork();
  // Seed with a corruption of the first stored pattern.
  corruptFromPattern(0, 0.30);
  layout();
}

function tick({ ctx, width, height, input }) {
  if (width !== W || height !== H) {
    W = width; H = height;
    layout();
  } else if (gridBox.w === 0) {
    layout();
  }

  frameCount++;

  handleInput(input);

  // Run async Glauber updates unless we're holding on a converged state.
  if (holdLeft > 0) {
    holdLeft--;
    if (holdLeft === 0) {
      // Re-seed with a corruption of a (randomly chosen) stored pattern,
      // weighted toward the first 3 canned glyphs for recognisability.
      const which = (Math.random() * Math.min(patterns.length, 3)) | 0;
      corruptFromPattern(which, 0.32);
    }
  } else {
    for (let k = 0; k < UPDATES_PER_FRAME; k++) {
      const flipped = glauberStep();
      if (flipped) stableCount = 0;
      else stableCount++;
      if (stableCount >= CONVERGE_STEPS) {
        recalledIdx = classifyState();
        holdLeft = HOLD_FRAMES;
        break;
      }
    }
    // Push one energy sample per frame for a smooth curve.
    pushEnergy(totalEnergy());
  }

  // --- render ---
  ctx.fillStyle = BG;
  ctx.fillRect(0, 0, W, H);

  drawGrid(ctx);
  drawHUD(ctx);
  drawEnergy(ctx);
}

Comments (0)

Log in to comment.