← DEVLOG
Game Dev2025.09.178 min read

Recreating Galaga in Canvas

Classic arcade Galaga — enemy formation AI, tractor beam, sprite caching — a record of rebuilding it in Canvas 2D

canvasweb-audiogame-aisprite-cache

The Beginning — Arcade Memories

Galaga looks simple, but it's remarkably precise under the hood. Formation movement, dive patterns, tractor beam boss — looking at it again now, it's astonishing that these mechanics existed in 1981.

I wanted to see if Canvas 2D API could reproduce it faithfully. The first problem I ran into was rendering performance.


The shadowBlur Problem — Sprite Caching Pattern

For Galaga's characteristic glow effects, I initially applied shadowBlur to every enemy on every draw call. The result was brutal. With just 40 enemies on screen, frame drops were severe.

shadowBlur cost scales with pixel count. Recalculating blur every frame for the same shape repeatedly is wasteful. The solution was a sprite caching pattern — render once to an offscreen canvas and reuse.

const spriteCache = new Map<string, HTMLCanvasElement>();

function getGlowSprite(type: EnemyType, color: string): HTMLCanvasElement {
  const key = `${type}-${color}`;
  if (spriteCache.has(key)) return spriteCache.get(key)!;

  const offscreen = document.createElement('canvas');
  offscreen.width = ENEMY_SIZE + GLOW_PADDING * 2;
  offscreen.height = ENEMY_SIZE + GLOW_PADDING * 2;
  const ctx = offscreen.getContext('2d')!;

  ctx.shadowBlur = 12;
  ctx.shadowColor = color;
  drawEnemyShape(ctx, type, GLOW_PADDING, GLOW_PADDING, ENEMY_SIZE);

  spriteCache.set(key, offscreen);
  return offscreen;
}

After caching, shadowBlur is applied directly only to the player ship (1 object) and player bullets (max 2). All Bee, Butterfly, and Boss enemies are drawn by copying the cached image via drawImage. With 40 enemies on screen, render time dropped from ~8ms to under 1ms.


Formation Movement — The offsetX System

Galaga enemies don't move independently. The entire formation moves as one unit, shifting left and right and descending slightly each time it hits a wall.

interface Formation {
  offsetX: number;
  dir: 1 | -1;
  speed: number;
}

function updateFormation(formation: Formation, enemies: Enemy[]) {
  formation.offsetX += formation.dir * formation.speed;

  // Find the leftmost and rightmost enemy in formation
  const leftmost = Math.min(...enemies.map(e => e.homeX + formation.offsetX));
  const rightmost = Math.max(...enemies.map(e => e.homeX + formation.offsetX));

  if (leftmost <= WALL_MARGIN || rightmost >= GAME_W - WALL_MARGIN) {
    formation.dir *= -1;
    // Descend 6px each time the wall is hit
    enemies.forEach(e => {
      e.homeY += 6;
    });
  }
}

Each enemy's actual position is homeX + formation.offsetX combined with homeY. Enemies in a dive state detach from the formation offset and follow an independent trajectory.


Dive AI — State Transition Pattern

The dive pattern is managed with three states.

type EnemyState = 'formation' | 'diving' | 'returning';

In formation state, enemies move with the group. At a certain probability, a dive begins — the enemy transitions to diving and descends along a Bézier curve path toward the player's X position. Once it exits the bottom of the screen, it reappears at the top and enters returning state to rejoin the formation.

The Boss Galaga opens a wing pair on dive to fire a tractor beam. A captured player ship joins the enemy formation, and if the player shoots it down, they recover their ship and activate dual-fire mode — worth double points.

if (boss.state === 'diving' && boss.tractorActive) {
  if (checkBeamCollision(boss, player)) {
    player.captured = true;
    boss.capturedShip = createCapturedShip(player);
    boss.tractorActive = false;
  }
}

Audio — The pendingSounds Queue Pattern

The Web Audio API AudioContext operates on a separate timing thread from the game loop. Playing sounds immediately during game logic processing can cause multiple sounds to overlap in the same frame or delay rendering.

The solution is to queue sound events into a pendingSounds array during the game loop and flush them at the end.

const pendingSounds: SoundEvent[] = [];

// During collision detection
pendingSounds.push({ type: 'enemyExplode', x: enemy.x });

// At the end of the loop
pendingSounds.forEach(evt => audioModule.play(evt));
pendingSounds.length = 0;

All sounds — enemy destroy, player fire, tractor beam effects — go through this queue. A debounce also removes duplicates if the same sound is queued twice in the same frame.


Stage Design

If Boss Galaga appears from stage 1, there's no sense of escalation. The early stages are Bee-only, with Butterflies and then Bosses added as stages progress.

The formation layout was based on the original arcade version: Bees in 5 columns × 4 rows, Butterflies in 2 rows above, and Bosses in a single top row. Each group flies in from the side of the screen in a curved entry animation before settling into position.


Closing Thoughts

Implementing Galaga gave me a deeper appreciation for the choices the Namco team made in 1981. Why the formation moves as a unit, why diving enemies follow exactly that path — these were all solutions to hardware constraints that also delivered the best possible gameplay.

Recreating in code the game that once cost me a pocketful of coins, I felt like I'd recaptured something from childhood.

Content related to this post

Try it yourself