Note
A JavaScript implementation of the fire effect from the DOOM (1993) title screen, originally reverse-engineered and documented by Fabien Sanglard.
The fire is simulated on a flat pixel grid. Every cell holds an intensity value from 0 (cold, near-black) to 36 (white-hot). On each tick, every pixel reads the intensity of the pixel directly below it, reduces it by a small random amount, and writes the result to a slightly shifted horizontal position. Over many iterations, this single rule produces the upward-spreading, flickering flame shape.
The bottom row is permanently set to maximum intensity (36) — it acts as the heat source. Everything above it is shaped by propagation alone.
The original approach rendered the fire as an HTML <table>, rebuilding the entire DOM — up to 2,400 <td> elements — on every frame. Each rebuild triggers HTML parsing, layout recalculation, and paint. At 20 frames per second that is 48,000 DOM node operations per second.
A <canvas> element sidesteps the DOM entirely. Pixels are drawn directly into a GPU-backed bitmap with no layout cost, no element creation, and no garbage collection pressure from discarded nodes.
Even on canvas, calling ctx.fillStyle = "rgb(...)" + ctx.fillRect(...) per pixel has meaningful overhead:
- String parsing — every
fillStyleassignment parses a CSS color string. - Per-pixel draw calls — each
fillRectis a separate graphics API call with state validation and rasterization setup.
ImageData eliminates both. The color palette is pre-computed once as a flat Uint8ClampedArray of raw RGBA bytes. Each frame, the render loop writes bytes directly into a reused memory buffer and uploads the entire result to the canvas in one putImageData() call — no string parsing, no individual draw calls.
fillRect approach → 2,400 fillStyle parses + 2,400 draw calls per frame
ImageData approach → 2,400 byte writes + 1 putImageData per frame
setInterval(fn, 50) fires on a fixed wall-clock timer regardless of what the browser is doing. This causes several problems:
- Drift — timer callbacks can stack up if a frame takes longer than 50 ms, causing stuttering bursts.
- No sync with display — updates can land mid-frame, producing tearing.
- Wasted work — the interval keeps firing even when the tab is hidden.
requestAnimationFrame is driven by the browser's rendering pipeline. It passes a high-resolution timestamp to the callback, pauses automatically when the tab is backgrounded, and aligns updates with the display's refresh cycle.
Since the fire runs at ~20 fps (one update every 50 ms) but displays typically refresh at 60 Hz, the loop uses a timestamp threshold to skip frames without accumulating lag:
#loop(timestamp) {
if (timestamp - this.#lastTime >= 50) {
this.#lastTime = timestamp;
this.#calculatePropagation();
}
requestAnimationFrame(this.#boundLoop);
}The canvas size is determined by three values:
| Variable | Default | Description |
|---|---|---|
data-width |
40 |
Fire grid width in cells |
data-height |
40 |
Fire grid height in cells |
Fire.pixelSize |
4 |
Canvas pixels per fire cell |
The rendered canvas will be:
canvas width = data-width × pixelSize
canvas height = data-height × pixelSize
For example, a grid of 253 × 151 cells at pixelSize = 4 produces a 1012 × 604 px canvas. Increasing pixelSize gives a chunkier, more pixelated look; decreasing it gives finer detail at the cost of a smaller visible result.
Configure the grid size directly in the HTML:
<canvas id="fireCanvas" data-width="253" data-height="151"></canvas>To change the size of each cell, update the static property in script.js:
static pixelSize = 4;Open index.html directly in a browser. No build step or server required.
