Architecting BugSmasher: A React + Canvas Game Engine Without External Libraries
How we built a high-performance React game engine with custom canvas state management and 60fps rendering without external game libraries. Verification lives in the repo STATUS.md ledger — not as a frozen “678 passing” headline.
The Challenge: React vs. Game Loops
React is fundamentally built for declarative UI rendering, state diffing, and component trees. Game loops require deterministic requestAnimationFrame ticks, mutable transform matrices, and low-latency physics calculations. Reconciling these two paradigms usually results in either heavy abstraction layers or janky frame drops.
When we set out to build BugSmasher under Hope Theory, the constraint was absolute: deliver a buttery 60fps arcade experience inside a modern web app with a serious verification baseline and robust backend persistence — without claiming coverage we had not certified.
Architecture & State Management
Instead of relying on heavy third-party game frameworks like Phaser or Pixi.js which bloat bundle sizes and complicate React state synchronization, we architected a bespoke rendering pipeline:
- Core Game Loop: Implemented via a custom
useGameLoophook leveragingrequestAnimationFramewith delta-time normalization to prevent speed variations on high-refresh-rate displays. - State Separation: UI overlays (scoreboards, health bars, combo multipliers) live in optimized React state, while high-frequency sprite coordinates and collision boxes live in a typed Float32Array memory buffer.
- Zero Runtime Bloat: Total bundle size under 120KB gzipped, loading instantly even on 3G connections in Dhaka.
// Simplified Game Loop Architecture
class GameEngine {
constructor(canvas, config) {
this.canvas = canvas;
this.ctx = canvas.getContext('2d');
this.entities = new Map();
this.lastTime = performance.now();
}
tick = (currentTime) => {
const dt = (currentTime - this.lastTime) / 1000;
this.lastTime = currentTime;
this.update(dt);
this.render();
this.animationId = requestAnimationFrame(this.tick);
}
}
Testing Strategy: Tracked in STATUS.md, Not Certified
Quality in game development is notoriously difficult to guarantee without manual QA. We treat game mechanics as pure deterministic functions that can be tested in headless Node environments. Current pass/fail counts are the repo ledger’s job — see docs/STATUS.md.
"If your game logic is tightly coupled to DOM elements or requestAnimationFrame, your tests will be brittle. Decouple physics from rendering, and you can test 10,000 game ticks in under 2 seconds."
The current documented baseline (2026-08-30, main @ bbc7250) is green across the board: 752/752 frontend tests (40 files), 81.70% line coverage with enforced floors, Firebase emulator 26/26, Playwright 5/5, production build passing, and zero production-dependency vulnerabilities. Certification remains NOT CERTIFIED 10/10 — the remaining gaps are ESLint warnings (910 → 0), accessibility evidence, and operations, all tracked in the repo ledger. Older “651 tests / failing emulator / Playwright skipped” figures from the 2026-08-26 snapshot are superseded.
Key Takeaways & Lessons Learned
- Immutability has a cost: In high-frequency render loops, avoid deep object spreads. Pre-allocate arrays and reuse object pools for particles and entities.
- Test-driven game dev works: Writing collision and scoring rules in Vitest before touching canvas rendering saved countless hours of debugging.
- Performance budgets matter: Monitoring frame budget (16.6ms per frame) ensured zero layout thrashing.