The Mystery of the Deterministic Super Shotgun
Okay, I've caught a cold. If I try to move, snot flies everywhere. My brain is working at 5% capacity and I need to entertain myself somehow, so here you go:
In a recent discussion, the case of the Doom random number generation popped up. I casually mentioned what happens when you exchange the random numbers for all zeroes or all 255s:
What does it play like? I tried two values, 0x00 and 0xFF. With either value, the screen “melt” effect that is used at the end of levels is replaced with a level vertical wipe: the randomness was used to offset each column. Monsters do not make different death noises at different times; only one is played for each category of monster.
The bullet-based (hitscan) weapons have no spread at all: the shotgun becomes like a sniper rifle, and the chain-gun is likewise always true. You’d think this would make the super-shotgun a pretty lethal weapon, but it seems to have been nerfed: the spread pattern is integral to its function.
Someone wanted an explanation of what "seems to have been nerfed" meant. I had no idea, but I got curious myself and I happened to know the Doom source code is publically available, so I set out on a quest to find out why the super shotgun needs the spread to do solid damage.
Being Lost
It was years since I last played Doom. I've never seen the code base before. Beginner programmers often ask how one navigates a completely foreign code base. Perhaps this can serve as a rough guide, or at least a case study.
I started by just poking my head into different files to see if I could get
a sense for which abbreviations meant what, and how everything fit together.
Some seemed less relevant than others (s_sound.c
) while others more
so (p_enemy.c
?)
In a happy accident, I found a good place to start digging deeper. As I
quickly scrolled through g_game.c
my eyes got stuck on one
declaration: int mousebfire;
. That must be related to firing your
weapon.
Seeing the Light
Sure enough. That declaration is used later in the same file:
if (gamekeydown[key_fire]
|| mousebuttons[mousebfire]
|| joybuttons[joybfire])
cmd->buttons |= BT_ATTACK;
This was found in a procedure that started with
// Builds a ticcmd from all of the available inputs
// or reads it from the demo buffer.
// If recording a demo, write it out
void G_BuildTiccmd(ticcmd_t* cmd) {
At that point, I could only assume a ticcmd
is a data structure
that contains all the actions a player can perform in a single frame (or tick)
which are then to be executed by the game engine (and perhaps sent over to
other players in a multiplayer session.)
One sneaky benefit of building a ticcmd
from the inputs instead
of converting the inputs directly to game actions is that you can build
ticcmd
s from pretty much anything (such as network data or a
previously recorded demo file) and then from that point on use the same code
as if the player was actively playing those characters affected.
In either case, this BT_ATTACK
constant seemed like a good
thing to search for in all files. Whatever code handles the shooting bit of a
ticcmd
would probably also mention the BT_ATTACK
constant. Searching* gives a few results:
./p_pspr.c:315: if (player->cmd.buttons & BT_ATTACK)
./p_pspr.c:350: if ( (player->cmd.buttons & BT_ATTACK)
./wi_stuff.c:1480: if (player->cmd.buttons & BT_ATTACK)
./g_game.c:332: cmd->buttons |= BT_ATTACK;
./d_event.h:75: BT_ATTACK = 1,
By taking a quick peek into d_event.h
one discovers that that
is the header file that defines, among other things, the value of the
BT_ATTACK
constant. wi_stuff.c
appears to handle
acceleration or otherwise movement physics – probably not relevant to us.
g_game.c
is the occurrence that started this search.
The two remaining hits sound interesting though! Both of them lead pretty
quickly to a P_FireWeapon(player)
procedure, which is our next
target. It's a very short method:
void P_FireWeapon(player_t* player) {
statenum_t newstate;
if (!P_CheckAmmo(player))
return;
P_SetMobjState(player->mo, S_PLAY_ATK1);
newstate = weaponinfo[player->readyweapon].atkstate;
P_SetPsprite(player, ps_weapon, newstate);
P_NoiseAlert(player->mo, player->mo);
}
Most of this is actually irrelevant to the problem at hand, though. The ammo
check doesn't matter to us. The last three lines in the function are just
concerned with graphics and monster AI (NoiseAlert
is the thing
that makes monsters turn onto you when you make sound – something I found out
when I was just randomly checking into files at the start of this.)
So the only thing that might be relevant here is the line that says
P_SetMobjState(player->mo, S_PLAY_ATK1);
and even so, that isn't actually executing an attack, it's just setting some sort of attack state on the character object. So let's search all files for that attack state!
./p_pspr.c:253: P_SetMobjState (player->mo, S_PLAY_ATK1);
./p_pspr.c:290: if (player->mo->state == &states[S_PLAY_ATK1]
./info.c:290: {SPR_PLAY,4,12,{NULL},S_PLAY,0,0}, // S_PLAY_ATK1
./info.c:291: {SPR_PLAY,32773,6,{NULL},S_PLAY_ATK1,0,0}, // S_PLAY_ATK2
./info.c:1120: S_PLAY_ATK1, // missilestate
./f_finale.c:418: if (caststate == &states[S_PLAY_ATK1])
./f_finale.c:427: case S_PLAY_ATK1: sfx = sfx_dshtgn; break;
./info.h:330: S_PLAY_ATK1,
I'm not particularly interested in f_finale.c
, because that has
something to do with when you complete a level… or the game… or
something like that. It has nothing to do with shooting and causing damage.
Unfortunately, the info.h
and info.c
files are
also not particularly interesting, because they only contain
definitions of animations and such, from what I gather. So the only really
relevant hit is the second one in p_pspr.c
– the file we just were
in!
Here's the bummer:
// get out of attack state
if (player->mo->state == &states[S_PLAY_ATK1]
|| player->mo->state == &states[S_PLAY_ATK2] ) {
P_SetMobjState (player->mo, S_PLAY);
}
That code is irrelevant too.
Lost Again
This probably means that some of the games functions are cycled through by
a proper state machine. The info.c
definitions might very well
specify how states transition into each other, and how attacking somehow
happens when the player is in the S_PLAY_ATK1
state… but
I'm not going to put in the time and energy into understanding what all the
arrays and numbers and constants mean in those definitions.
So at this point, I almost gave up.
Almost.
Hipshot
As a last-ditch effort, I thought I'd search through all files for
S_PLAY_ATK2
, the other attack state that is mentioned in a few
places.
./p_pspr.c:291: || player->mo->state == &states[S_PLAY_ATK2] )
./p_pspr.c:453: P_SetMobjState (player->mo, S_PLAY_ATK2);
./p_pspr.c:653: P_SetMobjState (player->mo, S_PLAY_ATK2);
./p_pspr.c:676: P_SetMobjState (player->mo, S_PLAY_ATK2);
./p_pspr.c:706: P_SetMobjState (player->mo, S_PLAY_ATK2);
./p_pspr.c:742: P_SetMobjState (player->mo, S_PLAY_ATK2);
./info.c:291: {SPR_PLAY,32773,6,{NULL},S_PLAY_ATK1,0,0}, // S_PLAY_ATK2
./info.h:331: S_PLAY_ATK2,
That is a bit odd. S_PLAY_ATK2
is mentioned a bunch in very
quick succession somewhere in p_pspr.c
. I wonder what that is
about. Let's check it out.
Success!
As it turns out, the procedures that set that state have names like
A_FirePistol
, A_FireShotgun
,
A_FireShotgun2
and so on. Can I assume Shotgun2
means super shotgun?
player->ammo[weaponinfo[player->readyweapon].ammo]-=2;
I remember the super shotgun is double-barreled in the game, so that it consumes two units of ammunation at each shot would make a lot of sense. I'm fairly sure I've found what I'm looking for!
After the sounds have started playing, and the graphics are set, the code that deals with the effects of shooting is executed:
P_BulletSlope(player->mo);
for (i = 0; i < 20; i++) {
damage = 5 * (P_Random() % 3 + 1);
angle = player->mo->angle;
angle += (P_Random() - P_Random()) << 19;
P_LineAttack(
player->mo,
angle,
MISSILERANGE,
bulletslope + ((P_Random() - P_Random()) << 5),
damage
);
}
The first call to BulletSlope
appears to determine the height
at which the bullet marks should be drawn. Recalling that Doom has what is
essentially a 2D engine – only graphics are drawn three-dimensionally – that
makes sense. The engine only knows which direction the player is facing in the
2D plane. To be able to draw bullet marks at the correct height it guesses
a height based on which enemy the player seems to be aiming at, and how high
up that enemy must be based on level geometry.
Either way, that is irrelevant for damage. It is an aesthetic concern.
The Answer
We don't need to look long beyond that for the answer. The for
loop is telling me the super shotgun fires 20 projectiles with a random spread.
The damage of each projectile is determined by
5 * (P_Random() % 3 + 1);
Normally, P_Random()
is going to be a random number between 0
and 255. When we mod that down with % 3
we get a random number
between 0 and 2. This means that the lowest damage a single projectile will
do normally is 5 * (0 + 1)
which is 5. The highest damage a
single projectile will do is 5 * (2 + 1)
, or 15.
Extrapolating for all 20 projectiles, a single shot with the super shotgun, when all projectiles hit, can make as little as 100 damage, or as much as 300 damage. On average, you can expect to make 200 damage when all projectiles hit.
What happens if we set the array of random numbers to either all zeroes or all 255s? Obviously if every random number generated is zero, the super shotgun will always do minimum damage (100 if all projectiles hit.)
What about 255? Unlucky choice of number! 255 % 3
is zero too!
So whether you fill the random array with 0 or 255, you'll do the minimum
possible damage with the super shotgun, which is half of what you'd expect
normally. Quite the nerf indeed!
This of course also means that you can cleverly pick a number (like 2, or 254) to fill the random array with to do maximum damage with the super shotgun every time.
Conclusion
So in the end, it's not that the super shotgun is dependent on the spread to function, it's that the damage it deals is randomised, and the two numbers tested both happened to give bad results.
Somewhere in there, there's a lesson about relying too much on testing and not doing enough reasoning about your code…