DOOM...*rendered* using a single DIV and CSS!
For clarity, I have not rebuilt DOOM in CSS...yet. No this is far simpler: rendering the output of DOOM into a single div using a single background-image: linear-gradient block. all client side in the browser! Is it silly? Yes Why did I do it? I take it you have never read my articles before, I do silly things with web technologies to learn things... Why should you read this nonsense? - well you get to play DOOM rendered with CSS for a start! But seriously, the code can show you some interesting things about interacting with WASM and scaling an image from a . I also deep dive into averaging pixel values from a 1D array, so if that interests you, that might be useful too! Oh and a huge shoutout to Cornelius Diekmann, who did the hard work of porting DOOM to WASM Anyway, enough preamble, you are here to play "Doom in CSS*" Doom rendered in CSS On mobile, controls are below the game, for PC controls are explained in the pen. You have to click on the game before input is recognised (Also if you are on PC clicking the buttons below the game will not work, they are mobile only). So what is going on here? It just looked like low quality Doom right? BUT - if you dared to inspect the output you probably crashed chrome... You see, we are doing the following: Getting the output from doom.wasm and putting it on a element. We are hiding the canvas element and then using JS to gather pixel data We take that pixel data, find the average of every 4 pixels to halve the resolution. We then convert those new pixels to a CSS linear gradient. We apply that linear gradient to the game div using background-image: linear-gradient A single linear gradient generated is over 1MB of CSS (actually 2MB), so sadly I can't show you here what it looks like (or on CodePen!) as it is too large! And we are creating that 60+ times a second...web browsers and CSS parsing is pretty impressive to be able to handle that! Now I am not going to cover everything, but one thing that was interesting was turning the data into an array and then getting pixel data for rescaling, so let's cover that: Simple way to get average pixel colour I had an issue. Rendering the game in CSS at 640*400 made Web browsers cry! So I needed to downscale the image to 320*200. There are loads of ways to do this, but I chose a simple pixel averaging method. There are some interesting things in the code, but I think the resizing function is one of the most interesting and may be useful for you at some point. It's especially interesting if you have never dealt with an array of pixel data before (as it is a 1D representation of a 2D image, so traversing it is interesting!). Here is the code for grabbing the average across pixel data for reference: function rgbaToHex(r, g, b) { return ( "#" + [r, g, b].map((x) => Math.round(x).toString(16).padStart(2, "0")).join("") ) } function averageBlockColour(data, startX, startY, width, blockSize) { let r = 0, g = 0, b = 0; for (let y = startY; y "#" - 200.2 -> round (200) -> (12 * 16) + 8 = C8 - 6.9 -> round (7) -> (0 * 16) + 7 = 7 - 88.4 -> round (88) -> (5 * 16) + 8 = 58 - Pad -> C8, 07, 58 - Join -> #C80758 And there we have it, R200, G7, B88 is hex code #c80758. That's a wrap There are some really interesting parts around sending commands to WASM applications in there, I would encourage you to explore those yourself, along with the super interesting article by Cornelius Diekmann on porting DOOM to WASM I mentioned earlier. If you found this article interesting (or distracting...haha) then don't forget to give it a like and share it with others. It really helps me out! See you all in the next one, and have a great weekend

