Hi! I’m Niels and it is so good to be back on the stage here at CSS Day. Today I want to talk about some projects I created in the last year or so. Now, I’m going say up front that these projects are a bit…. Let’s just say… Out there.
A presentation at CSS Day in June 2026 in Amsterdam, Netherlands by Niels Leenheer
Hi! I’m Niels and it is so good to be back on the stage here at CSS Day. Today I want to talk about some projects I created in the last year or so. Now, I’m going say up front that these projects are a bit…. Let’s just say… Out there.
You’ve probably seen the CSS DOOM game upstairs and maybe even played it, and that is something that I created. I originally I never intended to recreate DOOM in CSS. Because that is… Insane. But here we are.
Oh, PPK…? Did we update the code of conduct, because shooting other attendees in the back with a rocket launcher feels like it would be frowned upon.
But cssDOOM is just small part of the whole story. In fact, it is kind of an accident that happened - an aside to a completely different project – one that is just as unhinged. And I want to start with that story.
I get obsessed sometimes. Well… Not sometimes. Often. And not the unhealthy kind of obsessions, but periods of genuine interest and focus. And that often leads to ideas and rabbit holes and sometimes unexpected, and sometimes weird, wonderful results.
I get obsessed by web standards, but also northern lights, lego, clocks, user agent strings, astro-photography, oscilloscopes, receipt printers, reverse engineering bluetooth devices…
Video’s about watch repair, evolution of germanic languages, DMX controlled stage lights and pyrotechnics, browser compatibility before 2000, the space shuttle challenger accident report, how the browser parses HTML, history of writing
Typography and getting annoyed that proper use of the em dash now makes you look like an AI bot, making remote controlled lego cars, how barcodes and QR codes are encoded… And so much more
But mostly about the web and how I can use the web beyond the edges of the browser window. I fell in love with the web back in 1994 and that has pretty much been a constant in everything I create. But lately my obsession has been…
Making a laser clock. Even since I seen a talk by Seb Lee Delisle talk about lasers 10 years ago I’ve been fascinated by laser projectors. But I knew nothing of how lasers work and if I could connect them to the web – Seb did explain some of the issues he ran into recreating the classic 1979 Asteroids game.
Now the original game used a special display system called the QuadraScan, which used a CRT, but instead of pixels it directly steered the electron beam to draw these vector shapes.
Now, before you get excited about me showing a laser clock… They are expensive. It’s not like my name is Seb Lee Delisle who has probably a couple of spare ones just stacked up behind couch.
But… That game of Asteroids kept lingering in the back of my head and I figured that an oscilloscope could function in the same way as that QuadraScan display.
And they are really cool too. I just love the aesthetic – it really feels you have wandered into the lab of a mad scientist with all of those buttons. And if you think of it. It literally is a small particle accelerator that fires electrons at about a quarter of the speed of light. And it is pointed right at your face. Only to be caught by a thin layer of phosphor.
But of course we want to use web technology to create this clock. So this part of the talk is titled: “How I used CSS Animations to draw a clock on an oscilloscope”
Eh… Wait… Actually, that is not really accurate.
How I used WebAudio to blow up an oscilloscope and almost caused a fire…. That would be more appropriate.
Now before I tell that story… I want to take a minute and let’s think about what a clock really is… I don’t mean philosophically…
It is a circle and three lines. And on the web we can use SVG for that. Four simple shapes… Vectors… Just like that Asteroid game.
And now we can just use CSS to animate the hands and rotate them over time. Let’s take a look at the hour hand. It does a full rotation every 43200 seconds or better 12 hours. The default position of the hand is at the top. Midnight or noon. But we want the hand to be at 10.
So the animation starts at the to and it takes 10 hours to reach the position we want. But we want to start at 10. And we can do that by setting the animation delay to negative 10 hours. Then it no longer is a delay, but it pretends the animation started 10 hours ago. 10 hours ago it started at the top, so right now - ten hours later - it is at 10. I love negative animation delay. So useful.
Now we need to draw these shapes on the scope. The face-melting electron beam is steered - or better deflected - by two electrically charged plates, called the X- and Y-plates. And by deflecting the beam vertically and horizontally we can trace the image that we want. And do that multiple times per second and our image appears.
So we need to generate two signals, one for X and one for Y and it needs to trace the image we want to draw. Let’s start with the clock face, the circle. This is the circle and to create this you need some high school math. Turns out my math professor was right all those years ago – I was going to need math later in life.
So this is the circle and if you look closely it actually starts to make sense. We just make a wave form directly from the X/Y coordinates of the circumference of the circle.
And we can make any shape with that method. Every shape can be expressed in that two channel signal.
Even triangles…. Which are three lines… And we need three lines for our clock. Just oriented slightly different.
So this is what our clock looks like. If you look closely at the red signal, we have a sine wave, that the circle, and three triangles, those are the clock hands that are drawn from the centre out and then back to the centre. That is one triangle. Then another back and forth for the second hand. And another one for the third. But how do we get those coordinates?
We start with our SVG and insert into the DOM with all of the CSS transforms and animation applied, and we can the getTotalLength() and getPointAtLength API to get the raw coordinates, then apply the computed transform matrix. And we end up with two arrays with numbers. And if we plot those numbers in a graph, you can see the sinus wave again, and the three triangles.
And we do this 30 times per second… And because the SVG exists in the DOM we just get the CSS animations for free and every time we sample the shapes, we capture the current state of the animation and so we get different wave that exactly captures every frame of the animation. CSS animations on a oscilloscope.
So I built a web app that does exactly this. It has a small editor which you can use to edit your SVG and CSS and it will just inject that into the DOM and sample the geometry 30 times per second. And it outputs the waveforms using WebAudio to the oscilloscope.
We’ve connected the computer’s audio output to the X and Y channel of the scope
Let see how this looks on the scope… And now let’s set it to XY mode. And there it is…
And at this point the oscilloscope decided to… Explode. Now this is just the aftermath… I was too busy finding the power plug, scrambling to find my phone to film it and at the same time a big column of smoke was rising up and sparks went flying everywhere. And then I realised — I should probably unplug my computer too. What if this thing sends a couple thousand volts back down the audio cable?
I was fine. Did not get electrocuted. My computer survived. But the scope was dead. Now what…
Redundancy!… I may have overreacted…
But I am getting ahead of myself. My scope has exploded. I have a signal generator to finish, but no way to actually continue until I get my scope repaired or find a replacement.
And I find myself on the train to Beyond Tellerrand in Berlin last November. It’s a five hour ride and I have nothing to do… So I decide to built my own oscilloscope simulator.
But what I really want a 1980s oscilloscope with all of its faults and limitations. We also want to replicate how the phosphor works in a real scope. And what we really want is a physics simulation of the electron beam and how it is deflected by the electrostatic x and y plates. But I have to do it from memory. I only had the scope working for an hour or so when it exploded.
And this is where things go off the rails. I did a deep dive into how scopes work. Electromagnetic Force, Acceleration, Velocity, Damping, Euler integration of how velocity moves the beam. Euler integrations. Overshoot amplitude decay…
And I was about to calculate how the electrons are exciting the phosphor and then I realised… That I have a life.
I actually don’t care about this. Apparently there was an end to the rabbit hole for me. It turns out I only care about how it looks. Does it look the same as a real scope? Yes. So great…
And with the simulator in place, it did allow me to create some other generators. Fully created and tested on the simulator… And when my scope got repaired, they just worked.
Great. But this isn’t the real DOOM game. We can move around and that is pretty much it. But we do have all of this data. We know where the walls are, where the floors and ceilings are. So I started thinking… We could project those walls with CSS 3D transforms. We could add textures. And within a day I had something where I could walk around. Eventually I starting adding more and more, such as doors, pickups, and the enemies, even fireballs.
And it isn’t fully CSS of course. There is a considerable bit of Javascript ported from the original game. But the renderer is almost 100% CSS.
But why?
First of all. JavaScript should only do what only Javascript can do. An CSS can do this. So… Now the next question is probably: “Are you crazy?”… Well… Eh..
So… There is just a small layer of JS that does basically nothing more than create DOM elements and sets classes and custom properties.
And every wall, floor, ceiling is a DIV. Simple DIVs that are 3D transformed.
We’re not using JavaScript to position every DIV, but instead the JavaScript extracts the raw coordinates from the DOOM game file - the WAD file - and those raw coordinates are passed to CSS as custom properties.
So these are all the DIVs we created for level 1. And this is one of the smaller levels. So we insert this in the DOM. And then we let CSS handle the rest. All the complicated math is done by CSS.
It’s actually not that bad. Especially compared to all the other math I needed for the scope simulator.
It basically boils down to the theorem of Pythagoras. And the reason why is that DOOM isn’t actually fully 3D, but 2,5 D. It is a flat map with heights. Not a full 3D scene - which is also why the translation to CSS works so well.
So this is our top view. We have our start coordinates and our end coordinates. What we want to do is position our wall on those start coordinates, and set a width and rotate it on the angle between the start and end coordinates.
And the width is just the longest side of the triangle, which is the square root of x squared + y squared. And the angle is the inverse tangent of y divided by x.
Great. Everybody with me? Just nod and pretend this makes sense. It’s fine.
In our CSS this looks like this. Thanks to the relatively new CSS trigonometry functions we can now just calculate the width and the angle that we need. No need for JavaScript to pre-calculate everything. No. We’re just setting the properties straight from the DOOM WAD file and CSS does the calculations.
And this is where we then simply set the width - and also the height which we can calculate from the height of the floor and ceiling.
And then we position the DIV in 3D space using translate3d with the start coordinates and finally rotate it using the angle we just calculated. And we do this for every wall in our scene. Which can be a couple of thousand.
And of course ceilings and floors too. They are also just square DIVs that use the same positioning calculations, except that we need to rotate it 90 degrees to place them flat “on the floor”.
Now this works… If you have a square “sector”. A sector is basically a group of walls, floors and ceilings that belong to each other.
But as you just seen, we can place wall at arbitrary angles, so rooms are not by definition squares. And DIVs are… So how are we dealing with these floors. Here we can see two sectors. One is a simple octagon. The other is an polygon with a hole cut out of it. And these are DIVs too…
DIVs with a clipping path. Great. This involves a bunch of extra math that we do in JavaScript - it isn’t really feasible to do this in CSS. That is that little bit of JavaScript that we need in our renderer. But we pre-calculate that and set it as a clipping path and then CSS does the actual rendering.
Now, simple shapes like octagons have been possible for a while now. But the shape() function is relatively new and it uses a human readable format that basically describes the shape of the clipping path. And thanks to evenodd, we can basically describe two separate paths, so you can cut off the edges, and create a cut-out in the middle as well.
Now if we turn around and only look at the lighting of the scene we can see two things. First of all we’re standing on a light platform looking at a dark room with another light room in the distance. Those are all static lights.
The light level is stored in the game wad file and not calculated on the spot. That makes our lives a lot easier. We can also see that the floors and the ceilings have the same shape and the same lighting level. Ceilings, floors and walls are grouped into sectors and the light is set on the whole sector.
But sectors are not always room sized. If you look at the stairs you can see the ceiling above the stairs have the same lighting as the stairs itself. That is because these are also sectors. Not the stairs together.
But every single step of the stairs is a sector with its own floor height and ceiling. And we set the light for the whole sector, so the ceiling is also lighter than the surrounding room, just like the step of the stairs.
How do we set the light level? We set a custom property on the sector itself and then that property is inherited down to the individual surfaces and we apply a brightness filter to it. And that is all we need to do. We are lighting our whole scene with a filter on every element.
But you’ll also notice that we have two pillars here that have a slow pulsating lighting effect. These effects are also per sector and are defined in the DOOM game file. And DOOM supports several types of lighting effects – which are little more than just animated brightness changes.
The pilar has a simple class on the sector which will run an infinite animation that changes the —light custom property. And now some of you will immediately say, you can’t do that. And that is true. Because CSS does not know what the light custom property is and how to transition between values.
So we can use at property to tell CSS that it is a number - which can have fluid transition and it inherits, which we need to give each child surface div the value we assign to the containing sector div.
And if we want to change the animation we only have to change one class and let CSS does what it does best.
But lets look one other very important part of DOOM. The textures.
So the textures are just the original image files that I extracted from the DOOM wad file, converted to PNGs. There are lots of them and they are pretty tiny according to modern standards
And every wall, ceiling or floor has the data-texture attribute which tells us which texture we are using. And we have a file that just loads the correct background image based on that texture value. I wish we could use attr() here, but as Kevin the Youtube guy showed, that is not allowed.
And then we got…. Eh. Yeah. That isn’t supposed to happen.
Sorry… Try again…
Ok, here we go.
Oh. Eh… This is of course CSS. So we could just override the textures with anything that we want.
Ok… We’re back. All this time we’ve been walking around this world… But how do we actually do that. Of course there is JavaScript involved here. We read keystrokes, mouse events or touch events, or the game pad buttons. And then… A lot more Javascript. We need collision detection. This is all running in the game loop.
But there is no camera in CSS that we can move around. Instead we move the world. So we don’t move through the world, the world moves around us. The game loop sets 4 simple custom properties. And that is all the renderer needs to know. Whenever the player moves, the properties are updated. And CSS moves the world using transform translate3d and rotateY. We don’t need to recalculate every wall floor or ceiling. The world itself is “static”.
If we zoom out a bit we can actually see this. The world rotates around us. If we move up the stairs, the world moves down. The player is static at exactly the same position. We go back down, the world moves back up.
And CSS gives us this for free. What you are seeing right here is spectator mode and it is build into cssDOOM. And this costs us literally nothing. It is just a small override of the scene transform where we add an additional translate to move the camera a bit back and up and rotate our camera to look down…
You’ve maybe also noticed that sprites, such as barrels and pick ups are always directed at player. This is called billboarding and from above that is clearly visible.
And that is again something that CSS will do for us. We already have the —player-angle custom property on the scene. And we can just use that to calculate the correct rotation to apply on the sprite.
This angle also gives us a good look the door. Let’s open it.
I wish I could show some interesting code here… But no. My apologies about the terrible joke here. Doors are actually really boring. They just transition between two heights… The offset is defined by the game data, so we just set that as a custom property when we generate the door.
The difficult part is all in the game loop. It needs to check collision detection and make sure that enemies cannot see through doors. But that is not interesting. The renderer actually does not need to know anything except when the door opens and closes and all it does is set the door-state attribute.
The next thing we should talk about is sprites. We have all kinds of objects that we can pick up, barrels that we can shoot and of course monsters. They are 2D animated images. For example for enemies we have images for walking from different viewpoints and we have shooting and dying. All of these images are combined in one large sprite sheet.
Now that image is way larger than the div that contains it. Here you can see the whole sprite sheet, and the yellow box is the active sprite. You can see the selection of the sprite within that sheet constantly changing. So we use background position to show the correct angle or action.
And by clever orientation of the images and changing the background position we can have the sprites fully animated. One very important detail is that we are using stepped animations, which in case of this helmet, we are stepping over four possible positions.
So that gives us the appearance of the helmet pulsating, while in reality the image is just moving around.
And it looks really good. Again fully automatic and powered by CSS.
Fireballs are also sprites. They are animated and billboarded, just like the other sprites. But they are quite special… Because they are flying through space using a simple CSS animation.
Whenever an imp launches a fireball, a new DIV is added to the DOM with a couple of custom properties, such as the start point, the calculated endpoint and the time needed.
And then we let CSS run that animation. And it is incredibly effective. We don’t need to update the position from the game loop. CSS does it for free. And if you look closely here… We’re using the standalone translate property, not the transform property. And that is for a very good reason.
And that reason is billboarding. If we were to use the transform property we could not independently run the animation and have the billboarding effect. But by using the standalone transform properties we can set them without overriding the other. The animation controls the positioning. And the player angle controls the rotation.
Now we do need to tell the renderer when there is an impact to remove it - perhaps even mid flight and then show the explosion sprite. By the way, explosions and bullet impacts are simple sprites created by the JavaScript layer that run for a couple of frames and then remove themselves again from the DOM by listening to the animationend event. So they are literally fire and forget.
So let’s talk a bit about another animation technique that is used throughout this project.
When the player is moving around you can see the weapon bob around. That is a nice little CSS animation.
And originally we just apply the animation when the player was moving using a class set on the viewport. And that works, but the illusion breaks whenever you stop moving. The weapon would just jerk back to the default position.
The solution is to always have the animation, but the default play state is set to paused. It is just paused somewhere in that animation and we don’t care where. And whenever the player is moving we set the state to running, so it will continue from where it last stopped. So now the transition between walking and stopping is seamless.
Now, one of the last things I wanted to show you. Because this is the web, cssDOOM is responsive.
And that creates some other challenges, because the gun here needs to be aligned to the top of the status bar. And when the status bar wraps over two or even three rows, we need to move the gun upwards. Anchor positioning to the rescue. The status bar is the anchor and we just make sure the bottom of the gun is anchored to the top of the status bar. Probably not something that the spec authors thought about when they created Anchor positioning. But I love it.
So yeah. That is just some small details of how css DOOM works. It grew quite a bit since that first proof of concept. I’ve added multiplayer support - you can play it yourself upstairs - not now - later - which basically renders two full scenes in one browser window which is then split over two screens.
It is amazing that this actually works. We’re pretty close to the limit of what the browser can do…. WAIT Everything you’ve seen so far is just thousands of divs - probably less than a React app, but still. 1000s of divs and some CSS. But there is one exception…
This is a button. As it should be. And if I can do that in DOOM, what excuse do you have? A div is not a button.
So… Is this useful. No. Don’t do it. CSS was never intended to do this… But I am actually amazed how performant it is… CSS is awesome!
But not every browser does this well. We are definitely finding the limits. There are some issues in Chrome - which will hopefully be solved eventually. Some rendering issues in Safari, but it is definitely quite playable. The best browser for this kind of brutal rendering punishment is Firefox which has been not been flawless, but pretty close. Bugs will be filed. And I’ve been told that Jake and Bramus will personally fix them this afternoon… So…
So this was a very nice side-quest. A rabbit-hole inside of a rabbit-hole. But why stop here? Am I going to build a version of CSS DOOM that runs on Lyra’s CSS CPU emulator… No.
Is there more to this story? Well I got distracted for a bit.
I created a CSS flamethrower.
On the train to Beyond Tellerrand I also saw this on AliExpress. It’s really cheap. AliExpress, cheap and flamethrower. Not great to hear those words in the same sentence…. But there was this button. And I bought it… I figured I had weeks before it arrived and plenty of time to prepare my wife. And when I arrived home, it was there. On my coffee table. I had some explaining to do.
Yeah. I cannot show it here on stage, because * apparently a church burned down in Amsterdam earlier this year and I’ve been told that under no circumstances I am allowed to set fire to the stage.
We’re using the same technique as before. We’re sampling the styles applied to elements in the DOM. In this case getComputedStyle to get the values of custom properties. I build a whole web app for this that can control all kinds of devices, such as lights, smoke machines, laser projectors and flamethrowers. All using CSS and it can actually run animations on it.
And yes, I still want to make a clock using a laser projector… I figured that if I can buy 6 oscilloscopes and a flamethrower I could buy a laser projector as well… And as it turns out connecting it to the web is relatively simple using WebUSB. And it expects X and Y coordinates – which we already have for our oscilloscope…. So who wants to see CSS animations on a laser projector?