JavaScript Game Development Using Canvas: Performance Optimization And Benchmarking

An abstract painting featuring vibrant colors including red, blue, yellow, and green, with dynamic brush strokes creating a sense of motion and energy.

Rainboids is an HTML5 canvas browser-game written in JavaScript. It’s an overhead shooter that features mobile support, an original soundtrack, and RPG mechanics for collecting drops from enemies and spending those in an in-game shop for offensive and defensive upgrades. You can check out the source code on github.

The game doesn’t use any bitmap/raster sprites, instead rendering everything with math, paths, and 2D canvas draw calls. Vector icons and unicode emojis are used for simple HUD graphics. Vector icons in particular are stored as raw path information in the game’s source. Text is rendered using Google TTF fonts, CSS effects, and JavaScript for placement. A certain amount of DOM manipulation is used for layering HUD and menu text.

There’s a wide variety of mathematically-generated graphics, the most essential (and visible) being the 3D-rotating wireframe asteroids and the twinkling, layered, background starfield with parallax.

The game’s rendering pipeline performs 3D math to render pseudo-3D asteroids. Specifically, it uses 3D rotation matrices and performs perspective projection of the 3D frustum onto 2D before drawing it on canvas using lines to generate a pseudo-3D wireframe effect. The asteroid is the singlemost complex entity to render.

An asteroid’s vertex definitions are stored in model space, while rotation is handled using 4D linear transformation matrices. The perspective projection maps (or “flattens”) the 3D vertices to 2D screen space before canvas draw calls are used to render a wireframe.

Rephrased more succinctly, the asteroids are stored using 3D matrices, rotated using transformation matrices, then perspective projection is used to normalize the 3D matrix to 2D before finally being rendered on the canvas.

For the background starfield, a combination of procedural noise algorithms are utilized to achieve a more organic distribution of stars. Specifically, Perlin noise and Worley noise are utilized, along with some other placement techniques, to produce an organic and naturalized distribution rather than the uniform distribution of a simple white noise algorithm.

Because of all these computations, each frame involves thousands of arithmetic ops, canvas state changes, and draw calls – and that’s not even considering collision checks and other in-game computations. All of this was engineered in a naïve “functionality first” approach without careful attention to pipelining and and optimization.

Object Pools and Asteroid Rendering

Rainboids utilizes object-based particles. It manages these particle objects using object pools that recycle objects to avoid the overhead of destroying and re-creating them. This same approach is used to pool and manage asteroid objects.

Rendering one asteroid without any optimization issues 180 canvas commands (30 stroke() calls with associated state changes and shader setup) – which can be cleverly optimized to a single stroke() call to drastically reduce the number of draw calls and GPU flushes per frame.

Likewise, many particles and background stars make heavy use of GPU effects like shadowBlur, which trigger expensive offscreen Gaussian blurs. Even a lot of the text in the HUD and menus is rendered using CSS-based shadowBlur which is similarly expensive to render.

Game Slows to a Crawl with only a Few Objects

Prior to implementing optimizations, it was possible to slow the game down to 10-20 FPS using a handful of enemies and asteroids – say, 10 enemies and 10 asteroids. The primary reason for such slowdown was the inefficient use of draw calls made per frame to render enemies and asteroids, on top of which there were numerous shadow blur and glow effects that thrashed the GPU because they weren’t pipelined effectively.

A secondary bottleneck was the sheer number of mathematical operations that needed to be computed per frame prior to rendering the game’s geometry using draw calls.

All these inefficiencies combine to such an extent that the game doesn’t even run smoothly on a desktop. It’s even worse on a mobile device, particularly because all the excessive operations and calls stress the mobile device and drain its battery much faster.

Implementing and testing performance optimizations so the game runs smoothly on desktop and doesn’t drain a mobile battery in 30 minutes is both time-consuming and rewarding. Using Ai (Claude Code specifically), we performed a thorough analysis of the entire codebase and compiled a report filled with various potential optimizations that could be made. For every suggestion, we additionally did some back-of-the-envelope calculations to estimate much faster these optimizations would make the game – either in terms how many milliseconds faster a frame could be rendered, or in terms of the number of operations that would be saved. Implementation of specific optimizations was then prioritized according to what offered the biggest gains with the least changes.

Key optimizations we implemented:

  • reducing the number of canvas draw calls in the game’s main loop by batching operations together and using Path2D caching
    • Path2D caching uses a Path2D object to store draw calls and batch-render, rather than making a bunch of separate draw-calls.
  • adding sprite, text, and icon caching
    • Icons are either emoji or vector paths
  • changing the array used to render a bullet’s trail to a circular ring-buffer to eliminate Array.shift() calls
  • drawing entities off-screen and then blitting
  • performing temporal upscaling to render the game at a lower resolution (30 Hz) then upscale to 60 Hz
  • pooling objects: pre-allocating a certain size pool for managing entities and recycling these objects to avoid jank – pauses from the GC (garbage collector)
  • using a pool index to reduce the asymptotic complexity of releasing objects in the pool and cleaning up the pool – release was reduced to O(1)O(1) and cleanup was reduced from O(n2)O(n^2) to O(n)O(n)
  • batch rendering stars in the background starfield based on depth
  • replacing sqrt-based collisions with squared-distance comparisons
  • reducing the number of distinct linear gradients in each frame by caching three linear gradients for reuse
  • caching CSS color strings for reuse

Nuances of Benchmarking in JavaScript

