← All posts

Quadtrees in HTML5 Canvas: Smooth 60 FPS Collision Optimization

Learn how to use quadtrees to power up HTML5 canvas browser games and handle 10,000 entities at a silky-smooth 60 FPS without melting your CPU.

If you have ever tried throwing ten thousand moving sprites into an HTML5 Canvas browser game without a safety net, you already know the deafening sound of a laptop fan spooling up like a Boeing 747 preparing for transatlantic flight. Your gorgeous indie bullet-hell masterpiece instantly degrades into a cinematic slideshow running at a painful four frames per second.

Why? Because brute-force collision detection is a math monster. Checking every single entity against every other entity means your JavaScript loop has to execute an $O(N^2)$ operation. With 10,000 entities, that is a cool 100,000,000 checks every single frame. Spoiler alert: the single-threaded JavaScript call stack simply cannot cope, no matter how much clean-looking code you wrote.

Enter the Quadtree: the absolute holy grail of spatial partitioning for 2D browser games. Let's break down how modern indie web developers use this recursive data structure to maintain buttery-smooth 60 FPS performance when the screen is absolute chaos.


What is a Quadtree? (Entity Definition)

Entity Definition: A Quadtree is a hierarchical tree data structure in computer science specifically designed to partition a two-dimensional space by recursively subdividing it into four quadrants or regions. In HTML5 browser game development, it acts as a spatial filter, drastically reducing the number of collision checks required each frame by ignoring objects that are nowhere near each other.

Instead of checking every sprite against the entire game world, a quadtree neatly organizes your entities based on their exact $(x, y)$ coordinates on the canvas.

The Core Mechanics

1. The Boundary: The tree starts with a root node covering your entire canvas dimensions (say, 800x600 or full-screen).

2. The Capacity Limit: Nodes have a strict maximum capacity—usually around 4 to 8 entities.

3. Subdivision: The moment an inserted entity pushes a node past its capacity limit, that node splits cleanly into four smaller child quadrants (North-West, North-East, South-West, South-East).

4. Insertion: Existing and new entities filter down into the smallest bounding box that completely encloses them.


The Brutal Math: $O(N^2)$ vs $O(N \log N)$

Let's look at why your current collision loop is melting user devices.


Brute Force Collision Check:
For entity A in Entities:
    For entity B in Entities:
        CheckIntersection(A, B) // O(N^2) disaster

When you implement a proper spatial partitioning tree, you completely bypass checking distant entities. You only test objects that occupy the same leaf node or immediate neighborhood.

MetricBrute Force ($O(N^2)$)Quadtree Optimized ($O(N \log N)$)
100 Entities~10,000 checks / frame~700 checks / frame
1,000 Entities~1,000,000 checks / frame~10,000 checks / frame
10,000 Entities~100,000,000 checks (Instant Lag)~140,000 checks (Smooth 60 FPS)

Implementing a Basic Quadtree in JavaScript

You don’t need an external physics engine to get started. Here is a clean, dependency-free implementation tailored for HTML5 canvas games, reflecting current developer consensus found across community GitHub gists and web tech channels.


class Rectangle {
    constructor(x, y, w, h) {
        this.x = x; // Top-left or center depending on preference
        this.y = y;
        this.w = w;
        this.h = h;
    }

    contains(point) {
        return (point.x >= this.x && 
                point.x < this.x + this.w && 
                point.y >= this.y && 
                point.y < this.y + this.h);
    }

    intersects(range) {
        return !(range.x > this.x + this.w ||
                 range.x + range.w < this.x ||
                 range.y > this.y + this.h ||
                 range.y + range.h < this.y);
    }
}

class QuadTree {
    constructor(boundary, capacity) {
        this.boundary = boundary; // Rectangle instance
        this.capacity = capacity; // Max entities per node
        this.points = [];
        this.divided = false;
    }

    subdivide() {
        let x = this.boundary.x;
        let y = this.boundary.y;
        let w = this.boundary.w / 2;
        let h = this.boundary.h / 2;

        this.nw = new QuadTree(new Rectangle(x, y, w, h), this.capacity);
        this.ne = new QuadTree(new Rectangle(x + w, y, w, h), this.capacity);
        this.sw = new QuadTree(new Rectangle(x, y + h, w, h), this.capacity);
        this.se = new QuadTree(new Rectangle(x + w, y + h, w, h), this.capacity);
        
        this.divided = true;
    }

    insert(point) {
        if (!this.boundary.contains(point)) {
            return false;
        }

        if (this.points.length < this.capacity) {
            this.points.push(point);
            return true;
        }

        if (!this.divided) {
            this.subdivide();
        }

        if (this.nw.insert(point)) return true;
        if (this.ne.insert(point)) return true;
        if (this.sw.insert(point)) return true;
        if (this.se.insert(point)) return true;

        return false;
    }

    query(range, found = []) {
        if (!this.boundary.intersects(range)) {
            return found;
        }

        for (let p of this.points) {
            if (range.contains(p)) {
                found.push(p);
            }
        }

        if (this.divided) {
            this.nw.query(range, found);
            this.ne.query(range, found);
            this.sw.query(range, found);
            this.se.query(range, found);
        }

        return found;
    }
}

Community Insights & Optimization Pitfalls

Social media game dev channels and tech subreddits frequently debate the performance overhead of tree rebuilding. Here is what you need to keep in mind before dropping this code into production:

  • Garbage Collection Jitters: Instantiating new Rectangle and QuadTree objects every single frame creates massive memory churn. This triggers the V8 garbage collector, causing random micro-stutters. The Fix: Clear and reuse existing node instances, or pool your memory allocations.
  • Rebuild vs. Update: Trees are notoriously bad at handling fast-moving objects if you try to update their positions in-place. Because entities cross boundaries constantly, the standard industry practice is to completely wipe and rebuild the quadtree from scratch every single frame before running your collision queries. Modern JS engines handle array recreation surprisingly fast for up to 10,000 simple objects.
  • Bounding Box vs. Exact Circle Collision: Use the quadtree to narrow down potential candidates using coarse bounding boxes, then run your precise pixel-perfect or circle-to-circle distance formula (Math.hypot) only on the returned subset.

Key Takeaways for Web Game Devs

  • Brute force fails scale: Once your browser game scales past a few hundred moving entities, spatial partitioning becomes mandatory.
  • Rebuild every tick: Don't fiddle with complex node-removal logic; clear the root and re-insert all entity coordinates every frame.
  • Combine with broad/narrow phases: Use the quadtree as your broad-phase filter to feed streamlined candidates into your narrow-phase collision math.

Thanks for reading. Browse more from the Wobblox blog, or jump straight into all 100 free games.