Today in the Sauerworld Discord (join here), we talked about the chaingun (aka minigun, aka machine gun) and shotgun damage and how their rays spread around your crosshair. Games like Counter-Strike aim for realistic projectile spread induced by the weapon's recoil: your crosshair (and with it, projectile vectors) tend to move upwards the longer you burst-fire, and players learn to correct for it by moving the crosshair down. Some weapons in some games also have projectiles spreading outwards around your crosshair, with the spread usually increasing the longer you hold the trigger.

Sauerbraten, in contrast to most shooter games you may be playing, only has very simple projectile spread mechanics. Since Sauer is open-source, let's understand the details by taking a look behind the scenes! The following code is taken directly from Sauerbraten's source code (src/fpsgame/weapon.cpp):

void offsetray(const vec &from, const vec &to, int spread, float range, vec &dest)
{
    vec offset;
    do offset = vec(rndscale(1), rndscale(1), rndscale(1)).sub(0.5f);
    while(offset.squaredlen() > 0.5f*0.5f);
    offset.mul((to.dist(from)/1024)*spread);
    offset.z /= 2;
    dest = vec(offset).add(to);
    if(dest != from)
    {
        vec dir = vec(dest).sub(from).normalize();
        raycubepos(from, dir, dest, range, RAY_CLIPMAT|RAY_ALPHAPOLY);
    }
}

Some things to note before we dive into this function:

  • the function calculates the offset for a single ray
  • it's the only function to calculate ray offset (= projectile spread) in the source code, which means the overall spread calculation is the same for all weapons that have spread (currently, chaingun, shotgun and pistol)
  • however, it takes a spread argument, so the output is not neccessarily the same for all weapons
  • the function is called with the same spread argument for every ray of a weapon, meaning chaingun spread does not increase with time

So what does this function do, exactly?

The first two arguments tell the function from where to where in the map the player is shooting. Then there are the spread and range arguments, which are taken from the weapon's defined settings. (These weapon settings are set in the source code, and can't be changed in-game. Other weapon settings would be how much damage a ray deals and how long it takes to reload.) The last argument, dest, is a variable that will hold the destination of the offset ray after the function ran. (In other programming languages, this would be the return value of the function, so let's just call it that from now on.)

From a high-level point of view, the function calculates a single ray's destination vector by preparing an offset vector which it adds to the vector pointing from the player to the target, i.e. offsetting the ray from the vector connecting from and to. It returns the vector pointing from from to the offset target as the dest vector.

The first three lines, vec offset; do ... while ...; prepare a vector variable with three components (x, y and z) and try random values between -0.5 and 0.5 for each component, until it finds a vector where x2 + y2 + z2 is greater than 0.25. This basically means the offset vector can point in any direction, but its magnitude is limited to a sphere of radius 0.5. Although this explicitly prevents the case that x2 + y2 + z2 = 0, it does not mean that this function will never produce a ray that goes exactly straight: the offset vector might point parallel to the direction of the shot, so offsetting the ray will only make it point behind or in front of the original target! You might get lucky and get a straight shot even with your chaingun!

The next line, offset.mul(...); makes it so long range shots are offset more than short range ones. It uses the shot distance (to.dist(from)), scales it by a magic factor of 1/1024, and then scales it again by the weapon's spread setting (currently 100 for chaingun, 400 for shotgun, 50 for pistol). The entire offset vector is then scaled by the result of all that scaling of the shot distance.

The following line, offset.z /= 2; is very interesting: z is the up-down axis in Sauer (if you jump, your z coordinate increases, if you fall down like a noob on reissen, it decreases). The /= 2 bit means the z component is halfed. We will get to what this means for you as a player later!

dest = vec(offset).add(to); simply defines dest as the position where the offset ray ends, by adding the offset vector to the position vector of the target of the shot.

The next line ensures that the calculated ray doesn't end where it starts, for reasons I am not sure why. It might have to do with Sauer's spawn kill protection, but it's really just my best guess here. In the cases interesting to us, this will never be the case, so the code inside the braces will run.

The last two lines of code move the destination of the ray along the ray until it collides with something in the world, for example the wall or (ideally) an enemy's player model. This is done by calculating a normalized vector dir of the ray's direction from its start (from) and end (dest) vectors, and then relying on the engine to set dest to the point where this vector dir, starting at from intersects with something that would stop a projectile. Essentially, this makes sure the ray doesn't end in front of the player or goes through her model without hitting or has the ray end somewhere beside the player in the middle of the air.

Now back to why offset.z /= 2 is so interesting here: For you as a player, this line means shots are more accurate when you are at the same height as your target! If it's not obvious, think about the sphere of possible offset vectors around the target: because the offset vector's z component is reduced by the /= 2 operation, the sphere of possible offsets around the target is no longer a sphere, but more of a pumpkin! At the very end, what matters is the 2D projection of this pumpkin towards the players camera, since the depth component of the offset vector in relation to the player's camera is irrelevant (the end point of the ray is recalculated after offsetting the shot). Seen from eye level (that is, perfectly horizontal), the surface area of the sphere of possible offset vectors got smaller by compressing it along the z-axis, but seen from above, it's still the same size! This means player's benefit less from this compression, the greater the difference in z height of the start and end of the shot, i.e. the player and their target!