For clarity, I have not rebuilt DOOM in CSS...yet.
No this is far simpler:
- rendering the output of DOOM
- into a single div
- using a single
background-image: linear-gradient
block. - all client side in the browser!
Is it silly? Yes
Why did I do it? I take it you have never read my articles before, I do silly things with web technologies to learn things...
Why should you read this nonsense? - well you get to play DOOM rendered with CSS for a start!
But seriously, the code can show you some interesting things about interacting with WASM and scaling an image from a .
I also deep dive into averaging pixel values from a 1D array, so if that interests you, that might be useful too!
Oh and a huge shoutout to Cornelius Diekmann, who did the hard work of porting DOOM to WASM
Anyway, enough preamble, you are here to play "Doom in CSS*"
Doom rendered in CSS
On mobile, controls are below the game, for PC controls are explained in the pen.
You have to click on the game before input is recognised
(Also if you are on PC clicking the buttons below the game will not work, they are mobile only).
So what is going on here?
It just looked like low quality Doom right?
BUT - if you dared to inspect the output you probably crashed chrome...
You see, we are doing the following:
- Getting the output from
doom.wasm
and putting it on aelement.
- We are hiding the canvas element and then using JS to gather pixel data
- We take that pixel data, find the average of every 4 pixels to halve the resolution.
- We then convert those new pixels to a CSS linear gradient.
- We apply that linear gradient to the game div using
background-image: linear-gradient
A single linear gradient generated is over 1MB of CSS (actually 2MB), so sadly I can't show you here what it looks like (or on CodePen!) as it is too large!
And we are creating that 60+ times a second...web browsers and CSS parsing is pretty impressive to be able to handle that!
Now I am not going to cover everything, but one thing that was interesting was turning the data into an array and then getting pixel data for rescaling, so let's cover that:
Simple way to get average pixel colour
I had an issue.
Rendering the game in CSS at 640*400 made Web browsers cry!
So I needed to downscale the image to 320*200.
There are loads of ways to do this, but I chose a simple pixel averaging method.
There are some interesting things in the code, but I think the resizing function is one of the most interesting and may be useful for you at some point.
It's especially interesting if you have never dealt with an array of pixel data before (as it is a 1D representation of a 2D image, so traversing it is interesting!).
Here is the code for grabbing the average across pixel data for reference:
function rgbaToHex(r, g, b) {
return (
"#" +
[r, g, b].map((x) => Math.round(x).toString(16).padStart(2, "0")).join("")
)
}
function averageBlockColour(data, startX, startY, width, blockSize) {
let r = 0, g = 0, b = 0;
for (let y = startY; y < startY + blockSize; y++) {
for (let x = startX; x < startX + blockSize; x++) {
const i = (y * width + x) * 4;
r += data[i];
g += data[i + 1];
b += data[i + 2];
}
}
const size = blockSize * blockSize;
return rgbaToHex(r / size, g / size, b / size);
}
This averageBlockColour
function is useful if you ever want to do simple image resizing (for a thumbnail for example).
It is limited to clean multiples (2 pixel, 3 pixel block size etc.), but gives a good idea of how to get average colours of a set of pixels.
The interesting part is const i = (y * width + x) * 4
This is because we are using a Uint8ClampedArray
where each pixel is represented by 4 bits, 1 for red, 1 for green, 1 for blue and 1 for the alpha channel.
We use this as we need to move around an array that is in 1 dimension and grab pixel data in 2 dimensions.
Pixel data explanation
We need to be able to move around in blocks to average the colours.
These blocks are X pixels wide and X pixels tall.
This means jumping past the rest of an images row data to get the second (or third, or fourth...) rows data as everything is stored in one long line.
Let me try and explain with a quick "diagram":
Image (3x2 pixels):
Row 0: RGBA0: (0,0) RGBA1: (1,0) RGBA2: (2,0)
Row 1: RGBA3: (0,1) RGBA4: (1,1) RGBA5: (2,1)
Array data: [ RGBA0 | RGBA1 | RGBA2 | RGBA3 | RGBA4 | RGBA5 ]
Image pos: (0,0) (1,0) (2,0) (0,1) (1,1) (2,1)
Array pos: 0-3 4-7 8-11 12-15 16-19 20-23
So now you can see how each row of our image is stacked one after the other, you can see why we need to jump ahead.
So our function takes:
- data: our array of pixel data,
- startX: the left most position of the pixels we want data for (in 2 dimensions)
- startY: the top most position of the pixels we want data for (in 2 dimensions)
- width: the total width of our image data (so we can skip rows)
- blockSize: the height and width of the number of pixels we want to average.
If we wanted to get the average of the first 2 by 2 block of pixels here we would pass:
-
data:
[ RGBA0 | RGBA1 | RGBA2 | RGBA3 | RGBA4 | RGBA5 ]
- startX: 0
- startY: 0
- width: 3
- blockSize: 2
Within our loops we get:
//const i = (y * w + x) * 4;
const i = (0 * 3 + 0) * 4 = start at array position 0: RGBA0
const i = (1 * 3 + 0) * 4 = start at array position 12: RGBA3
const i = (0 * 3 + 1) * 4 = start at array position 4: RGBA1
const i = (1 * 3 + 1) * 4 = start at array position 16: RGBA4
Which is pixel data for:
(0,0, 1,0)
(0,1, 1,1)
Then if we want to get the average of the next 4 pixels we just pass:
-
data:
[ RGBA0 | RGBA1 | RGBA2 | RGBA3 | RGBA4 | RGBA5 ]
- startX: 1 <-- increment the start by 1
- startY: 0
- width: 3
- blockSize: 2
Within our loops we now get:
//const i = (y * w + x) * 4;
const i = (0 * 3 + 1) * 4 = start at array position 4: RGBA1
const i = (1 * 3 + 1) * 4 = start at array position 16: RGBA4
const i = (0 * 3 + 2) * 4 = start at array position 8: RGBA2
const i = (1 * 3 + 2) * 4 = start at array position 20: RGBA5
Which is pixel data for:
(1,0, 2,0)
(1,1, 2,1)
Now we have the raw pixel data
The rest of the process is easier to understand
We have some RGBA data for a pixel - which might look like [200,57,83,255]
.
We just add up the values of each part:
r += data[i]; //red
g += data[i + 1]; //green
b += data[i + 2]; //blue
//we deliberately don't grab the "a" (alpha) channel as it will always be 255 - the same as opacity: 1 or non-transparent.
Once we have done this for our 4 pixels (our 2 loops for y and x) we will end up with a total R, G and B value for those 4 pixels (y is 0 and 1, x is 0 and 1 in the first instance and y is 0 and 1 and now x is 1 and 2 in the second instance).
We then just take the average of them:
const size = blockSize * blockSize; // (2 * 2)
// avg red, avg green, avg blue
return rgbaToHex(r / size, g / size, b / size);
And then we pass it to a function that turns variables of red, green and blue into a valid hex value.
function rgbaToHex(r, g, b) {
return (
"#" +
[r, g, b].map((x) => Math.round(x).toString(16).padStart(2, "0")).join("")
)
}
Let's break this down into steps:
- Start with a
#
- Take each of the R, G, B values in order and do the following:
- Round the value (as we had averages and we need integers)
- Convert the raw value to hexidecimal (0-9A-F to change the string to base16)
- make sure that smaller numbers (0-15) are padded with a 0 so we always get 2 digits for each of the R, G and B values (so we always get a total of 6 characters(
- join the R, G and B hex values together.
So if we had [200.2,6.9,88.4] as our R, G and B values we would get:
A = 10, B = 11, C = 12, D = 13, E = 14, F = 15
- Start -> "#"
- 200.2 -> round (200) -> (12 * 16) + 8 = C8
- 6.9 -> round (7) -> (0 * 16) + 7 = 7
- 88.4 -> round (88) -> (5 * 16) + 8 = 58
- Pad -> C8, 07, 58
- Join -> #C80758
And there we have it, R200, G7, B88 is hex code #c80758.
That's a wrap
There are some really interesting parts around sending commands to WASM applications in there, I would encourage you to explore those yourself, along with the super interesting article by Cornelius Diekmann on porting DOOM to WASM I mentioned earlier.
See you all in the next one, and have a great weekend