How the monsters work

What DOOM actually did in 1993, and what this port does instead — including the exact test for whether a shot hit or went past.

The raycaster underneath this game is a port of a 42 school project written in C. It draws walls and nothing else: no enemies, no sprites, no depth buffer, and a fire() that starts an animation and throws the mouse coordinates away. Everything below had to be built, so it was worth building it the way the game that invented this genre did.

01Everything is a table, not a branch

The instinct is to write monster behaviour as code: if the player is visible and close, attack; otherwise walk towards them. DOOM does almost none of that. In info.c every actor in the game is described by two enormous tables, and the behaviour falls out of them.

A state is five fields:

{ sprite, frame, tics, action, nextstate }

P_MobjThinker runs once per tic — DOOM's clock ran at 35 of them a second — counts tics down, and when it reaches zero moves the actor to nextstate and calls that state's action function. An imp walking is just a ring of states pointing at each other: S_TROO_RUN1 through S_TROO_RUN8, each with A_Chase as its action, the last pointing back at the first.

The pay-off is that adding a monster means adding rows to a table, not writing a new class. The cost is that behaviour is scattered across a data file, which is why reading info.c cold is miserable.

Here: the same machine, with one shortcut. DOOM gives every animation frame its own state; src/game/enemy.ts lets a state carry the list of frames it cycles, because typing eight near-identical rows adds nothing. The table is still a table — IDLE, CHASE, WINDUP, STRIKE, RECOVER, PAIN, DEATH, CORPSE — and the update loop still just counts down and advances.

02Noticing you: A_Look

An idle monster sits in a state whose action is A_Look. That calls P_LookForPlayers, which calls P_CheckSight — a line-of-sight test that walks the level's BSP tree to find out whether anything solid sits between the two points.

There is a second way to be noticed, and it is the one people remember without knowing why. When you fire a weapon, P_NoiseAlert floods the sound through connected sectors, waking monsters in rooms you have not entered yet. That is why a single shotgun blast can bring a level down on your head, and why the chainsaw felt sneaky.

Here: there is no BSP, but there is already a DDA — the same grid walk the renderer runs 960 times a frame to draw the walls. Line of sight is that ray, stopped short: cast from the enemy towards the player and compare the distance it travelled against the distance between them. If the wall is nearer, no sight. It is about six lines, in hasLineOfSight.

The noise rule is simpler still: a shot that hits nothing wakes every enemy on the level. Small rooms, no sectors to flood.

03Coming at you: A_Chase and the eight directions

This is the part worth stealing. A DOOM monster cannot walk at an arbitrary angle. It moves in one of eight directions — the four compass points and the four diagonals — and that is all. P_NewChaseDir picks one:

  1. Work out which way the player is, as one horizontal and one vertical preference.
  2. Try the diagonal that combines them.
  3. Try each of the two on its own, longer axis first.
  4. Try every remaining direction.
  5. Give up and turn around.

A counter called movecount forces a fresh decision every so often, which is what stops a monster grinding against a corner forever. The 45-degree granularity is why DOOM monsters have that faintly crab-like gait — and why they read as creatures rather than homing missiles.

The choice you have

ApproachFeels likeCost
Eight directions, DOOM-style Characterful, slightly jerky, occasionally dim ~40 lines
Steer straight at the player, slide along walls Smooth, but sticks on corners and feels robotic ~10 lines
A* or breadth-first on the tile grid Never stuck, and unnervingly clever ~80 lines, and it can feel unfair

Here: eight directions, faithfully, in newChaseDir. The tile grid is small and open enough that pathfinding would only make the enemies harder to read. The boss is the place to reach for A* if it ever needs it.

04Hitting you

Once in range, A_Chase hands over to an attack state. DOOM's attack actions come in three flavours, and every monster is some mix of them:

ActionWhat it does
A_PosAttackHitscan — an instant ray, with a random angle spread so the zombie is not a sniper.
A_SargAttackPure melee: if you are close enough when the claw lands, you take damage.
A_TroopAttackMelee up close, otherwise P_SpawnMissile — a real moving object with its own thinker.

Every one of them starts with A_FaceTarget, which snaps the monster to look at you. Damage is rolled, not fixed: ((P_Random() % 5) + 1) * 3 and friends. Random, but bounded — you can always survive a known number of hits.

