CSS Doom Lasers

A presentation at CSS Day in June 2026 in Amsterdam, Netherlands by Niels Leenheer

Slide 1

Slide 1

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.

Slide 2

Slide 2

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.

Slide 3

Slide 3

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.

Slide 4

Slide 4

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.

Slide 5

Slide 5

I get obsessed by web standards, but also northern lights, lego, clocks, user agent strings, astro-photography, oscilloscopes, receipt printers, reverse engineering bluetooth devices…

Slide 6

Slide 6

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

Slide 7

Slide 7

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

Slide 8

Slide 8

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…

Slide 9

Slide 9

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.

Slide 10

Slide 10

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.

Slide 11

Slide 11

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.

Slide 12

Slide 12

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.

Slide 13

Slide 13

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.

Slide 14

Slide 14

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”

Slide 15

Slide 15

Eh… Wait… Actually, that is not really accurate.

Slide 16

Slide 16

How I used WebAudio to blow up an oscilloscope and almost caused a fire…. That would be more appropriate.

Slide 17

Slide 17

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…

Slide 18

Slide 18

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.

Slide 19

Slide 19

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.

Slide 20

Slide 20

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.

Slide 21

Slide 21

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.

Slide 22

Slide 22

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.

Slide 23

Slide 23

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.

Slide 24

Slide 24

And we can make any shape with that method. Every shape can be expressed in that two channel signal.

Slide 25

Slide 25

Even triangles…. Which are three lines… And we need three lines for our clock. Just oriented slightly different.

Slide 26

Slide 26

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?

Slide 27

Slide 27

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.

Slide 28

Slide 28

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.

Slide 29

Slide 29

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.

Slide 30

Slide 30

We’ve connected the computer’s audio output to the X and Y channel of the scope

Slide 31

Slide 31

Let see how this looks on the scope… And now let’s set it to XY mode. And there it is…

Slide 32

Slide 32

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?

Slide 33

Slide 33

I was fine. Did not get electrocuted. My computer survived. But the scope was dead. Now what…

Slide 34

Slide 34

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.

Slide 35

Slide 35

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.

Slide 36

Slide 36

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.

Slide 37

Slide 37

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…

Slide 38

Slide 38

And I was about to calculate how the electrons are exciting the phosphor and then I realised… That I have a life.

Slide 39

Slide 39

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.

Slide 40

Slide 40

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.

Slide 41

Slide 41

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.

Slide 42

Slide 42

But why?

Slide 43

Slide 43

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..

Slide 44

Slide 44

So… There is just a small layer of JS that does basically nothing more than create DOM elements and sets classes and custom properties.

Slide 45

Slide 45

And every wall, floor, ceiling is a DIV. Simple DIVs that are 3D transformed.

Slide 46

Slide 46

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.

Slide 47

Slide 47

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.

Slide 48

Slide 48

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.

Slide 49

Slide 49

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.

Slide 50

Slide 50

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.

Slide 51

Slide 51

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.

Slide 52

Slide 52

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.

Slide 53

Slide 53

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.

Slide 54

Slide 54

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…

Slide 55

Slide 55

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.

Slide 56

Slide 56

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.

Slide 57

Slide 57

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.

Slide 58

Slide 58

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.

Slide 59

Slide 59

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.

Slide 60

Slide 60

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.

Slide 61

Slide 61

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.

Slide 62

Slide 62

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.

Slide 63

Slide 63

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.

Slide 64

Slide 64

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.

Slide 65

Slide 65

And if we want to change the animation we only have to change one class and let CSS does what it does best.

Slide 66

Slide 66

But lets look one other very important part of DOOM. The textures.

Slide 67

Slide 67

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

Slide 68

Slide 68

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.

Slide 69

Slide 69

And then we got…. Eh. Yeah. That isn’t supposed to happen.

Slide 70

Slide 70

Sorry… Try again…

Slide 71

Slide 71

Ok, here we go.

Slide 72

Slide 72

Oh. Eh… This is of course CSS. So we could just override the textures with anything that we want.

Slide 73

Slide 73

Slide 74

Slide 74

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.

Slide 75

Slide 75

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”.

Slide 76

Slide 76

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.

Slide 77

Slide 77

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…

Slide 78

Slide 78

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.

Slide 79

Slide 79

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.

Slide 80

Slide 80

This angle also gives us a good look the door. Let’s open it.

Slide 81

Slide 81

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.

Slide 82

Slide 82

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.

Slide 83

Slide 83

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.

Slide 84

Slide 84

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.

Slide 85

Slide 85

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.

Slide 86

Slide 86

So that gives us the appearance of the helmet pulsating, while in reality the image is just moving around.

Slide 87

Slide 87

And it looks really good. Again fully automatic and powered by CSS.

Slide 88

Slide 88

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.

Slide 89

Slide 89

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.

Slide 90

Slide 90

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.

Slide 91

Slide 91

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.

Slide 92

Slide 92

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.

Slide 93

Slide 93

So let’s talk a bit about another animation technique that is used throughout this project.

Slide 94

Slide 94

When the player is moving around you can see the weapon bob around. That is a nice little CSS animation.

Slide 95

Slide 95

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.

Slide 96

Slide 96

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.

Slide 97

Slide 97

Now, one of the last things I wanted to show you. Because this is the web, cssDOOM is responsive.

Slide 98

Slide 98

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.

Slide 99

Slide 99

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.

Slide 100

Slide 100

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…

Slide 101

Slide 101

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.

Slide 102

Slide 102

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!

Slide 103

Slide 103

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…

Slide 104

Slide 104

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.

Slide 105

Slide 105

I created a CSS flamethrower.

Slide 106

Slide 106

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.

Slide 107

Slide 107

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.

Slide 108

Slide 108

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.

Slide 109

Slide 109

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?

Slide 110

Slide 110