Whereas unit-tests and end-to-end tests are rather straightforward in how they test functionality, benchmarks must be handled with a whole extra dimension of thought and care because they are dependent on things like warmup (whether a code-block has been compiled by the JavaScript engine to machine code), garbage collection, and CPU operation: whether the CPU is in a “boost” mode and operating at a higher frequency or whether it’s operating at a lower, more power-conservative state, or if thermal throttling is in effect.

JavaScript performance is additionally contingent on low-level factors, such as the JavaScript engine used, the performance of the engine’s interpreter, and the engine’s JIT (just-in-time) compilation performance. Each JavaScript engine additionally makes its own advanced compiler optimizations at the machine-level. Therefore, it is inappropriate compare performance between different engines when benchmarking the performance of specific logic: to eliminate extraneous variables and truly have a sense whether or not a certain optimization has improved a certain block of logic, we want to ensure we’re using the same JavaScript engine in both cases.

Different JavaScript engines will interpret and compile JavaScript at different speeds. JavaScript engines include:

  • Google V8 (Chromium, Edge)
  • Mozilla SpiderMonkey (Firefox)
  • JSC (Apple Webkit’s JavaScript Core)

Each of these engines use JIT (just-in-time) compilation to compile JavaScript down to machine code for the target architecture.

Because of the JIT compilation of JavaScript, the cold execution of a block of code is going to have considerably different performance than when that code is run a second time: the first time, we hit the overhead of interpretation, JIT compilation, and CPU and instruction caching; the second run and onwards gives us a more consistent measurement of the performance of a block of code. If you were to write your own rudimentary benchmarks and run them using Node.js (which uses Google’s V8 engine) you would begin to notice that the timing of the same tests between different runs can vary quite a bit.

Benchmark tests need to be aware of these kinds of nuances which introduce variance between test runs and obfuscate how performant the code truly is – a single run may indicate that performance has regressed, but it could simply be for that run. This makes it difficult for the engineer to heuristically determine whether a certain optimization genuinely improves performance, or whether performance just happened to be faster for a particular benchmark run.

Benchmarking libraries like mitata take care to address all these things by implementing features like “warmup” to interpret, compile and cache the block of code before the actual benchmark timing measurements are done. Mitata is also neat in that it self-determines how many times to re-run a test so that it can assert with statistical confidence that the benchmark timing is meaningful.

In the worst case, a performance optimization has the potential to cause the game to function in an undesirable way, possibly becoming less performant, or causing undesirable behavior – hence it is crucial to regularly run benchmarks after any optimization change to gauge how much faster the optimized version truly is, or whether performance has regressed.

The Need for Speed

The first step in optimization is profiling and identifying hotspots. Using the browser’s performance tools or custom timers reveals the heaviest costs: repeated canvas draw calls, live blurs, unnecessary allocations, and expensive math per-object. Armed with this data, we can attack optimizations systematically.

Batch Drawing & Path Caching

Being completely generative (relying purely on geometry and math for its graphics, as opposed to bitmaps and sprites), Rainboids executes a large number of mathematical computations and draw calls when rendering each frame. Asteroids in particular have the most Canvas API calls per frame of any entity, around 180 per-frame, along with 210 state changes that force GPU updates; this is because they are rendered in wireframe using color gradients and alpha. Reducing the number of Canvas API calls and GPU state changes for every asteroid per frame is therefore a huge win.

A primary technique to accomplish this is draw-call reduction. Canvas APIs like beginPath(), moveTo(), lineTo(), and stroke() incur significant overhead, especially if invoked many times per frame. Instead of drawing each asteroid edge with a separate stroke(), we can accumulate edges into one path. For instance, rather than 30 individual ctx.stroke() calls, we can do:

jsCopyctx.beginPath();
// moveTo/lineTo for all edges of the asteroid
ctx.stroke();

This yields a single GPU path render instead of 30 separate ones – nearly a 30× reduction in GPU flushes. Similarly, if shapes share the same style or alpha, group them. In the rain effect example below, multiple raindrop shapes were all drawn with the same fill color. By combining their geometries into one Path2D and calling ctx.fill() once, the draw is batched.

Use a single Path2D to batch many shapes into one draw call. Grouping shapes into one path amortizes state changes and draw overhead. As shown on GameDev.StackOverflow, “if your drawing uses the same color for all rain drops, you can group them in one path… you will need only one instruction to draw them,” which dramatically saves work. By contrast, setting fill/stroke styles or invoking stroke() dozens of times is much costlier. Web guidelines also note that creating one unified path and issuing one draw call is far more efficient than many short paths.

Path2D caching can help us save even more draw calls. Shapes that don’t change (like a ship sprite or static terrain) can be rendered once into a Path2D or offscreen canvas and reused each frame. The game already does this for HUD icons and text (via PathCache and TextCache). Each cached path avoids re-tessellating geometry or re-laying out text each frame. For example, caching ship outline shapes in a Map by key means the engine can just ctx.stroke(cachedPath) rather than rebuild every move.

Off-Canvas Drawing & Blitting

Web experts emphasize this strategy: “if element style hasn’t changed since last draw, reuse cached path”. Likewise, the MDN Canvas guide advises pre-rendering repeating objects on an offscreen canvas and then blitting them each frame. This turns heavy draw sequences into a single drawImage(), reducing per-frame work drastically.

Path2D caching

Path2D caching refers to the use of the Path2D object to store and reuse complex path drawing commands, which can significantly improve performance when the same shape needs to be drawn multiple times or in different locations.

