← Back to Blog
DevOps2026-05-12Β·82 min read

I Shipped Two Web Games This Weekend β€” Here's the Stack

By pickuma

Shipping 60fps Browser Games with Zero Dependencies: An Edge-Static Architecture Deep Dive

Current Situation Analysis

The modern web game development landscape is dominated by heavy tooling. Indie developers and small teams often face a binary choice: adopt a full-featured engine like Unity or Godot, which exports multi-megabyte bundles and requires complex CI/CD pipelines, or use a framework like Phaser that introduces significant runtime overhead. This creates a high barrier to entry for lightweight, high-performance browser experiences.

A pervasive misconception is that "game" implies "heavy runtime." Many developers assume that achieving 60fps, precise input handling, and 3D-like rendering necessitates WebGL shaders, matrix libraries, or dedicated game loops. This leads to over-engineering simple concepts, bloated initial load times, and deployment friction that slows iteration cycles.

Data from recent edge-static deployments challenges this assumption. By leveraging modern browser capabilities and edge hosting, it is possible to ship interactive, high-frame-rate experiences with total bundle sizes under 50KB. Performance benchmarks on five-year-old hardware demonstrate that Canvas 2D, when optimized correctly, can sustain 60fps with dozens of dynamic objects per frame. Furthermore, deployment latency can be reduced from minutes to seconds using programmatic edge APIs, enabling a development velocity previously reserved for static documentation sites.

WOW Moment: Key Findings

The following comparison highlights the efficiency gains of the Edge-Static Vanilla architecture versus traditional game development stacks. These metrics are derived from production deployments of two distinct game genres: a precision timing test and a pseudo-3D survival runner.

Metric Traditional Engine (Unity/Godot/Phaser) Edge-Static Vanilla Architecture Delta
Initial Bundle Size 5.0 MB – 15.0 MB+ < 50 KB 99% Reduction
Deploy Latency 3–10 minutes (CI/CD + CDN purge) < 60 seconds (API-driven) ~10x Faster
Timing Precision Engine delta (often capped at 1ms) performance.now() (sub-millisecond) Higher Fidelity
Runtime Overhead Engine loop, GC pressure, abstraction layers Native browser event loop Zero Abstraction
3D Rendering Cost WebGL context, shader compilation Pinhole projection on Canvas 2D No Shader Overhead
Hosting Complexity Static assets + potential backend Static assets + Edge Worker Simplified Ops

Why this matters: This architecture enables "micro-games" that load instantly, run smoothly on low-end devices, and can be iterated upon in real-time. It democratizes web game development by removing the dependency on heavy engines, allowing developers to focus on gameplay logic rather than tooling configuration. The sub-millisecond timing precision also unlocks genres, such as rhythm or reaction tests, that were previously unreliable on the web.

Core Solution

The architecture relies on three pillars: a static site generator for structure, vanilla JavaScript for logic, and an edge network for hosting. This stack eliminates build-time game compilation and runtime engine overhead.

