DOOM Collision Detection


In the DUNSHIRE DOOM launch announcement, I mentioned collision detection was an obstacle every time I’ve tried to work on 3D graphics. This time - whether it was from helpful links and blogs, reading the DOOM source code, or just sheer luck and experience - I actually managed to build a working collision detection system. I’d like to talk about what I learned along the way and also point out interesting differences in DUNSHIRE DOOM compared to the original DOOM.

A little warning: this post is dense with details. It was challenging for me to organize my thoughts and present them in a coherent way. The process of building and writing about collision detection has certainly been educational for me. I hope the write up is fun or educational for you.

Terminology

Before getting into the details, it’s important to understand some common DOOM map and collision detection terms.

At a high level, DOOM maps are a collection of 2D polygons called sectors. A sector has a floor height and ceiling height and the walls of the sector are defined by linedefs. A linedef can be single sided (a solid wall) or double sided when it joins two sectors. For rendering, Sectors also have a light level and linedefs also have a sidedef with texture information but these values do not effect collision detection. For collision detection, we need to know the start and end of a line, whether it is single sided or double sided, and if it’s double sided, we need to know the difference between floor and ceiling height of the two sectors it joins. If the ceiling of the sector we are entering is too low, we cannot enter. Similarly, we cannot enter if the floor is too high.

Nothing selected

Top down view of DOOM’s first map E1M1: Hangar. Drag or zoom to look around. Linedefs are red (single sided), yellow (double sided but same zfloor) or white (step or ledge). Hovering or tapping on a sector will highlight the sector linedefs in magenta.

When I talk about collision detection, I’ve actually got two things in mind: detection and response. Collision detection is determining when and where objects collide and collision response is deciding how the objects respond. In the code, these things are generally separate. There is a block of code for detecting collisions and several different blocks for responding to the collisions. For example, a bullet hitting a wall is different from a player hitting a wall or a bullet hitting a player. This is collision response, detection is just figuring out if and where those things collide.

One more thing. For performance, collision detection happens in two phases: broad and narrow. In the broad phase, we are quickly finding objects that are candidates for collision or, to put it another way, we are eliminating objects we know cannot collide. It can be expensive to figure out if the objects actually collide so we want to eliminate as many candidates as possible in the broad phase. Once we have the candidate list, we run a the narrow phase with detailed calculations to determine if the objects collide and where.

A Journey in Collision Detection

My implementation was a journey. I changed approaches as I went and learned. This section tries to capture the key points in that journey. The journey has been slow because I’ve mostly been working alone. This is one of those times where, working with a team or at least having a peer close by to bounce around ideas can save a bunch of time. The first chapter of the book, Team Geek, has several great examples of this idea. Software development is best done with a team. Without a team, it’s easy to get stuck or go down the wrong path and waste time.

My journey with collision detection started without broad phase detection. I knew this would never scale for large maps but I wanted to start simple and get something working. I started by testing if the player (represented by a circle) hit any linedefs in the map. A little background: DOOM used “discrete” collision detection. Rather than move the player their whole velocity, DOOM moved the player a small step multiple times and checked for collisions at each step. This works for low velocity objects but small steps for high velocity objects means either lots of intermediate checks or large steps that may miss collisions. For my own interest, I wanted to try out continuous collision detection so I found a nice little article for circle-line collisions. Why circle-line? The size of a DOOM object is determined by its radius so representing objects as circles seemed like a good place to start. It took some debugging to get the implementation right but it worked well enough.

Circle-line intersection test. Drag the circles to move the endpoints of the line. The grey arrow represents the direction of the object. If you drag the arrow over the line, you will see where the object would collide.

I extended the code a little bit to also test circle-circle collisions to test collisions between the player and other objects.

Circle-Circle intersection test. Drag the objects (circles) into position. The grey arrow represents the direction of motion of one object.

Next step was to add in broad phase collision detection. DOOM used a 2D grid called a block map for broad phase collision detection. Each cell in the block map contains the objects and linedefs that touch the cell. When a player moves, we find the cells they are touching and run detailed tests on only those objects. I loaded up the DOOM block map and used it and, for slow moving objects like a player or monster where we cross one or two blocks, it seemed to work. There were little problems here and there because objects on the edge of a block would be touching multiple blocks but I knew DOOM had some quirks with collisions and I figured I could clean it up later somehow. For bullets though, it got more complicated. Bullets could cross ten or twenty blocks and how do we find those blocks? With a little googling, I came across the Amanatides-Woo algorithm for fast voxel traces with an assist from stackoverflow on initializing the values.

Trace start: (0, 0) Trace end: (0, 0)

