Developer Commentary
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.
git push to deploy.
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.
Each game moves through a strict set of states:
init → menu → playing
→ paused → gameover
(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); } }
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;
// ...
}
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.
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.
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); }
"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.
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 } }
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 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:
↑ ↑ ↓ ↓ ← → ← → B A
-- the classic. Listened for globally via keydown.
AchievementManager.getGamesWithMinScore(5000) on load.
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.
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.
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.
The CRT effect -- scanlines, vignette, curvature, phosphor glow, chromatic aberration, flicker -- is pure CSS. No WebGL. No fragment shaders. No JavaScript at all.
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) ); }
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.
pointer-events: none -- they never intercept clicks.filter properties (blur, brightness, contrast). Those are expensive when applied over animated canvas content.box-shadow offsets instead of filters.will-change: opacity to promote to their own compositing layer.
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.
requestAnimationFrame,
the Web Audio API, Canvas 2D -- modern vanilla JS is a capable
platform. 16 games and 20,000 lines with no framework, no
transpilation, and no polyfills.
localStorage. No accounts, no databases, no API
calls. For a client-side project, it is exactly the right tool.