Deathmatch Singleplayer

Special thanks to Professor Wouter "Aardappel" van Oortmerssen for original DMSP mod/concept and awesome sounding name.

Before we begin, if you haven't downloaded it already you'll need this file:


DMSP for Q4 was originally an internal experiment to see if a new gametype was possible entirely within script. The basic premise is to provide the player an arcade-style limitless supply of monsters and pickups. A deathmatch map is the perfect place for it, because it has a closed connected layout the player can run around in as long as he can stay alive, henceforth the term "deathmatch single player."

Q4DM4 was determined to be the best map for the job, because almost the entire layout is navigable without the use of jumppads or teleporters (which the AI doesn't pay attention to). Maps like Q4DM5 had too many isolated platforms that would fill up with monsters that couldn't go anywhere.

The experiment was a success aside from a few small issues. The main one is that the player spawns in a multiplayer map with the default unstroggified marine stats and physics if the gametype isn't specifically set to multiplayer (and the game isn't a lot of fun if you're stuck moving slowly), but in multiplayer AI doesn't evaluate. Thus, besides including .aas files for the deathmatch map, the "player" keyvalue had to be set on the worldspawn in the .map itself to "player_marine_mp".

q4dm4.script is loaded by default when the map is loaded, because they share a name and a directory. q4dm4.script points to dmsp_base.script, which starts the fun.

The first thing we do is set up variables for the values we're going to have to keep track of persistently:

float dmsp_skill = 0;
float dmsp_nextComboTime = -1;
float dmsp_combo = 1;
float dmsp_score = 0;

We stash the value for the g_skill cvar in dmsp_skill, so we can use it in various functions later without having to run sys.getcvar() every time we want it. dmsp_score is pretty self explanatory, and the two combo variables are used to keep track of how many kills the player has chained together in a short span of time.

There's a lot of stuff following this in the script, but the thing that kicks it off is at the end in main(), which runs by default when a map loads. Since the game runs about a dozen frames of any map before actually spawning the player and starting the show, anything we sneak into the beginning of main() will happen at the loading screen. Since the map has no monsters in it, the game would pause to cache their models and sounds every time a new one was spawned if we didn't do this.

void main()
        dmsp_skill = sys.strToFloat( sys.getcvar("g_skill") );

        // Spawn all possible monsters and items in main to force them to cache when the map loads
        // then remove them out of the way
        entity tempy, tempy2, tempy3;           // three at a time so it doesn't take 30 game frames
        tempy = sys.spawn( "monster_strogg_marine" );
        tempy2 = sys.spawn( "monster_strogg_marine_sgun" );
        tempy3 = sys.spawn( "monster_strogg_marine_mgun" );
        tempy.remove(); tempy2.remove(); tempy3.remove();


        sys.println("Three ... ");
        sys.println("Two ... ");
        sys.println("One ... ");
        sys.println("The invasion has begun!");

After that there's a dorky countdown to give the player a couple seconds to run for some guns, and then dmsp_createSpawner() is called. This function has to do things in a somewhat peculiar order because everything relies on spawnArguments. The gameplay is driven by a func_spawner, which has to be spawned with its keyvalues already set. If we spawn it and then set keys on it they won't have any effect.

void dmsp_createSpawner()
        float j = dmsp_createSpawns();  // make nulls for spawn points

The function starts by going out to dmsp_createSpawns(), which in turn searches the entity list for every entity whose name starts with "info_player_deathmatch_", and spawns a target_null (func_spawners can only be targetted at target_nulls for whatever reason). We do this early so we can set spawnArgs on the nulls we create without interfering with the spawnArgs we set on the func_spawner. This function returns the number of spawns it found/created, which gets put in j.

At this point we begin setting spawnArgs for the spawner itself. def_spawns are listed to set up our monster loadout - we skip the tactical transfers because they don't move fast enough for the ravenous horde combat we're looking to achieve. After those are done, we add extra keys for functionality:

        sys.setSpawnArg("max_active", j);                       // assume # of spawns in map is a good monster limit
        sys.setSpawnArg("delay", ( 5 - dmsp_skill ) );          // shorter delay on harder skill
        sys.setSpawnArg("auto_target", "1");
        sys.setSpawnArg("face_enemy", "1");
        sys.setSpawnArg("skipVisible", "0");
        sys.setSpawnArg("remove", "0");                         // don't disappear from the entity list if I break this
        // spawn_* keys are set on the spawned monster:
        sys.setSpawnArg("spawn_neverdormant", "1");
        sys.setSpawnArg("spawn_script_death", "dmsp_killMonster");      // for spawning loots
        sys.setSpawnArg("spawn_script_init", "dmsp_whoosh");            // for spawning zoots

At the end of this function, the func_spawner is finally spawned and triggered, which commences the invasion.

Assuming that the # of spawns is a good monster limit isn't entirely safe - while designers seem to commonly stick to an unspoken standard of deathmatch spawn count vs map size, there is the occasional aberration. (q4dm6, for example, has 64 spawns!) spawn_neverdormant ensures that if the player leaves the PVS of a group of monsters they won't suddenly stop and stand there blinking - this way the player is always beset on all sides by the horde.

The rest of the gameplay functionality comes from the last two keys. spawn_script_init will make the monster call dmsp_whoosh() when it spawns, which simply adds some effects to try and mask their sudden appearance. spawn_script_death is the important one, which makes the monster call dmsp_killMonster() when it dies. This is where all of the item drops and scoring is handled.

void dmsp_killMonster( entity victim )
        string vicType = victim.getKey( "classname" );  // what kind of monster is it?
        vector vicOrg, vicSize, vicDrop, vicPip;

        float score = (dmsp_skill + 1) * 25 * dmsp_combo;       // score on general = 4x score on easy
        vicOrg = victim.getWorldOrigin();       // where was it killed?
        vicSize = victim.getSize();             // how big was it?  for deciding how high to toss items and print score
        vicDrop = vicOrg;
        vicDrop_z = vicDrop_z + vicSize_z * 0.5;        // drop items waist high
        vicPip = vicOrg;
        vicPip_z = vicPip_z + vicSize_z * 0.75;         // print score pip about head high

What makes it all work is that when an entity calls any of its script_ functions, it passes itself in as the first parameter. This means we can have one function that has different output based on what kind of monster calls it. This works great for us, because now we can have puny monsters add appropriately puny amounts to the player's score and drop simple items like armor shards, but have much better rewards for the beefy opponents. We set up a couple of variables early:

After that comes a block like this for every monster type that was specified in the func_spawners args:

        ... if ( vicType == "monster_iron_maiden" ) {
                if ( val > 0.6 ) dmsp_throwLoots( vicDrop, "ammo_rocketlauncher_mp", 1, 0 );
                else if ( val > 0.3 ) dmsp_throwLoots( vicDrop, "ammo_hyperblaster_mp", 1, 0 );
                else dmsp_throwLoots( vicDrop, "item_health_small_mp", 1, 0 );

                score = score * 5;
                thread dmsp_scorePip( score, vicPip, 1.25 );
        } else ...

We put a random number in val, and each monster gets its own custom bracket of what items it drops, and how frequently. This is done by calling dmsp_throwLoots(). Score is multiplied again by some other value based arbitrarily on the monster's strength, and scoring is then handled by calling dmsp_scorePip(). We'll look at both of them to see how they work.

dmsp_throwLoots is relatively simple - it takes parameters for where the drop occurs (taken from the monster's place of death), what to drop, how many of them to drop, and for fun an extra flag to allow an occasional bonus "double drop." This, once again, is done through spawnArgs. The setLinearVelocity() function is used to give all the items a random parabolic arc, so the items "pop" out of the monster instead of just appearing on the floor.

void dmsp_throwLoots ( vector org, string lootClass, float cnt, float nodoubles )
        float i;
        entity loots;
        vector toss;
        float dist = 128;                               // radius distance to spawn goodies
        float doubChance = 1 - ( 0.1 * dmsp_skill );    // more double drops on harder skill to keep the player stocked
        if ( sys.random(1) > doubChance && nodoubles == 0 ) {
                cnt = cnt * 2;                          // twice the goodies
                dist = 192;

        for( i = 0; i < cnt; i++ ) {
                toss_x = dist - sys.random(2 * dist);
                toss_y = dist - sys.random(2 * dist);
                toss_z = dist;
                sys.setSpawnArg( "origin", org );
                sys.setSpawnArg( "nodrop", 1 );
                loots = sys.spawn( lootClass );
                sys.waitFrame();                        // wait until entity exists to set toss velocity
                loots.setLinearVelocity( toss );

dmsp_scorePip() is what puts the floaty text on screen above the monster's head when it dies. It sets up timing for the text shrinking and fading out, and the combo functionality was lumped in here as well.

void dmsp_scorePip ( string text, vector origin, float pipTime ) {
        float frameTime = sys.getFrameTime();
        float startTime = sys.getTime();
        float i, d, score;
        vector org = origin, color;
        string comboType;

        if (startTime <= dmsp_nextComboTime) dmsp_combo = dmsp_combo + 1;
        else dmsp_combo = 1;
        dmsp_nextComboTime = startTime + 2 * pipTime;   // window until combo cutoff

        score = sys.strToFloat( text ) * dmsp_combo;
        pipTime = pipTime + ( 0.25 * pipTime * ( dmsp_combo - 1 ) );

We'll get to how the text works in a moment, but the function can only display static text in space. The way this function works is to only display text for one frame, and slowly change the size and color of the text in a loop. startTime is set to the time when the function is called (when the text first appears), and we then change our text's properties depending on how many frames we've waited since that time.

The way the combo system works is that every time this is called, dmsp_nextComboTime is set to about 2 seconds into the future (a little more if it's a tougher monster). Before doing that, if the current time is still earlier than the last time it was set to, then we're still within the combo delay, and we increase the modifier. That gets multiplied to the score we were passed from dmsp_killMonster(), as well as the lifetime of the floating text.

We start the loop, continually setting d to a value from 0 to 1, where 0 is the time we started, and 1 is the time the text is completely finished.

        for ( i = startTime; i < (startTime + pipTime); i = i + frameTime ) {
                d = (i - startTime) / pipTime;

After that we pick a color based on what combo level we're at, using d in all of them to make them fade to black over the text's lifetime.

        if (dmsp_combo == 1) { 
                color_x = 1 - d;                // red
        } else if (dmsp_combo == 2) { 
                color_x = 1 - d; 
                color_y = 0.5 * ( 1 - d );      // orange
                comboType = "Combo!";
        } else

The meat of the function comes at the end. The origin in space for the text is also changed using d, so that it floats upward over time.

        org_z = origin_z + d * 96;
        sys.drawText( score, org, 0.5 * (1 - d), color, 1, 0 );
        if (dmsp_combo > 1) {
                org_z = origin_z + d * 96 + 12;
                sys.drawText( comboType, org, 0.25 * ( 1 - d ), color, 1, 0 );

sys.drawText() is the function that puts the text on screen. It's usually just for placeholder stuff in development, to do something like put "Big cutscene here where the player gets his next mission" on the screen before the cutscene's been made, but it'll work well enough for our purposes. We print twice, once for the score and then again slightly higher with the comboType string we've picked.

After all that, we add the final value to the player's score and show him in the console.

        dmsp_score = dmsp_score + score;        // The only way to show the player his score is to update it in the
        sys.println( dmsp_score );              // console since there's no way to display it on the player hud

At this point our neverending train of functions is complete. If you'd like to try it out for yourself, just put dmsp_v1.pk4 in your q4base folder, set 'g_skill' to taste, and type 'map dmsp/q4dm4' at the console.

It's not perfect, of course. It's lacking the polish necessary to make it feel finished and professional, but the limitations of what we can and can't do in script eventually make themselves known. It is, however, pretty remarkable that we're able to do as much as we can.

The following are the known issues (all of them easily fixable if this were implemented in code, including the player spawnclass fix that required a custom version of the map):

MakeAMod-DMSP (last edited 2005-11-09 17:47:31 by MattBreit)