axolotl – Let’s write a Doom Engine – Part 1
Published on by noot
Alright so I’ve been itching to put together this series for a while now. In this series, we will be deep-diving into the original Doom engine’s internals. How?? We will be writing our game engine to be Doom-compatible. Throughout this series of posts we’ll learn about how Doom levels are composed, how the engine work, and more.
You can think of this guide as my developer diary for this project, which we’ll call axolotl. No reason why — I just think they’re adorable.
Goals
The main project goal is to have a working game engine that can run DOOM1.wad
.
What is DOOM1.wad
you ask? Well, a .wad
file, which stands for “where’s all the data?”, contains all of the assets, maps, monsters, etc, that make up a Doom mod. Internally, each .wad
file is itself composed of pieces of data called lumps. You can think of the .wad
as a folder, and the lumps as files in the folder.
So what is DOOM1.wad
you ask (again)?
DOOM1.wad
is the shareware version of Doom. It was distributed by id Software for free, and it’s exit screen (the screen that pops up after the player quits the game) contained information about how to purchase the full, 4-episode version of Doom. Back then, in the early 1990’s, you would mail a check to the company, along with your purchase, and they would mail the game in return. DOOM1.wad
is the first episode in the full version of the game. id Software gave you a taste with the first 8 levels, and banked on the game being so fun that players would purchase the full version.
ProTip
Modern Doom mods are typically distributed as.pk3
, which itself just an alias extension for.zip
, made popular by id Software when they created Quake II. We’ll get more into this later.
The .wad
spec
To create an engine compatible with DOOM1.wad
, we will need to take a look at the .wad
specification, which lays out how the wad buffer is structured. The Doom wiki does a great job at documenting this. I’ll be paraphrasing it here.
- A
.wad
can have typeIWAD
(internal wad), orPWAD
(patch wad). - A
.wad
always starts with a 12-byte header.- The first 4 bytes of the header are ASCII characters
IWAD
orPWAD
. This is the type of the wad. - The next 4 bytes (after the ASCII characters, so offset
0x04
), is an integer specifying the number of lumps in the wad. - The 4 bytes after the number of lumps (so offset
0x08
), is another integer that holds a pointer to the location of the lump directory.
- The first 4 bytes of the header are ASCII characters
- The
.wad
lump dictionary also has a specification.- The first 4 bytes are an interger holding a pointer to the start of the lump’s data.
- The next 4 bytes are an integer representing the size of the lump, in bytes.
- The next 8 bytes is an ASCII string defining the lump’s name.
- The Doom wiki article notes that the lump name has a limit to 8 characters, and should be null-terminated if less than 8 characters to ensure maximum tool compatibility.
- Virtual lumps, such as
F_START
, will have a size of 0.
By the way — All integers are x86-style little-endian.
Here’s all this info in an easier to digest format.
Header Specification
Position | Byte length | Type | Description |
---|---|---|---|
00 | 4 | ascii | IWAD or PWAD |
04 | 4 | int | The number of lumps in the wad |
08 | 4 | int | A pointer to the location of the lump directory |
Lump Directory Specification
Offset | Byte Length | Type | Description |
---|---|---|---|
00 | 4 | int | A pointer to the start of the lump data |
04 | 4 | int | The size of the lump (in bytes) |
08 | 8 | ascii | The lump name |
With all this together, you can conceptialize a wad file as such:
HEADER |
LUMP DATA |
LUMP DIRECTORY |
Coding it in Python
Ok, so now that we know how the wad file is composed, let’s get started on coding a way to read it.
First let’s grab a copy of DOOM1.wad
, which can be found on the DOOM1.wad page on the Doom Wiki. I’m going to write tests to verify the spec is adhered to. But first, here’s the Wad
class, which will take care of loading the wad file, as well as parsing out all of the data inside of it.
# Wad.py
class Wad:
def __init__(self):
self.buffer = None
self.header = None
self.type = None
self.num_lumps = None
self.dir_offset = None
@staticmethod
def load(filepath):
with open(filepath, 'rb') as file:
buffer = file.read()
wad_instance = Wad()
wad_instance.buffer = buffer
wad_instance.header = buffer[:12]
wad_instance.type = wad_instance.header[:4].decode('ascii')
wad_instance.num_lumps = int.from_bytes(wad_instance.header[4:8], byteorder='little')
wad_instance.dir_offset = int.from_bytes(wad_instance.header[8:12], byteorder='little')
return wad_instance
Like a good boy, we’re going to write a test for this. Here it is.
# test_wad.py
import unittest
from axolotl.core.Wad import Wad
class TestWad(unittest.TestCase):
def test_load(self):
wad = Wad.load('assets/DOOM1.WAD')
self.assertIn(wad.type, ['IWAD', 'PWAD'])
self.assertIsInstance(wad.num_lumps, int)
self.assertGreater(wad.num_lumps, 0)
self.assertIsInstance(wad.dir_offset, int)
self.assertGreater(wad.dir_offset, 0)
if __name__ == '__main__':
unittest.main()
We can now run the test doing python3 -m unittest tests/test_wad.py
🪄
Coming up next… reading the lump directory