I Shipped Two Web Games This Weekend β Here's the Stack
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
- 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.
- 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.
- 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.
- 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 / relZcreates perspective. AsrelZdecreases,scaleincreases, 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 ofDate.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'inastro.config.mjsto ensure zero client-side framework overhead. - Set Up Cloudflare Workers: Configure
wrangler.tomlfor 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
- Initialize Project: Run
npm create astro@latestand select the "Empty" template. Configureoutput: 'static'inastro.config.mjs. - Add Game Logic: Create a module in
assets/for your game logic. Implement timing, projection, and rendering using the patterns described above. - Create Game Page: Add an Astro page in
src/pages/that imports your game module and attaches it to a<canvas>element. - Configure Hosting: Install
wranglerand runnpx wrangler init. Configurewrangler.tomlto point to your build output directory. - Deploy: Run
npm run buildfollowed bynpx wrangler deploy. Your game will be live on the edge with instant SSL. Use the Cloudflare API to bind your custom subdomain.