Blockmap hits. Each block represents 128 map units. Touch/click to start a trace and release to end the trace. The blockmap blocks that are touched by your trace are highlighted in red. Note that some blocks are hit multiple times (they are a darker red). You can make the “box size” bigger to see how it impacts the trace.

Although this worked, I wasn’t happy with the implementation. I ended up with three ways of scanning the block map: one for movement (moving object with radius), one for bullets (line trace), and one I haven’t mentioned which was for explosions (non-moving point with radius). Can’t we just have one that works for everything? I started to think and realized we could do this using parameters for radius, movement, and starting position:

Getting this working took several evenings and when it was done, it felt like a hack.

In parallel to implementing and debugging collisions, I was playing around with the renderer and reading about Binary Space Partitioning (BSP). BSP was key in making DOOM possible on early 1990’s hardware although my understanding is that it is not used much in modern games. I won’t try to explain BSP here because this post is long already and there are great articles out there like this one or a video explanations. At a high level, BSP is a binary tree and walking the tree tells you what objects are near you. As I learned more about BSPs though, I kept reading that it could be used for collision detection and I wanted to try that out.

BSP Collision Detection

Using BSP for collision detection was simple and clean once I got comfortable with it. We take a 2D point and traverse the BSP tree to find the sector that contains the point. Once we know the sector, we can find the walls or objects nearby. It’s a little more complicated in practice because the partition lines can divide a wall such that part of a wall is on one side and part is on the other (these are called segs in DOOM). Similarly sectors are divided up by the partition lines into subsectors. These subdivisions actually help because a large sector with lots of monsters or walls will be split into small subsectors and we only need to check for collisions with monsters and walls in the same subsector as the player.

Nothing selected

E1M1: with subsectors visible. A selected subsector will highlight in cyan and the containing sector in magenta. TIP: Go left and inspect the the purple room and the outdoor red room to see examples of walls that are split into segs.

Traversing the tree gets a little more complicated when tracing a bullet (ray) or a moving object but not much more complicated:

Drag a line through the map. An animation will show the BSP partition lines (magenta) and the subsectors that are searched in order. Most traces will end before visiting all subsectors because the trace will hit a wall or monster.
Hint 1: draw a line on the stairs or across the whole map.
Hint 2: draw a short line to partition lines for a single subsector.

Tree traversal code is straightforward. The actual code is not as short as the snippet below but it’s not far from it.

function visitNode(node: TreeNode) {
    if (node.isLeaf) {
        if (boundsOverlap(node, point, radius)) {
            processNode(node);
        }
        return;
    }

    // is object sitting on the partition line? If yes, we check both sides
    const traceOnLine = isTouchingLine(node.partitionLine, point, radius);

    // Visit left or right child depending on which side of the partition line the point is
    const side = traceOnLine ? -1 : Math.sign(signedLineDistance(node.partitionLine, point));
    visitNode(side <= 0 ? node.left : node.right);

    // Visit opposite side if start and end are on different sides of the partition line
    const endSide = Math.sign(signedLineDistance(node.partitionLine, endPoint));
    if (traceOnLine || endSide !== side) {
        visitNode(side <= 0 ? node.right : node.left);
    }
}

There also one more suble thing going on in the code above. Partition lines continue infinitely but sectors (and subsectors) do not. Just because you are on one side of a partition, does not necessarily mean you are inside the subsector. Most of the time this doesn’t matter but in E1M7, Imp-43 and Shotgunner-41 are touching the wall of the sector and the neightbouring sector is a lift. Because the trace determins the monsters are in both sectors then they block the lift from moving. Hopefully the diagram below helps clarify what is going on.

Imp-43Shotgunner-41Pinkie-42Pinkie-44Pinkie-196

DOOM E1M7: Computer Station. Drag a diagonal line through Imp-43 or Shotgunner-41 see the correct subsector is hit. Disable bounds checks and try again and see the subsector you expect as well as the one on the other side of the gap are hit.

To get around this, we check the bounds of the object against the bounds of the subsector. This is why we check boundsOverlap() before processNode() in the sample code above. DUNSHIRE DOOM uses axis aligned bounding boxes (AABB) for these checks so there may still be some false positives because subsectors are not necessarily rectangles but I haven’t noticed any problems yet.

Narrow Phase Refinements

I mentioned above that my initial approach for narrow phase collision detection was circle-line and circle-circle collision tests. Over time, and as I filled in more implementation and did more testing, it became obvious this was the wrong choice. DOOM object size is indeed controlled by a radius property however the radius actually defines the size of the bounding box. If we use radius only, the player will fit through areas they shouldn’t or squeeze past objects that should block their way. You can experience this in E1M1 by trying to walk through a barrel in the starting room. As you walk into the barrel, you’ll feel yourself blocked like you are inside a square box pushing against another square box. As a result, I threw away the circle-line and circle-circle tests and went with AABB tests which wasn’t too hard.