Key Concepts

  • Command Accumulator: A Path2D object acts as an accumulator for a sequence of drawing commands (like moveTo, lineTo, arc, etc.), much like how you would draw on the canvas context directly.
  • Reusability: Instead of issuing all the individual path commands to the canvas context (ctx) every time a frame is rendered, you define the path once within a Path2D object. This object can then be “replayed” on the canvas using methods like ctx.stroke(path) or ctx.fill(path) whenever needed.
  • Performance Optimization: By creating a path object, you reduce the overhead of repeatedly calling numerous JavaScript API methods. The browser can potentially optimize the internal handling and rendering of the pre-defined path, leading to better performance, especially for complex or frequently drawn shapes.
  • Separation of Path and Context: The Path2D object is separate from the canvas’s default path. This means you can define multiple paths and use them independently without them interfering with the current canvas state.
  • SVG Integration: Path2D objects can also be created from existing SVG path data strings (new Path2D("M10 10 h 80 v 80 h -80 Z")), making it easy to reuse vector graphics definitions. 

How it is Used

  1. Create a Path2D object:
    const p = new Path2D();
    or with SVG
    const svgPath = "M10 10 h 80 v 80 h -80 Z";
    const p = new Path2D(svgPath);
  2. Define the path: Use path methods on the Path2D object itself.
    p.moveTo(10, 10);
    p.lineTo(100, 50);
    p.lineTo(30, 200);
  3. Draw the path on the canvas: When drawing, pass the Path2D object to the canvas context’s drawing methods.
    const canvas = document.getElementById("canvas");
    const ctx = canvas.getContext("2d");
    ctx.strokeStyle = "blue";
    // Reuse the path
    ctx.stroke(p);
    ctx.fillStyle = "red";
    // Reuse the path again
    ctx.fill(p);  

The Big, Bad, Shadow Blur

Shadow blurs and compositing are visually appealing but often the most expensive ops. When ctx.shadowBlur > 0, the canvas pipeline must (1) draw the shape offscreen, (2) apply a Gaussian blur, and (3) composite back. The blur is separable but still O(r2)O(r^2) in the blur radius: for a 10px-radius particle with blur 40px, about 10,000 pixels are blurred, costing on the order of millions of GPU ops per particle. With dozens of particles, that easily adds several milliseconds per frame just for shadows. A StackOverflow answer notes that an on-the-fly blur “has to be recalculated every single pass” and suggests drawing the blurred object once on a hidden canvas instead.

In Rainboids, every particle explosion and many stars use live shadowBlur. The fix is to pre-render glow sprites. At load time, draw the blurred circle into an offscreen canvas (or use the existing IconSpriteCache mechanism), and at runtime simply do ctx.drawImage(sprite, x, y). This converts an O(r²) blur into a single texture blit. Likewise, HUD elements using glow can be precomputed. As one practitioner put it, “performance cost can be reduced by drawing the object with the blur effect to a separate, invisible canvas, and then drawing that canvas”.

Another approach is to fake bloom with additive blending, skipping costly blurs entirely. By drawing several concentric semi-transparent circles (globalCompositeOperation = 'screen' or 'lighter') with decreasing alpha, one can approximate a glow cheaply. This heuristic yields softer highlights at negligible cost compared to real blurs. However, we opted not to implement this as it created filled circles rather than unfilled ones.

We must also minimize canvas state changes. Changing ctx.fillStyle, ctx.globalAlpha, ctx.lineWidth, etc., forces GPU state updates. The analysis found the asteroid draw loop did 210 canvas state changes per frame. Wherever possible, set styles once per batch (or once per frame if constant) instead of in tight loops. For example, hoist ctx.lineWidth = 2 and color settings outside the edge-loop, since they don’t really vary per-edge. This is aligned with guidance that “manipulating the state machine” is expensive; grouping objects by common style yields huge savings.

Finally, we implemented layered canvases. Rainboids already uses multiple stacked canvases for background, game objects, effects, and UI. MDN notes that if parts of a scene are static, drawing them on a separate canvas can save redrawing every frame. In practice, Rainboids could reduce the number of layers (the analysis suggests only two may suffice) to minimize GPU compositing overhead. But layering does help by limiting what needs redrawing each frame: e.g. a static starfield canvas could be left untouched if there were no twinkle or parallax (though Rainboids does animate stars).

Object Pooling and Garbage Collection

To avoid GC spikes, Rainboids uses custom object pools for bullets, particles, stars, enemies, etc. In principle, pooling (recycling old objects) prevents constant new/delete, which is great for smoothness. However, pooling only reduces jank (GC pauses) – it usually doesn’t speed up raw execution. As one JavaScript performance expert explains, “pooling objects is not going to improve speed… object creation is typically very cheap. The purpose of pooling is to cut down on jank (periodic lag) caused by garbage collections”.

That said, pools must be implemented carefully. Rainboids’ PoolManager.release() currently searches for the object with indexOf (O(n)O(n) scan) before swap-and-pop, which is wasteful. By storing each object’s pool index, release can be true O(1)O(1). Even small garbage of dozens of objects per frame can add up; eliminating that hidden cost sharpens performance headroom. Similarly, avoid heavy cleanup loops (e.g. splicing lists in tight loops) – use swap-pop or batching techniques to keep pooling overhead linear at worst.

Pools also incur some management overhead: e.g. if the pool empties, Rainboids logs an eviction or dynamically expands. Monitoring and tuning pool sizes (e.g. max bullets or particles) ensures you don’t thrash memory. In practice, well-tuned pools combined with pre-allocation (no new in hot paths) yield consistent frame times even under heavy particle loads.