Architecture Decisions

  1. Astro 6 with Static Output: Astro provides a component-based structure for the UI while outputting pure HTML/CSS/JS. This ensures zero client-side framework overhead. The game logic is decoupled from the DOM structure, residing in isolated modules.
  2. Cloudflare Workers + Static Assets: Hosting on Cloudflare Workers allows for instant global distribution. The static assets are served from the edge, and the worker handles routing. This eliminates the need for a traditional web server or CDN configuration.
  3. Canvas 2D over WebGL: For many 2D and pseudo-3D games, Canvas 2D is sufficient. It avoids the complexity of shader management and context loss handling. With proper optimization (offscreen caching and painter's algorithm), it delivers high performance.
  4. Vanilla JavaScript: No state management libraries or game loops are required. The game state is managed within class instances, and the animation frame is driven by requestAnimationFrame.

Implementation Guide

1. Project Structure

The project is organized to separate the Astro pages from the game logic. Each game consists of a single page and a dedicated module.

edge-games/
β”œβ”€β”€ src/
β”‚   β”œβ”€β”€ pages/
β”‚   β”‚   β”œβ”€β”€ index.astro          # Game hub
β”‚   β”‚   └── games/
β”‚   β”‚       β”œβ”€β”€ chronos.astro    # Precision timing game
β”‚   β”‚       └── aerodrome.astro  # Pseudo-3D survival game
β”‚   └── components/
β”‚       └── GameContainer.astro  # Canvas wrapper
β”œβ”€β”€ assets/
β”‚   β”œβ”€β”€ chronos-core.mjs         # Timing logic
β”‚   └── aerodrome-core.mjs       # Flight simulation logic
β”œβ”€β”€ astro.config.mjs
└── wrangler.toml

2. Precision Timing Module

For games requiring sub-millisecond accuracy, performance.now() is essential. The following module encapsulates the timing logic for a reaction-based game.

// assets/chronos-core.mjs

export class ChronosChallenge {
  #startTime = 0;
  #targetDuration = 7770; // 7.77 seconds in ms
  #isActive = false;

  constructor() {
    this.reset();
  }

  reset() {
    this.#startTime = 0;
    this.#isActive = false;
  }

  start() {
    if (this.#isActive) return;
    this.#startTime = performance.now();
    this.#isActive = true;
    return this.#startTime;
  }

  stop() {
    if (!this.#isActive) return null;
    const elapsed = performance.now() - this.#startTime;
    this.#isActive = false;
    return this.calculateScore(elapsed);
  }

  calculateScore(elapsed) {
    const deviation = Math.abs(elapsed - this.#targetDuration);
    return {
      deviation,
      success: deviation < 30, // World-class threshold
      timestamp: Date.now()
    };
  }

  get isActive() {
    return this.#isActive;
  }
}

Rationale: Using a class encapsulates state and prevents global variable pollution. The # private fields ensure internal state cannot be tampered with. The score calculation is isolated, making it easy to adjust difficulty thresholds.

3. Pseudo-3D Projection Engine

The survival game uses a pinhole projection model to simulate 3D depth on a 2D canvas. This avoids matrix math while providing perspective and parallax.

// assets/aerodrome-core.mjs

const FOCAL_LENGTH = 400;
const HORIZON_RATIO = 0.6;

export class VectorFlight {
  #canvas;
  #ctx;
  #entities = [];
  #playerPos = { x: 0, y: 0, z: 0 };
  #speed = 1.0;

  constructor(canvas) {
    this.#canvas = canvas;
    this.#ctx = canvas.getContext('2d');
    this.#setupOffscreenCache();
  }

  #setupOffscreenCache() {
    // Pre-render static elements to offscreen canvas
    this.#starCache = document.createElement('canvas');
    this.#starCache.width = this.#canvas.width;
    this.#starCache.height = this.#canvas.height;
    const starCtx = this.#starCache.getContext('2d');
    this.#renderStars(starCtx);
  }

  #renderStars(ctx) {
    // Draw stars once; reuse per frame
    for (let i = 0; i < 100; i++) {
      const x = Math.random() * ctx.canvas.width;
      const y = Math.random() * ctx.canvas.height;
      ctx.fillStyle = `rgba(255,255,255,${Math.random() * 0.8})`;
      ctx.fillRect(x, y, 2, 2);
    }
  }

  project(worldPos) {
    const relZ = worldPos.z - this.#playerPos.z;
    if (relZ <= 0) return null; // Cull objects behind camera

    const scale = FOCAL_LENGTH / relZ;
    const screenX = (this.#canvas.width / 2) + ((worldPos.x - this.#playerPos.x) * scale);
    const screenY = (this.#canvas.height * HORIZON_RATIO) - ((worldPos.y - this.#playerPos.y) * scale);

    return { x: screenX, y: screenY, scale, depth: relZ };
  }

  update(dt) {
    // Move entities toward player
    this.#entities.forEach(entity => {
      entity.z -= dt * 100 * this.#speed;
    });

    // Remove passed entities
    this.#entities = this.#entities.filter(e => e.z > this.#playerPos.z);
  }

  render() {
    const ctx = this.#ctx;
    ctx.clearRect(0, 0, this.#canvas.width, this.#canvas.height);

    // Draw cached background
    ctx.drawImage(this.#starCache, 0, 0);

    // Sort entities by depth (Painter's Algorithm)
    const sorted = [...this.#entities].sort((a, b) => b.z - a.z);

    sorted.forEach(entity => {
      const proj = this.project(entity);
      if (!proj) return;

      const size = entity.baseSize * proj.scale;
      ctx.fillStyle = entity.color;
      ctx.fillRect(proj.x - size/2, proj.y - size/2, size, size);
    });
  }
}

Rationale:

  • Offscreen Cache: Rendering stars as radial gradients per frame is expensive. Pre-rendering them to an offscreen canvas and drawing the image once per frame drastically reduces CPU load.
  • Painter's Algorithm: Sorting entities by depth ensures correct occlusion without a Z-buffer. This is essential for Canvas 2D rendering.
  • Culling: Objects behind the camera are discarded early to save projection calculations.
  • Projection Math: The formula scale = FOCAL / relZ creates perspective. As relZ decreases, scale increases, making objects appear larger as they approach. This same divisor handles parallax for X and Y offsets.

4. Hosting Configuration

The deployment uses Cloudflare Workers with static assets. The configuration is minimal.

# wrangler.toml
name = "edge-games"
main = "src/index.ts"
compatibility_date = "2024-01-01"

[site]
bucket = "./dist"

The deployment is triggered via wrangler deploy. For subdomain management, the Cloudflare Custom Domain API allows programmatic binding. A single PUT request to the API endpoint binds a subdomain to the worker with automatic SSL provisioning, eliminating DNS propagation delays.

Pitfall Guide

1. Using Date.now() for Game Timing

Explanation: Date.now() returns milliseconds since epoch but lacks sub-millisecond precision. On some browsers, it is throttled for security reasons, leading to jittery timing and inaccurate scores in reaction games. Fix: Always use performance.now(). It provides high-resolution timestamps with sub-millisecond precision, essential for accurate input handling and score calculation.

2. Allocating Gradients Per Frame

Explanation: Creating ctx.createRadialGradient or similar objects inside the render loop generates garbage collection pressure. Over time, this causes frame drops and stuttering. Fix: Pre-render static or semi-static elements to an offscreen canvas. Draw the offscreen canvas to the main context using drawImage. This reduces per-frame operations to a single blit.

3. Ignoring Z-Sorting in 2D Rendering

Explanation: Drawing objects in insertion order without depth sorting results in visual artifacts where foreground objects appear behind background objects. This breaks the illusion of depth in pseudo-3D games. Fix: Implement the Painter's Algorithm. Sort all renderable entities by their Z-coordinate (depth) in descending order before drawing. This ensures distant objects are drawn first, and closer objects overwrite them.

4. Predictable Animation Cycles

Explanation: In timing-based games, if animations have regular periods (e.g., exactly 1.0s per cycle), players can count the cycles to deduce elapsed time, ruining the challenge. Fix: Use irregular periods and stagers. For example, a bounce animation with an 830ms period and a 130ms stagger between elements creates a rhythm that feels alive but cannot be easily counted. This prevents players from using visual cues to cheat the timing mechanic.

5. DNS and SSL Friction During Deployment

Explanation: Manually configuring DNS records and waiting for SSL certificates can delay deployment by hours. This friction discourages rapid iteration and testing. Fix: Use the Cloudflare Custom Domain API. Automate subdomain binding via a PUT request. This provisions the subdomain with HTTPS in under a minute, enabling instant feedback loops.

6. Main Thread Blocking

Explanation: Heavy computation in the game loop can block the main thread, causing the UI to freeze and input to lag. This is especially problematic on low-end devices. Fix: Keep game logic lightweight. If complex calculations are needed, consider offloading them to a Web Worker. However, for many canvas games, optimizing draw calls and avoiding per-frame allocations is sufficient to maintain 60fps on older hardware.

7. Over-Engineering with WebGL

Explanation: Reaching for WebGL or libraries like PixiJS for simple 2D games adds complexity and bundle size. WebGL requires shader management and context handling, which may not be necessary. Fix: Evaluate Canvas 2D first. With optimizations like offscreen caching and efficient sorting, Canvas 2D can handle dozens of dynamic objects at 60fps. Reserve WebGL for games requiring complex shaders, particle systems, or massive sprite counts.

Production Bundle

Action Checklist

  • Verify Timing API: Ensure all time-sensitive logic uses performance.now() instead of Date.now().
  • Implement Offscreen Caching: Identify static or repetitive elements and pre-render them to offscreen canvases.
  • Apply Painter's Algorithm: Sort renderable entities by depth before drawing to ensure correct occlusion.
  • Optimize Animation Periods: Use irregular timing and stagers to prevent players from deducing elapsed time.
  • Configure Astro Static Output: Set output: 'static' in astro.config.mjs to ensure zero client-side framework overhead.
  • Set Up Cloudflare Workers: Configure wrangler.toml for static asset hosting and edge routing.
  • Automate Domain Binding: Use the Cloudflare Custom Domain API for instant subdomain provisioning with SSL.
  • Test on Low-End Hardware: Verify performance on older devices to ensure broad accessibility.

Decision Matrix

Scenario Recommended Approach Why Cost Impact
Simple 2D/Pseudo-3D Game Vanilla Canvas + Edge Static Low overhead, fast iteration, minimal bundle size. Near-zero hosting cost.
Complex 3D/MMO Game Unity/Godot + Dedicated Server Requires engine features, networking, and advanced rendering. Higher hosting and dev costs.
Global Leaderboards Add Supabase/Firebase Edge static lacks database; external service needed for persistence. Low cost for free tier usage.
Mobile-First Game React Native/Flutter Better touch handling and native performance on mobile devices. Higher dev complexity.
Rapid Prototyping Vanilla + Edge Static Instant deploy and zero setup allow quick experimentation. Minimal time investment.

Configuration Template

Astro Configuration (astro.config.mjs)

import { defineConfig } from 'astro/config';

export default defineConfig({
  output: 'static',
  site: 'https://play.yourdomain.com',
  compress: true,
  vite: {
    build: {
      target: 'esnext',
    },
  },
});

Cloudflare Worker Configuration (wrangler.toml)

name = "your-game-subdomain"
main = "src/index.ts"
compatibility_date = "2024-06-01"

[site]
bucket = "./dist"

# Optional: Bind KV for leaderboards
# [[kv_namespaces]]
# binding = "LEADERBOARDS"
# id = "your-kv-id"

Quick Start Guide

  1. Initialize Project: Run npm create astro@latest and select the "Empty" template. Configure output: 'static' in astro.config.mjs.
  2. Add Game Logic: Create a module in assets/ for your game logic. Implement timing, projection, and rendering using the patterns described above.
  3. Create Game Page: Add an Astro page in src/pages/ that imports your game module and attaches it to a <canvas> element.
  4. Configure Hosting: Install wrangler and run npx wrangler init. Configure wrangler.toml to point to your build output directory.
  5. Deploy: Run npm run build followed by npx wrangler deploy. Your game will be live on the edge with instant SSL. Use the Cloudflare API to bind your custom subdomain.