< Back to Arcade

How It Really Works

Developer Commentary

Architecture

The entire arcade is vanilla JavaScript with ES Modules. No React. No Svelte. No Webpack. No Vite. No npm install. Zero build step. You open index.html in a browser (or push to GitHub Pages) and it just works.

Why?

Project Structure

galaxy2/ css/ styles.css # site-wide neon theme crt-effects.css # scanlines, vignette, flicker game-common.css # shared game page layout js/ main.js # homepage: starfield, cards, secret game lib/ engine.js # GameEngine base class audio.js # Web Audio synthesizer particles.js # object-pooled particle system input.js # keyboard/touch input manager collision.js # AABB + circle collision sprites.js # procedural sprite drawing screen-effects.js # shake, flash, slow-mo, freeze transitions.js # scene transition animations achievements.js # cross-game achievement tracking utils.js # math helpers, color utils games/ pac-chase/ # each game is self-contained space-defenders/ asteroid-blaster/ ... # 16 games total (15 visible + 1 secret)
16 Games
~20k Lines of Code
10 Shared Modules
0 Dependencies

The Shared Engine

Every game extends a single GameEngine base class. This eliminates roughly 100 lines of boilerplate per game -- canvas setup, input handling, the game loop, pause menus, high score persistence, responsive scaling, and the state machine.

State Machine

Each game moves through a strict set of states: initmenuplayingpausedgameover (plus transition for scene changes). The engine handles all state transitions and calls lifecycle hooks on the subclass:

const STATES = ['init', 'menu', 'playing', 'paused', 'gameover', 'transition'];

export default class GameEngine {
  // Override these in your game:
  init() {}                // called once after construction
  update(dt) {}            // game logic (playing state only)
  render(ctx) {}           // draw game objects
  onEnterState(state) {}   // state transition in
  onExitState(state) {}    // state transition out

  setState(newState) {
    if (!STATES.includes(newState) || newState === this._state) return;
    this.onExitState(this._state);
    this._state = newState;
    this.onEnterState(newState);
  }
}

Delta-Time Loop with Spiral-of-Death Prevention

The loop tracks real elapsed time and converts it to seconds. If the browser tab goes backgrounded or the frame budget is exceeded, delta time can spike. Unclamped, this causes objects to teleport through walls -- the "spiral of death." The fix is one line:

_loop(now) {
  let dt = (now - this._lastTime) / 1000;
  this._lastTime = now;
  if (dt > this._maxDT) dt = this._maxDT;  // clamp to 1/30s -- prevents spiral of death
  if (dt <= 0) return;

  const timeScale = this.effects.getTimeScale();  // slow-mo support
  const sdt = dt * timeScale;
  // ...
}

Canvas Scaling

Games are authored at a fixed logical resolution (typically 800x600) but CSS-scaled to fill the viewport. The engine tracks the scale factor so mouse/touch coordinates map correctly. This means every game looks sharp on a phone and a 4K monitor without any game code needing to know the physical pixel size.

Sound Without Files

There are zero audio files in this project. Every sound effect -- coin pickups, explosions, lasers, power-ups, menu blips -- is synthesized in real time using the Web Audio API.

How It Works

Each sound is a "preset" function that creates a short chain of oscillators, gain nodes, and filters, schedules their envelopes, and lets them auto-disconnect when done. A coin pickup is two quick frequency ramps on a square wave. An explosion is white noise through a lowpass filter with a fast decay.

// Simplified from audio.js -- a coin sound in ~15 lines
coin(ctx, dest, t, vol) {
  const g = ctx.createGain();
  const osc = ctx.createOscillator();
  osc.type = 'square';
  osc.frequency.setValueAtTime(987, t);
  osc.frequency.setValueAtTime(1318, t + 0.08);  // B5 -> E6
  g.gain.setValueAtTime(vol, t);
  g.gain.linearRampToValueAtTime(0, t + 0.18);
  osc.connect(g).connect(dest);
  osc.start(t);
  osc.stop(t + 0.2);
}

Why Synthesize Everything?

Particles & Juice

"Juice" is the game design term for the gap between a game that works and a game that feels good. It is screen shake when you get hit. It is the particle burst when an enemy explodes. It is the 50ms slow-motion freeze on a big impact. None of it is gameplay -- all of it is feel.

Object Pooling

Spawning and garbage-collecting hundreds of particle objects per second causes GC pauses -- visible as micro-stutters. The particle system pre-allocates a pool of 500 particle objects at startup and recycles them:

class Particle {
  constructor() {
    this.alive = false;   // dead particles stay in the pool
    this.x = 0;  this.y = 0;
    this.vx = 0; this.vy = 0;
    this.life = 0;
    // ... size, color, rotation, gravity, friction
  }
  reset(x, y, config) {
    this.alive = true;    // recycled, not constructed
    // ... apply config
  }
}

class ParticleSystem {
  constructor(maxParticles = 500) {
    this._pool = new Array(maxParticles);
    for (let i = 0; i < maxParticles; i++)
      this._pool[i] = new Particle();  // allocated once, reused forever
  }
}

Screen Shake, Flash, Slow-Mo

The ScreenEffects module provides composable effects that stack on top of any game without touching game code:

None of these modify game state directly. They operate on the rendering pipeline and the time scale. That separation is what makes them safe to layer on without introducing bugs.

The Secret Game

Spoiler Warning -- This section describes the hidden 16th game slot

The 4x4 grid has 15 visible games and one card that reads "OUT OF ORDER." It is not decorative. There are three independent paths to unlock the 16th game:

The Unlock Sequence

When triggered, the card runs a 20-frame glitch animation -- random translate, skew, hue-rotate, and brightness -- before revealing the hidden game. A brief "???" text appears before resolving to the real title. The power-up sound plays.

Persistence

Once unlocked, localStorage.setItem('galaxy-arcade-secret-unlocked', 'true') persists the state. On subsequent visits, the card is revealed immediately without the animation. Clear localStorage to re-lock it.

Narrative Tie-In

The "OUT OF ORDER" card is the Cursed Cabinet -- the one machine in Sal's arcade that never quite worked right. Flickering screen, strange noises, quarters it swallowed without giving a game. Turns out it was just waiting for someone persistent enough to wake it up.

CSS-Only CRT Effects

The CRT effect -- scanlines, vignette, curvature, phosphor glow, chromatic aberration, flicker -- is pure CSS. No WebGL. No fragment shaders. No JavaScript at all.

Scanlines

A ::before pseudo-element with repeating-linear-gradient draws alternating transparent and semi-transparent black stripes. Tunable via CSS custom properties:

.crt::before {
  content: "";
  position: absolute;
  inset: 0;
  pointer-events: none;
  background: repeating-linear-gradient(
    to bottom,
    transparent 0px,
    transparent var(--crt-scanline-width),
    rgba(0,0,0, var(--crt-scanline-opacity)) var(--crt-scanline-width),
    rgba(0,0,0, var(--crt-scanline-opacity)) calc(var(--crt-scanline-width) * 2)
  );
}

Vignette

A ::after pseudo-element with radial-gradient darkens the edges. Transparent at center, fading to black at the perimeter. Combined with border-radius on the container, it produces the curved-glass look.

Performance

Toggle

Adding .crt-off to the container disables every effect cleanly. No game code needs to change. The effects exist in a completely separate layer from the game rendering.

What We Learned

< Back to Arcade