Starfield & Noise Generation

Rainboids’ deep background starfield is procedurally generated with Perlin noise, Worley noise, and logarithmic galaxy formulas. This gives a layered, organic look. Crucially, all generation runs once at startup (or level load) – stars’ positions are stored. Per-frame work is limited to rendering and a bit of twinkle. Nonetheless, the starfield renderer does its own batching: DepthBatchRenderer groups stars by alpha bucket and color, then draws each bucket in one path fill. This is similar to the rain example above and drastically cuts draw calls versus per-star fill. If performance were an issue, one could extend batching to more colors or eliminate Array.from().sort() each frame (caching sorted keys), but the core idea (fewer fill calls) is already applied.

On the math side, Rainboids already avoids some expensive ops: the asteroid’s 3D rotations cache sines/cosines outside loops. Similarly, collision checks and radial falloffs should compare squared distances instead of taking Math.sqrt every time. A classic trick: check 
dx*dx + dy*dy <= r*r 
instead of computing 
sqrt(dx*dx + dy*dy) <= r.

Game developers often note that replacing a sqrt with a simple multiplication/cmp “eliminates that nasty square root”. In tight loops (e.g. per-pixel alpha in ParticleBatch), this can cut execution by a factor of several. Likewise, reuse precomputed tables or linear approximations for sin/cos if angles are regular or if a small error is acceptable, since pure Math.sin/cos calls are relatively slow.

Spatial Partitioning & Collision

Naive collision detection is O(n2)O(n^2): every entity is checked against every other. Rainboids uses a quadtree, but as observed, it’s rebuilt every frame and not actually reducing pairs at current scales. For small n (tens of objects), the quadtree overhead (O(n log n)O(n\ log\ n) to build the quadtree, plus many allocations) can outweigh the naïve loop. In fact, one gamedev pattern book notes that spatial partitions pay off only when n is large; if n is small enough, it “may not be worth the bother”.

Quadtrees

A quadtree is a hierarchical tree data structure used to partition a two-dimensional space by recursively subdividing it into four quadrants or regions. This process continues until each subdivided region (or node) meets a specific criterion, such as containing no more than a maximum number of points or having uniform content (e.g., a single color in an image). 

How It Works

The fundamental principle of a quadtree is recursive decomposition, applying the “divide and conquer” method to 2D space. 

  • Root Node: The process starts with a single node representing the entire 2D area (e.g., a map or an image).
  • Subdivision: If a node contains too many data points (past a set capacity) or its contents are not uniform, it is divided into four equal, smaller child nodes, typically representing the Northwest, Northeast, Southwest, and Southeast quadrants.
  • Leaf Nodes: The subdivision stops when a node (a “leaf” node) meets the defined criteria (e.g., has 1 or fewer points, or is a uniform color).
  • Adaptive Partitioning: This creates an adaptive structure: dense areas have deeper, more subdivided regions, while sparse areas remain as larger quadrants, saving memory and computational resources. 

A simpler approach is often best: a fixed grid or spatial hash. If entities are roughly uniformly spread, a grid is often faster than a quadtree. With Rainboids’ current few asteroids/enemies, a uniform grid (say dividing the screen into an 8×68 \cross 6 cell grid) lets you insert each entity in O(1)O(1) (constant-time computation of cell index) and query collisions by only checking nearby cells. It avoids all dynamic tree recursion. Indeed, Bram observes that even if a grid cell has multiple objects, the “easy wins” of not testing remote objects typically justify it.

If Rainboids grows to many more enemies or projectiles, a grid can be tuned by cell size and dynamic resizing. You could also maintain separate lists for static vs dynamic objects to further reduce checks. The key is: do spatial partition only when needed. The priority is to have a correct baseline first; then if profiling shows collisions sucking up time, switch to grid or optimize the quadtree rebuild. For now, a grid would eliminate all runtime allocations in collision logic and cut constant factors.

A hand-drawn number line from 0 to 16, marked with figures at different intervals (0, 2, 3, 8, 9, 11, 14, 16).

By sorting or bucketing objects along the line, we can reduce search from brute-force O(n2)O(n^2) to O(n log n)O(n\ log\ n) or better. In this 1D analogy, if we sort the units by their x-coordinate, finding neighbors is logarithmic rather than linear per unit.

A grid with coordinates labeled on the axes containing simple stick figure drawings at various points within the grid.

2D uniform-grid partitioning. The battlefield is overlaid with cells, and each unit is placed into its grid cell. Collision tests then only involve units in the same or adjacent cells. This spatial partitioning greatly cuts down on pairwise checks by dividing the world into “mini-battlefields.”

The analysis also includes frustum culling by simply skipping update/draw for entities outside the viewport. Even a simple axis-aligned check (is the object’s bounding box within canvas bounds plus padding?) is constant-time at O(1) per entity. As one tutorial notes, “by using frustum culling, you ensure an object is only updated, drawn and checked if it’s within the frustum… culling has the potential to dramatically improve framerate”.

Rainboids already uses an AABB test with fixed padding. Fine-tuning the padding (e.g. based on object speed) can avoid drawing fast bullets that have actually left the screen.

Frustum Culling

Frustum culling is a performance optimization technique in 3D computer graphics that prevents rendering objects located outside the camera’s viewable area, known as the view frustum. By identifying and removing these unseen objects on the CPU before sending data to the GPU, it reduces unnecessary draw calls and significantly boosts frame rates.

