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:
- Work out which way the player is, as one horizontal and one vertical preference.
- Try the diagonal that combines them.
- Try each of the two on its own, longer axis first.
- Try every remaining direction.
- 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
| Approach | Feels like | Cost |
|---|---|---|
| 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:
| Action | What it does |
|---|---|
A_PosAttack | Hitscan — an instant ray, with a random angle spread so the zombie is not a sniper. |
A_SargAttack | Pure melee: if you are close enough when the claw lands, you take damage. |
A_TroopAttack | Melee 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:
-
along > 0— the dot product is negative for anything behind you. This is the "you cannot shoot backwards" test. -
perp <= radius— the 2D cross product of a unit vector withvis the perpendicular distance from the enemy's centre to the infinite line of your shot. So this is literally "how far to the side of the bullet was he". Within his radius: hit. Outside it: you missed, and by exactlyperp - radiustiles. The game reports that number, which is how the tests assert that a 25-degree turn misses by 1.35 tiles. -
along < distanceToWall— the wall ray is cast first, with the same DDA the renderer uses. If the wall is nearer than the enemy, you shot the wall. This single comparison is the entire reason you cannot shoot through walls.
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