AABB hit detection. Drag the boxes or the line to test for collisions. Drag the grey arrow to adjust the movement vector of the orange box.

The narrow phase itself performs collision tests against all objects and segs in the subsector (recall a seg is a part of a wall on one side of a partition line). We also test for a collision against the sector floor and ceiling. The hits are then sorted and passed on to collision response. One interesting thing about these tests is that we ignore a collision if the objects are moving apart. This mostly much prevents monsters from getting stuck together and it also allows the player move through solid walls when they move from from outside the map to the inside. I haven’t yet found problems with this, and it helps avoid bugs like the player start spot being stuck to the wall in DOOM II’s MAP03.

As an aside, collision detection does allocate lots of small objects. Someday, I’ll put a test harness in place and see if I can squeeze some performance out of this using a pool of objects.

Collision Response

DUNSHIRE DOOM collision response code is pretty messy in my opinion (example) but I’m still learning. I like to think that someday I’ll look back on this with good ideas for how to clean it up. The narrow phase collision detection tells us when we hit a wall, object, or floor/ceiling therefore most collision response is split into those three pieces. Some functions, like line of sight check only check for walls while functions like radius damage only check for objects.

In general, the collision response code didn’t change much throughout the iterations of collision detection. When a player hits a wall or object they slide along by projecting the direction of movement against the object they hit. For walls, this means projecting the velocity along the wall. For objects, it means projecting along either the x or y axis because all objects are AABBs. There were still a few gotcha though:

(1) If we collide the walls in the wrong order (a farther wall before a closer wall) then the collision response will be wrong. That was solved by sorting collisions in collision detection so nearest happens first.

(2) Horizontal and vertical walls are a special case because objects are axis aligned, a vertical wall can hit the object at the same time as a horizontal wall. We prioritize which wall to hit first based on which wall overlaps with the object. Diagonal walls do not have this problem because they always hit the object on the object corner.

Player

The Player AABB overlaps with the vertical wall (bold green line) so we prioritize that collision over the horizontal wall and the movement vector is projected along that wall (pink downward arrow).

(3) If we collide with a corner then we have two collisions. If we merely project the velocity along the first wall and then along the second we may walk through the first wall. To work around this, we track which walls have been hit during this collision check and zero the velocity if we hit the same wall twice.

PlayerWall AWall B

The player hits Wall A and projects the velocity along that wall (pink arrow). Then the player collides with Wall B and project along that wall (pink arrow). If we treat each contact in isolation then we may walk through Wall A (or Wall B depending on the order of collision).

DUNSHIRE DOOM and DOOM Differences

DUNSHIRE DOOM collisions are different from DOOM in subtle ways. The differences come from two things: (1) using BSP instead of blockmap for broad phase and (2) using continuous collision detection instead of discrete. If I were aiming for a 100% compatible port, I’d want to fix this but this project was about learning and I have no plans to improve compatibility at the moment (although I’m happy to look at a PR). As a result of the implementation, you can occasionally find maps that may not work, especially if they are taking advantage of blockmap bugs. Some speed running tricks won’t work either but collision detection is only part of that story.

Here are a few specific differences I’ve noticed:

Summary

This was a great learning experience for me and I’m happy with the result. There were so many times I was close to giving up because I would “fix” something and a bunch of other things would break and I couldn’t understand why. Once I understood and used the BSP, the code became simpler. Broad phase collision detection (traversing the BSP tree) is probably about 100 lines and the narrow phase is another 150. It’s not a huge amount of code but it took quite a bit of effort and thought to get there. That is perhaps a fascinating part of software: all the approaches and debugging and failures that most people will never see.

The collision response code works but, like I said above, I hope to someday make it easier to read.

One final note, you may be wondering why I didn’t use an off the shelf library for all of this. Threlte (which I’m using for rendering) even includes support for a nifty physics engine called Rapier. Although I didn’t mention it above, I actually did start out using Rapier. I thought using a physics engine would enable interesting extensions to the game. I ran into several problems and I couldn’t figure out a way to make it fit. I set out to build a DOOM game, written in JS, that felt like the original where you could bring your own renderer (in SVG, ThreeJS, BabylonJS, or whatever). That approach meant that I would be passing Rapier data, declared in the renderer, down to the game logic which felt backward. I do have a few old development branches where I tried this but the player movement never felt right in my attempts. Stepping up on stairs was particularly hard. Lastly, and personally, I really wanted to stretch myself to get collision detection working. It has been years since I tried collision detection and DOOM is mainly 2D collisions with lines and boxes which are simpler concepts compared to 3D meshes and polygons. Mission accomplished on learning so next time I write a game, I would almost certainly start from a library.