Other Optimizations

  • Avoid Redundant Work: Hoist invariants out of loops. For example, compute Date.now() once per frame (not per edge) and reuse it to animate colors; set ctx.lineWidth=2 just once. The performance analysis found 30 redundant Date.now() calls per asteroid per frame, all returning essentially the same value – a pure waste.
  • Minimize String Work: Converting colors or styles (e.g. template-literals for HSL) each frame adds GC pressure. Using cached CSS color strings or ctx.strokeStyle = someConstantColor is slightly faster.
  • Canvas Clear Regions: Only clear what’s necessary. The MDN guide suggests limiting redraw areas (“dirty rectangles”). For Rainboids, every frame we clear entire canvases; if only small parts change, partial clears could help. (Though with moving stars and UI, full clears may still be simplest.)
  • Typed Arrays for Particles: Rainboids has a disabled TypedArrayParticleSystem which stores particle data in linear buffers rather than JS objects. In hot math loops, this can yield cache-friendly updates. Re-enabling it and tuning the bundling could speed up large particle showers.
  • Profiling Assisted: Keep the Developer Tools open. Chrome’s profiler and paint/fps meters can highlight any overlooked hotspots (e.g. style recalculations from DOM text if the HUD is DOM-based, layout issues, etc).

Benchmarking Strategy

For benchmarking, we turn to mitata, a fast, feature-rich, and eloquent benchmarking framework which features adaptive iterations and adjusts the number of runs for any given benchmark based on statistical analysis of the execution times.

QA Testing with Playwright: Alongside micro-benchmarks, we use automated tests to ensure correctness isn’t broken. Using Playwright, we can run tests against a browser: load the game in a headless (or headful) browser, simulate key presses/mouse, and check outcomes. For example, a test might spawn the player ship, fire a bullet at a known enemy, and assert that the enemy’s health decreases correctly. Visual tests can capture a screenshot of the canvas after a known scenario and compare it to a golden image (e.g. asteroid drawn in correct color). These tests run after every optimization commit to ensure we don’t trade performance for bugs.

Headful Benchmarks (FPS Stress): We also run realistic stress tests. For instance, we programmatically spawn a large wave of enemies or bullets and measure the frame rate in a real browser. Playwright can capture the window.performance FPS or we inject a small FPS counter in the page. These “headful” tests (non-headless Chrome) reveal the actual rendered performance. We vary screen size (desktop vs mobile resolutions), toggle disabled features (like workers or upsampling), and ensure even under extreme load (hundreds of entities) the game stays above our target FPS (e.g. ≥60 on desktop, ≥30 on mobile). Bottlenecks found here (e.g. new frame drops) feed back into the micro-benchmark cycle.

Prioritization and Results

After careful analysis of the codebase, we came up with this prioritized list of optimizations:

  1. Batch Asteroid Edges (High Impact, Low Risk). Combine 30 stroke calls into a single one. This alone shaves ~0.5ms/frame for the asteroid draw path and eliminates dozens of redundant state changes (hues, alpha, etc). Easy to implement and yields immediate savings.
  2. Eliminate Live shadowBlur (Very High Impact). Pre-render glows. This can save several milliseconds per frame, especially critical on mobile GPUs. Millions of pixel-blur ops become cheap texture blits. According to our profiling, this change converts ~3–5ms mobile load into ~0.1ms – arguably the single biggest win.
  3. Fix PoolManager to O(1). Remove the hidden linear scan in release(). This doesn’t save much now, but it prevents a quadratic blowup if entity counts rise, and eliminates moderate GC churn.
  4. ParticleBatch Improvements. Switch the clear loop to TypedArray.fill(0) and use squared-distance tests. These fixes are trivial and speed up the high-particle path by ~2–3ms (when fully wired in).
  5. Gradient Caching (Minor). Only rebuild linear gradients when health changes. Small savings (~5µs/frame) but easy.
  6. Spatial Grid instead of Quadtree. Once logic is verified, switch to a fixed grid if collision cost grows. For now this yields only a tiny gain at ~10 entities, but future-proofs the system.
  7. Enable Temporal Upsampling. If we trust interpolation, running game logic at 30Hz but rendering at 60Hz halves update cost (2–3ms gain). Needs careful testing for input latency artifacts.
  8. Typed Array Particle System (Medium). Re-enable the sofa-of-arrays particle engine. This reduces GC and can leverage vectorized math, improving very large explosion performance.
  9. Web Workers (Experimental Idea). If entity counts really explode, offloading physics or spawn logic to workers could free some CPU. Realistically, workers help very little at the current scale but add a decent amount of complexity and serialization overhead for sending messages between the worker threads and the main thread.

Applying just the first few fixes (batching and blur elimination) yields a 30–50% reduction in the frame cost on mid-range hardware — a huge win. On mobile the effect is even more pronounced. These gains come with minimal visual change, so they’re essentially “free” in user experience. Continuous performance testing ensures we catch regressions early and always know exactly how much time each change saves.

Optimizing Rainboids has been a layered effort: harnessing object pooling and caches for steady-state performance, then digging into micro-ops (math, draw calls) for incremental gains, and finally validating performance gains using benchmarks. The result is an engine that can support many enemies, particles, and effects without dropping frames – a smooth, responsive game even under fire.

Web Workers Aren’t Worth It

Analysis: Web Workers for physics has too much overhead from sending messages between threads (messages which must be serialized and deserialized) compared to simply computing physics in the main thread.

We implemented EnhancedPerformanceManager, WorkerManager, and WorkerGameIntegration to handle physics computations using Web Workers, but these classes did not offer any performance gains. In fact, the code for Web Workers increasers the computational overhead owing to the serialization and deserialization needed to send messages between the main thread and worker threads.

