NOW LET US – AI RAG SaaS Studio TP.HCM
NOW LET US
Digital Product Studio
Back to news
DEV-TOOLS...6 min read

CSS is DOOMed

Share
NOW LET US Article – CSS is DOOMed

A developer demonstrates the power of modern CSS by rebuilding the classic game DOOM using only HTML elements and 3D transforms for rendering.

No, CSS is awesome. CSS is better than ever and it is only getting better. And that is why I built DOOM in CSS. Every wall, floor, barrel, and imp is a <div>, positioned in 3D space using CSS transforms. The game logic runs in JavaScript, but the rendering is entirely CSS. You can play it right now.

Why? Because I wanted to find the limits of what a browser can do. See how powerful modern CSS is. And because it’s DOOM. In CSS. Do you really need more of a reason?

So, modern CSS is awesome. The fact that you can even build something like this is proof how much CSS has progressed in the last 30 years. But that does not mean I did not run into problems.

The idea of this project started when I build a version of DOOM that ran on my old 1980’s oscilloscope. So a lot of the initial problems were already solved. I had the code to extract maps from the original game and a good idea about the math involved.

The first proof-of-concept I created was completely hand crafted and was created around the idea of doing as much as possible in CSS, even game state, game logic and calculations. Now that didn’t turn out to be feasible. Rendering? Yes. Absolutely. Game state… yeah, you could if you wanted to. Logic? No. Too complicated. So I split the project in two. Once I’ve proven to myself that rendering was feasible, I used Claude to create an approximate version of the game loop in JavaScript based on the original DOOM source, which to me is the least interesting part of the project. The C code is public and has been for years, so nothing new and challenging about that. So why waste time porting that over by hand. This allowed me to focus on the best parts: the CSS.

I’ve published the code on Github, but I’d like to explain a bit about how it works and what issues I’ve run into along the way.

So how does it work?

**Back to high school **

DOOM not only transports me back to a time when I was in high school, but when I started with planning this out it also brought back a lot of high school maths. Let’s start at the basics.

We use the same data the original DOOM engine uses: vertices, linedefs, sidedefs and sectors, all extracted from the original WAD file that came with the shareware version of the game back in 1993. And with this data we create a static scene out of a couple thousand <div> elements and let the browser do all of the hard work.

<div class="wall" style="
--start-x: 2560;
--start-y: -2112;
--end-x: 2560;
--end-y: -2496;
--floor-z: 32;
--ceiling-z: 88;
">

And we’re not just calculating everything in JavaScript. Each wall gets its raw DOOM coordinates as custom properties, such as two pairs of x/y coordinates and the floor and ceiling heights. We don’t set the 3D transforms or width and height of the element directly. CSS calculates everything else based on the data we get from the WAD file.

The width of the wall? That’s good old Pythagoras on the delta between start and end coordinates. The rotation? That’s the inverse tangent on the delta between the two sets of coordinates. I think a big thank you to my high school math teacher is in order, because I still remembered how to do this after more than 30 years.

All the geometry math happens in the browser’s CSS engine. And as luck would have it, we have CSS functions for both of these formulas. We can use hypot() and atan2() to get our width and angle. Actually that is not luck. Those formulas were deliberately added to make it easier to do these kinds of calculations.

.wall {
--delta-x: calc(var(--end-x) - var(--start-x));
--delta-y: calc(var(--end-y) - var(--start-y));
width: calc(hypot(var(--delta-x), var(--delta-y)) * 1px);
height: calc((var(--ceiling-z) - var(--floor-z)) * 1px);
transform:
translate3d(
calc(var(--start-x) * 1px),
calc(var(--ceiling-z) * -1px),
calc(var(--start-y) * -1px)
)
rotateY(atan2(var(--delta-y), var(--delta-x)));
}

JavaScript passes raw DOOM data in. CSS does the trigonometry. That separation was for me the right balance between JavaScript and CSS. JavaScript runs the game loop. CSS does the rendering.

In the code we also have this strict separation. The game loop is completely separate with a separate game state. The game loop then calls JavaScript functions in the renderer, which acts as a very thin layer around the CSS. It basically sets custom properties, classes and spawns new HTML elements.

The coordinate problem

DOOM’s coordinate system doesn’t map directly to CSS 3D. DOOM uses a top-down 2D system where Y increases going north. CSS 3D has Y going up and Z going toward the viewer. But other than that we do not have to do any conversion between the two coordinate systems.

This is the reason why you see me using translate3d(x,-z,-y) instead of translate3d(x,y,z), because our custom properties are in DOOM coordinates, while the transform needs CSS coordinates.

There’s one particularly satisfying result: the rotateY(atan2(var(--delta-y), var(--delta-x))) on walls. Because DOOM Y maps to negative Z, and CSS rotateY() rotates around the vertical axis, the raw DOOM deltas feed directly into atan2() without any additional conversion. The math just works out. Don’t worry if you don’t get it. I’m not even sure if I get it. It works. Trust me.

Moving the world, not the camera

I don’t have any experience with rendering in 3D. And what I remembered from the few times that I used 3D modeling software was that you have a camera, that you can move and animate. But CSS doesn’t have a camera. So we do a trick: we move the entire world in the opposite direction of the player. We move the world around the player. Which turns out to be one of the classic tricks of how this is done.

JavaScript sets just four custom properties on the viewport: --player-x, --player-y, --player-z, and --player-angle. CSS does the rest:

#scene {
translate: 0 0 var(--perspective);
transform:
rotateY(calc(var(--player-angle) * -1rad))
translate3d(
calc(var(--player-x) * -1px),
calc(var(--player-z) * 1px),
calc(var(--player-y) * 1px)
);
}

If you compare the translate3d() with the one for the walls, you’ll notice that it is now the inverse. Instead of translate3d(x,-z,-y) we now use translate3d(-x,z,y). This is because we’re moving the world in exactly the opposite direction. If we do a step forward, we’re moving the world backwards. If we go up the stairs, we’re moving the stairs downwards. Everything is in reverse.

That first translate: 0 0 var(--perspective) is a subtle but important detail. CSS perspective positions the viewpoint a certain distance away from the scene. Without compensating for that, the entire world appears too far away. So we shift the scene forward by exactly that amount. That took a bit to figure out. One other detail is that we’ve kept it separate from the main transform by using a standalone translate property instead of using a translate() function on transform, which allows for smoother transitions between different camera standpoints, but we’ll get back to that.

Moving and looking around is just updating four custom properties. That’s it.

Floors are divs, tipped sideways

DOM elements are vertical by default — they exist in the x/y plane. Floors need to be horizontal. So every floor gets a rotateX(90deg) to tip it from vertical into the horizontal plane.

.floor {
transform:
translate3d(/* position */)
rotateX(90deg);
}

It has to be positive 90 degrees, not negative, because we need the div to extend forward in the z direction. I got that wrong the first time. The floor was there, just facing the wrong way, so it was invisible from the player’s viewpoint.

DOOM’s floors aren’t rectangular. Sectors can be any polygon — L-shapes, irregular rooms, circular-ish curves. For those we use clip-path with polygon() to cut the rectangular div into the right shape.

© 2026 Now Let Us. All rights reserved.

Source: Hacker News

Advertisement
Ad slot ready: 5887729102

More in this category

EXPLORE TOPICS

Discover All Categories

Deep dive into the specific technology sectors that matter most to you.