Here: melee, with a deliberate WINDUP state in front of it. The enemy rears up for 0.45 seconds before the strike lands, and damage is applied on the strike frame only — so backing away mid-swing actually works. That telegraph is the whole reason the early levels are fair rather than merely easy.

05Getting hurt, and flinching

P_DamageMobj does three things worth copying. It subtracts the damage. It sets the monster's target to whoever just hit it — which is why monsters turn on each other when they catch a stray fireball. And it rolls against painchance, a per-monster number, to decide whether to drop into the painstate and stagger.

That one number carries an enormous amount of character. A former human flinches at almost anything and can be stun-locked by a chaingun. A Baron of Hell barely notices. Nothing else about the two monsters needs to differ for them to feel completely different to fight.

On death, P_KillMobj picks deathstate, or xdeathstate if the damage massively overkilled — that is the gib threshold. A_Fall then clears the MF_SOLID flag, which is precisely why you can walk over corpses.

Here: all three. painChance is 0.75 for the grunt and 0.18 for the boss, and solid returns false once dead. No gibs.

06An aside: DOOM's random numbers are not random

P_Random does not compute anything. It reads the next byte from rndtable, a fixed array of 256 values baked into the source, and bumps an index. Every "random" event in the game — damage rolls, pain chance, shotgun spread — comes off that one table.

It was fast, and it made demo recording possible: replay the same inputs from the same table index and you get the same playthrough, exactly. Speedrun verification still rests on it.

07Drawing them: billboards with eight faces

A DOOM monster is a flat picture that always turns to face the camera. What stops it looking flat is that there are eight pictures — TROOA1 through TROOA8 — and the renderer picks one from the angle between where you are standing and where the monster is facing. Walk around an imp and you see its back.

Most sheets only draw five, because the three side-on views can be mirrored. The rule is: take the angle from the actor to the viewer, subtract the actor's own angle, add half a sector so the boundaries land in sensible places, and split into eight 45-degree buckets.

Sprites are drawn after the walls, sorted far-to-near, and clipped per screen column against the solid wall segments already recorded for that column. Without that clipping a monster behind a pillar would be painted straight over it.

Here: the same, and the clipping is the reason the port needed something the C original does not have. The raycaster now stores the perpendicular wall distance for every screen column in a Float32Array, and drawSprites skips any column where the sprite is further away than the wall in front of it. One array, one comparison, and the enemies correctly disappear behind pillars.

The sprite sheet is CC0 art by Nmn with five rotations, mirrored to eight by rotationFrame.

08Shooting, and whether you hit

This is the question that started all of it: when you pull the trigger, how does the program decide between a hit and a shot that went past?

DOOM answers it with P_AimLineAttack followed by P_LineAttack, both walking the blockmap — a coarse grid laid over the level, where each 128×128 cell lists the things inside it, so a shot only ever tests nearby objects instead of all of them. The aim pass also auto-aims vertically, because the player had no way to look up or down.

Our map is already a grid, so we get the same spatial shortcut for free, and the whole test reduces to a ray against a circle. With dir the unit vector you are facing and v the vector from you to the enemy:

const v     = enemy.pos - player.pos;

const along = v.x * dir.x + v.y * dir.y;   // how far DOWN the ray it sits
const perp  = |v.x * dir.y - v.y * dir.x|; // how far to the SIDE of the ray

hit = along > 0
   && perp  <= enemy.radius
   && along <  distanceToWall;

Three lines, three separate questions:

The simpler alternative, and why it was not used

There is an easier test: the crosshair is at the centre of the screen, so ask whether the enemy's drawn sprite covers the centre column and whether it is nearer than the z-buffer value there. It is about four lines and it always agrees with what the player can see, which is a real virtue.

It was not used because it welds the rules of the game to the renderer. Shotgun pellets, enemies shooting each other, and anything fired by something that is not the camera all stop working. The world-space test does not care who is shooting.

One concession to playability: a small aim assist widens the target radius slightly, and shrinks that bonus with distance. DOOM's auto-aim was doing the same favour, less subtly.

09What is still missing

Projectiles as real moving actors, so the boss can fire something dodgeable rather than something instant. Monster infighting, which falls out almost free from the target-switching already implemented. Sound, which does more for the feel of a hit than any of the above. And DOOM's sector-flooded noise alert, which needs a notion of rooms that a flat tile grid does not have.

← back to the game