In other words, Web Workers would degrade performance and make things worse because serialization overhead dominates the workload. Every frame, the main thread would need to:

  • Serialize all entity positions/velocities into plain objects (updatePhysicsEntities, updateCollisionEntities)
  • postMessage() structured-clones those objects to the worker
  • Worker computes (trivial math: add velocity, apply friction, clamp speed)
  • Worker postMessage() results back
  • Main thread deserializes and applies updates to entities. For ~100 entities, the actual physics math takes <0.1ms. In contrast, the postMessage serialization/deserialization round-trip takes 0.5-2ms. The overhead of using Web Workers for physics is around 5-20× the cost of computing physics in the main thread.

Async timing breaks the game loop. The game needs collision results synchronously within the same frame to resolve damage, destroy entities, and spawn particles. Worker responses arrive asynchronously (next frame at earliest). The WorkerGameIntegration.handleCollisions() calls
this.gameEngine.resolveCollision() but by the time the response arrives, entities have already moved. This causes:

  • Missed collisions (bullets pass through asteroids)
  • Double-hit detection (same collision reported across frames)
  • Visual desync (entity destroyed but still rendered for 1-2 frames)

A more detailed analysis of the different types of workers (physics and collision) reveals how much more overhead Web Workers would add compared to doing the computations in the main thread.

  1. The physics is too simple to benefit. The physics worker does:
    vel += accel;
    vel *= friction;
    pos += vel;
    clamp(speed)
    That’s ~10 arithmetic operations per entity. With 200 entities, that’s 2000 operations and microseconds of work. Workers only help when computation exceeds ~5 ms, the overhead threshold. And yes: that’s milliseconds, which is a whole three orders of magnitude more than the microseconds it takes to actually perform the physics calculations for all the entities.
  2. The collision worker duplicates what the main thread already does better. The collision worker uses a Quadtree rebuilt from scratch every frame with splice() which has O(n log n)O(n\ log\ n) runtime complexity with GC pressure. The main thread already has a SpatialGrid with O(1)O(1) insert, O(k)O(k) query, and zero allocation. In comparison, the worker version is strictly worse.
  3. The Particle worker has a fatal flaw at this line:
    self.postMessage(
    {imageData},
    [imageData.data.buffer]
    )
    This code transfers the ArrayBuffer, then immediately allocates a new Uint8ClampedArray (8MB at 1920×1080) every frame. This is worse than the GC pressure it’s trying to avoid.

Based on all this, it becomes clear that Web Workers don’t offer any performance gains, but would degrade performance. On top of that, the Web Worker classes add 570 lines of code across four files.

While using Web Workers and multiple threads was a novel idea, they have no benefit for this kind of workload.

Benchmarking

A suite of benchmarks was developed using mitata. These tests are able to compare the performance between two branches (or two commits, or a specific branch and a specific commit), to help gauge whether certain performance optimizations truly helped, or if the optimizations caused a regression (decrease) in performance. For example, comparing the performance of the current branch against master can be done by using npm or node like so:

// compare performance of current branch against master
npm run bench:compare master
// alternatively, run the script directly
node /bench/compare.js master

❯ Would it be beneficial to calculate normal distributions & full statistical inference for benchmark results?

Current approach: The ±σ\pm σ column shows between-run standard deviation of the mean, and the ~ marker flags when |Δ|<\abs{\Delta} <combined σ\sigma.

This is a cheap, legible heuristic that tells you “measurement is noisy, don’t trust this number.” It works well for a micro-benchmark tool used interactively.

What Normal-Distribution Inference Could Add

We could compute a 95%95\% confidence interval CICI using a t-distribution:

CI=mean±t(0.975,n−1)×σ/nCI = mean \pm t(0.975, n-1) \times \sigma / \sqrt{n}

or

CI=x‾±t(0.975,n−1)×σnCI = \bar{x} \pm t(0.975, n-1) \times \frac{\sigma}{\sqrt{n}}

Where x̄ represents the sample mean.

Then we could replace the ∼\sim heuristic with a proper two-sample t-test like Welch’s t-test, since the two sides may have different variances.

Welch’s t-test is a robust, two-sample parametric test used to compare the means of two independent groups, particularly when they have unequal variances or sample sizes.

Key Aspects of Welch’s t-test

  • When to Use: Use when sample sizes and/or variances are not equal between the two groups. It is highly reliable even if variances are equal, unlike the Student’s t-test.
  • Assumptions: Assumes independent samples and that data are sampled from populations following a normal distribution.
  • Formula: X‾\bar{X} are sample means, s2s^2 are variances, and NN are sample sizes.


X1‾−X2‾s12N1+s22N2\frac{\bar{X_1} – \bar{X_2}}{ \sqrt{ \frac{{s_1^2}}{N_1} + \frac{{s_2^2}}{N_2} } }

This would let you say “the difference is statistically significant at p<0.05p < 0.05” rather than just “Δ>\Delta >combined σσ.”

