noot.sh

Thoughts and recipes from a Developer / Home Chef / Gamer / Doom enthusiast

axolotl – Let’s write a Doom Engine – Part 1

Published on by

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 type IWAD (internal wad), or PWAD (patch wad).
  • A .wad always starts with a 12-byte header.
    • The first 4 bytes of the header are ASCII characters IWAD or PWAD. 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 .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

PositionByte lengthTypeDescription
004asciiIWAD or PWAD
044intThe number of lumps in the wad
084intA pointer to the location of the lump directory

Lump Directory Specification

OffsetByte LengthTypeDescription
004intA pointer to the start of the lump data
044intThe size of the lump (in bytes)
088asciiThe 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

Leave a Reply

Built with plentiful amounts of 💖 and ☕

Check me out on GitHub

© 2025 - noot.sh