Why Normal Distributions Aren’t Worth It:

  1. The assumptions aren’t met. Micro-benchmark samples aren’t normally distributed; they’re right-skewed, dominated by occasional GC pauses,
    OS interrupts, and cache misses. The t-test assumes normality. For small nn such as --runs 3, this assumption fails badly.
  2. More runs already buy you more than inference. If you want confidence, just use --runs 10 instead of --runs 3. The σ\sigma shrinks, the ∼\sim marker disappears, and you have a real signal, no statistics needed.
  3. p-values don’t map to “is this optimization worth shipping?” A 1%1\% speedup might be statistically significant with 50 runs but practically irrelevant. The current ±2%\pm 2 \% threshold plus the ∼\sim noise flag is a more useful heuristic for game performance work.
  4. Cognitive overhead. The table is already dense. Adding p=0.03p=0.03 or CICI (confidence interval) columns would obscure the numbers engineers actually want: the mean, whether it got faster, and how stable the measurement is.
  5. Bottom line: The current ±σ+∼\pm \sigma + \sim approach is appropriate for this use case. The one thing worth considering is using sample stddev (/(n−1)/(n-1) instead of nn) in mergeBenchResults for better small-sample estimates, but with n≥3n \ge 3 the difference is negligible, and the heuristic interpretation of ∼\sim doesn’t change.

❯ Does it make sense to convert the codebase to TypeScript instead of using JavaScript?

For this project, it’s not worth it. That’s because TypeScript would introduce type-checking, which in turn would lead to more complicated code. We are already making good use of JavaScript’s loose typing to reduce the number of distinct data structures needed to track entities within the game.

  • The game has clear data shapes that would benefit from types: entity objects, wave configs, pool manager interfaces, bullet/enemy stats.
  • TypeScript would catch mistakes like passing the wrong stat object to a function.
  • Vite supports TypeScript out of the box – zero config change needed.
  • The benchmark suite already imports game modules, so type errors would surface earlier. Reasons it’s probably not worth it here:
  • It’s a game, not a library. TypeScript’s biggest payoff is at API boundaries between teams or packages. A solo/small-team game codebase with a single consumer (itself) gets less return.
  • Canvas game code is inherently dynamic. Entity pools, collision dispatch, and wave configs involve a lot of union types and conditional shapes that TypeScript makes more verbose, not less. You’d spend real time fighting the type system around things like activeEntities arrays that hold mixed object types.
  • The benchmark suite runs in Node.js with browser-mock.js shims. TypeScript would require either ts-node/tsx or a separate compile step for the benchmarks to work, adding friction to the bench workflow.
  • No team size justification. TypeScript’s collaboration benefits like autocomplete and the ability to refactor safely across many contributors don’t apply at a small scale.

Verdict: If the codebase grows significantly in complexity e.g. more systems and modules and/or more contributors, it might be meaningful to reconsider converting to TypeScript then. But with the current implementation, the effort of conversion to TypeScript – touching every .js file, handling canvas/browser type shims, and fixing the benchmark runner – far outweighs the benefits of strong typing.

Appendices

3D Matrix Rotation

3D vertex matrices represent points (x,y,z)(x, y, z) as column vectors, often expanded to 4×14 \cross 1 vectors (x,y,z,1)(x,y,z,1) using homogeneous coordinates, allowing translation, rotation, and scaling within a single 4×44 \cross 4 transformation matrix. Linear rotation transforms these vertices by multiplying them with rotation matrices (Rx,Ry,Rz)(R_x, R_y, R_z) which map old basis vectors to new orientations around axes.

The general form of a 4×44 \cross 4 homogeneous rotation matrix is:

[R11R12R130R21R22R230R31R32R3300001]\begin{bmatrix} R_{11} & R_{12} & R_{13} & 0 \\ R_{21} & R_{22} & R_{23} & 0 \\ R_{31} & R_{32} & R_{33} & 0 \\ 0 & 0 & 0 & 1 \end{bmatrix}

  • Top-Left 3×3 (R)3 \cross 3\ (R): This submatrix contains the pure rotation components (e.g., rotation around X, Y, or Z axes)
  • Right 3×13 \cross 1 Column: Zeros, indicating no translation is occurring.
  • Bottom Row: [0, 0, 0, 1][0,\ 0,\ 0,\ 1] to maintain the homogeneous coordinate system.

Individual Axis Rotations

Rx(θ)=[10000cos⁡(θ)−sin⁡(θ)00sin⁡(θ)cos⁡(θ)00001]R_x(\theta) = \begin{bmatrix} 1 & 0 & 0 & 0 \\ 0 & \cos(\theta) & -\sin(\theta) & 0 \\ 0 & \sin(\theta) & \cos(\theta) & 0 \\ 0 & 0 & 0 & 1 \end{bmatrix}

Ry(β)=[cos⁡(β)0sin⁡(β)00100−sin⁡(β)0cos⁡(β)00001]R_y(\beta) = \begin{bmatrix} \cos(\beta) & 0 & \sin(\beta) & 0 \\ 0 & 1 & 0 & 0 \\ -\sin(\beta) & 0 & \cos(\beta) & 0 \\ 0 & 0 & 0 & 1 \end{bmatrix}

Rz(γ)=[cos⁡(γ)−sin⁡(γ)00sin⁡(γ)cos⁡(γ)0000100001]R_z(\gamma) = \begin{bmatrix} \cos(\gamma) & -\sin(\gamma) & 0 & 0 \\ \sin(\gamma) & \cos(\gamma) & 0 & 0 \\ 0 & 0 & 1 & 0 \\ 0 & 0 & 0 & 1 \end{bmatrix}

Homogeneous Coordinates

Homogeneous coordinates, or projective coordinates, are a system used in projective geometry and computer graphics to represent nn-dimensional points with n+1n + 1 coordinates, allowing translation, rotation, and scaling to be performed via single matrix multiplications. They add a ww-dimension (x,y,z,w)(x,y,z,w) where w=1w=1 for standard points and w=0w=0 for points at infinity.

  • Definition: A Cartesian point (x,y)(x,y) becomes (x,y,1)(x,y,1) in 2D homogeneous coordinates. Similarly, a 3D point (x,y,z)(x,y,z) becomes (x,y,z,1)(x,y,z,1).
  • Scaling Property: The same point can have multiple representations; (x,y,w)(x,y,w) is the same as (kx,ky,kw)(kx,ky,kw) for any non-zero scalar kk.
  • Conversion: To convert back to Cartesian, divide x,y,zx,y,z by ww.
  • Applications: They are essential in computer graphics for handling perspective projection, rendering, and affine transformations (like translation).
  • Points at Infinity: When w=0w=0, the coordinate represents a point at infinity (a direction), which is crucial for 3D modeling and rendering.

Why Use Homogeneous Coordinates?

  • Unified Transformations: They allow rotation, translation, and scaling to be combined into a single 4×44 \cross 4 matrix through multiplication.
  • Efficiency: They enable 3D translation via matrix multiplication rather than addition.
  • Perspective Projection: Necessary for mapping 3D scenes onto 2D screens.
  • Points at Infinity: They can represent points at infinity, which is useful in computer vision.

This video provides a visual explanation of how 3D rotation matrices work:

3D Vertex Matrices

  • Definition: Vertices are typically represented as homogenous column vectors to facilitate affine transformations. Here is a 4×14 \cross 1 homogeneous column vector containing coordinates in 3D space (x,y,z)(x,y,z):

    [xyz1]\begin{bmatrix} x \\ y \\ z \\ 1 \end{bmatrix}
  • Transformation Matrix (M)(M): a 4×44 \cross 4  matrix is used to combine scaling, rotation, and translation, allowing complex transformations to be applied by multiplying the matrix MM by each vertex vector v: (v′=M⋅v)v:\ (v\prime = M \cdot v)
  • Order of Operations: Transformations are not commutative; the standard order is scaling, then rotation, then translation.

Perlin Noise

Perlin noise is a type of gradient, coherent noise developed by Ken Perlin in the 1980s that generates smooth, natural-looking, pseudo-random patterns. It is widely used in computer graphics and game development for creating procedural textures, terrain heightmaps, clouds, and animations. Unlike white noise, Perlin noise generates connected values that flow, making it ideal for simulating organic, irregular, and natural, phenomena.

Key Aspects of Perlin Noise

  • How it Works: It generates random gradient vectors at lattice points in an nn-dimensional grid and interpolates between them to create a smooth, continuous field of values.
  • Properties: It produces smooth transitions, avoiding the abrupt, jagged changes of pure randomness. The output usually ranges between 0.0 and 1.0 (or -1 to 1, depending on implementation).
  • Octaves & Layers: Multiple layers (octaves) of noise with varying frequencies and amplitudes can be stacked to create detailed textures, such as combining low-frequency, large-scale features with high-frequency, small-scale details.
  • Applications:
    • Terrain Generation: Creating realistic landscapes, mountains, and maps (e.g., in Minecraft).
    • Texturing: Generating natural, patterns like marble, clouds, fire, and water.
    • Animation: Creating smooth, random, motion.
  • Advantages: It is deterministic (the same input coordinates always yield the same noise value) and provides an organic, “hand-drawn” look rather than a digital, “static” look.
  • Limitations: The original algorithm has directional artifacts and, can be computationally expensive compared to simple random, generators, though it is still efficient enough for real-time applications.

Variations & Improvements

  • Simplex Noise: A faster, higher-dimensional alternative to Perlin noise with fewer computational artifacts.
  • Improved Perlin Noise: An updated algorithm with better interpolation and smoother gradients.
  • Billow/Rigid Noise: Variations used to create specific shapes like jagged mountains.

Worley Noise

Worley noise, or cellular noise, is a procedural texture technique developed by Steven Worley in 1996 that generates organic, cell-like patterns by calculating the distance from any point to the nearest randomly placed “feature points”. It is widely used in computer graphics to simulate textures like stone, water, or cells. Worley noise is an extension of the Voronoi diagram that outputs a real value at a given coordinate. The Book of Shaders provides a terrific article on implementing Worley noise complete with diagrams, code, and animations.

The following video illustrates the implementation of Worley noise:

Key Aspects of Worley Noise

  • Generation Method: The algorithm scatters random points (seeds) across a space and calculates the distance from a pixel to these points. The closest distance determines the pixel’s value (usually brightness).
  • Appearance: It produces distinctive, rounded cellular or mosaic patterns.
  • Applications: It is primarily used for procedural generation, creating textures for 3D modeling, and generating natural-looking patterns such as Voronoi diagrams, stone walls, or water caustics.
  • Distance Metrics: While Euclidean distance is common, other metrics like Manhattan distance can be used to create different patterns.
  • Variations: It can be combined with other noise functions (like Perlin noise) to create complex, organic textures.
· · · · · ·

Comments

One response to “JavaScript Game Development Using Canvas: Performance Optimization And Benchmarking”

  1. […] I eventually optimized around these limitations using a variety of techniques, including: shadow blur usage reduction, cached paths, batched draw calls, sprite-caching, off-screen blitting, emulating shadow blur using gradients, and temporal upscaling of game logic – just to name some of the most useful techniques. For references, I’ve written another in-depth article on all these optimizations. […]

Leave a Reply

Check also

View Archive [ -> ]

Discover more from afeique.com

Subscribe now to keep reading and get access to the full archive.

Continue reading