Codementor Events

Adding objectives to Quake 3-based games

Published Jan 02, 2022

Foreword

This is an early concept which I have implemented for the prototype of a singleplayer standalone game, which is based on the Spearmint game engine and mint-arena (A fork of ioQuake3). There are most likely bugs, or other issues. If you encounter a problem, feel free to let me know. Once I push my changes to the fork on my github account, I will add a link to the repository here, as well.

About me

I work full-time as a software engineer maintaining and developing applications for scientific research. In my spare time, I reverse engineer data from (primarily) older video games for fun, and design / maintain the runtime pipeline for MEK Engine with a close friend of mine who handles implementing the gameplay.

The problem

A story-based singleplayer game should, ideally, have a means to:

  1. Let the player know what they should be doing
  2. Keep track of the players progression through a level

While it is certainly possible to do this without making changes to the existing code that drove Quake 3, it can become something of a headache without actual support in the engine / game. To remedy this, I wanted to prototype a very basic system to keep track of player progression.

The solution

For my initial prototype, I chose to implement objectives as target entities. Target entities are entities which send and/or receive events, much like a trigger. Unlike triggers, targets cannot be "touched" directly by entities in the game. They also tend to be more specialized than triggers, and often perform specific actions. Examples of targets used in Quake 3 include:

  • target_delay
  • target_relay
  • target_speaker
  • target_print
  • ... And more!

I chose this approach because I felt that it allows for greater diversity in how objectives can be used in levels, when compared to a trigger-based approach.

For this basic prototype, changes need to be made to the following files in the mint-arena codebase:

  • game/g_target.c
  • game/g_spawn.c
  • cgame/cg_servercmds.c
  • cgame/cg_draw.c
  • cgame/cg_local.h
  • cgame/cg_main.c
  • Makefile (optional)

Similar changes would be made for various other forks of Quake 3, with some minor differences.

Modifying Makefile (optional)

If you wish to maintain the ability to compile the unaltered Q3 / Q3TA VM's, you will want to make sure that you contain your changes inside of conditional compilation (#ifdef/#endif) blocks. You should pick a relevant name for the macro that you use -- I have chosen the name IDA_SOURCE (IDA is the abbreviation for the working name of my project) for all conditional blocks. To compile your changes using the makefile which comes with mint-area, you will want to locate the line which states:

BASEGAME_CFLAGS+=-DMODDIR=\"$(BASEGAME)\" -DBASETA=\"$(MISSIONPACK)\"

This is currently located at or around line 120. Below it, you should append the macro definition for the name you chose. For example:

BASEGAME_CFLAGS+=-DIDA_SOURCE

You may also want to do the same thing for MISSIONPACK_CFLAGS, as well.

Modifying game/g_spawn.c

First, we need to add a forward declaration for the spawn function. This function initializes the required callbacks and data for a specific type of entity, and begins with SP_ by convention. Locate the existing forward declarations inside of game/g_spawn.c, and add the following lines:

#ifdef IDA_SOURCE
void SP_target_objective( gentity_t *self);
#endif

Just below the forward declarations, you should see spawn_t spawns[]. You will need to add a new entry to this array:

spawn_t spawns[] = {
// ...
#ifdef IDA_SOURCE
  { "target_objective", SP_target_objective },
#endif
// ...
};

Modifying game/g_target.c

Open game/g_target.c. At the end of the file, add:

#define OBJECTIVE_ACTIVE	(1 << 0)
#define OBJECTIVE_COMPLETE	(1 << 1)

void Use_target_objective( gentity_t *self, gentity_t *other, gentity_t *activator)
{
  if(self->health == OBJECTIVE_ACTIVE) {
    
    	// If the objective is already active,
        // triggering it's Use callback will mark
        // that objective as completed.
        // In the future, this should be expanded to support
        // both success and failure.
    	self->health = OBJECTIVE_COMPLETE;
        
        // Reset the objective Cvar's, in case there
        // are no more objectives.
        trap_Cvar_SetValue("cg_hasObjective", 0);
        trap_Cvar_Set("cg_currentObjective", "");
        
        // Activate anything listening for this objective
        // to be completed.
        G_UseTargets(self, activator);
        
    } else if(self->health != OBJECTIVE_COMPLETE) {
    
    	// If an objective is neither active nor complete,
        // it will be made the current active objective.
    	self->health = OBJECTIVE_ACTIVE;
        trap_Cvar_SetValue("cg_hasObjective", 1);
        trap_Cvar_Set("cg_currentObjective", self->message);
        
        // Send a command to the client to display the
        // newly activated objective.
        trap_SendServerCommand(-1, "oi");
    }
}

/*QUAKED target_objective (0 0.5 0) (-8 -8 -8) (8 8 8)
Denotes an objective for the player to complete
-------- KEYS --------
message		Text to display for the objective
-------- SPAWNFLAGS --------
ACTIVE		If set, objective will be marked active at spawn
*/
void SP_target_objective( gentity_t *self)
{
  // Set up the callback for interaction with other entities
  self->use = Use_target_objective;
    
    // Health used to store the current status of the objective
    // It can't take damage since it can't be directly interacted
    // with physically.
    self->health = (self->spawnflags & OBJECTIVE_ACTIVE);
    if(self->health) {
    
    	// If the objective is active, set the appropriate
        // cvar's. We need these to display the objective
        // text to the player.
    	trap_Cvar_SetValue("cg_hasObjective", 1);
        trap_Cvar_Set("cg_currentObjective", 1);
    }
    
    // Link the entity into the current level.
    trap_LinkEntity(self);
}

For the purposes of prototyping, I used the existing message member of the gentity_t structure. As far as my quick grep through the source code has shown me, this member is only used by target_print entities -- but I may make this a field of its own in the future.

Modifying cgame/cg_local.h and cgame/cg_main.c

Since there are only a handful of changes to make in these two files, and because they are mostly related, I have chosen to include them together in one section for brevity.

Inside of cgame/cg_local.h, locate the existing cvar declarations (You can search for the type vmCvar_t). In this block, we will add the two new Cvar's needed for our simple objective system:

#ifdef IDA_SOURCE
extern vmCvar_t cg_hasObjective[MAX_SPLITVIEW];
extern vmCvar_t cg_currentObjective[MAX_SPLITVIEW];
#endif

While you're here, locate the definition for localPlayer_t, and add the following members:

typedef struct {
// ...
#ifdef IDA_SOURCE
  int objectiveTime;
    float objectiveCharScale;
    int objectiveY;
#endif
// ...
} localPlayer_t;

Finally, locate the function declaration for `CG_GlobalCenterPrint`. Below it, add the following:

```c
#ifdef IDA_SOURCE
void CG_PrintObjective( int localPlayerNum, const char *str, int y, float charScale);
#endif

Now open cgame/cg_main.c, and search for vmCvar_t again. You will add the same declaration, but forgo the extern keyword.

#ifdef IDA_SOURCE
vmCvar_t cg_hasObjective[MAX_SPLITVIEW];
vmCvar_t cg_currentObjective[MAX_SPLITVIEW];
#endif

Next, locate userCvarTable_t userCvarTable[], and add one new entry for each of the new Cvar's:

static userCvarTable_t userCvarTable[] = {
// ...
#ifdef IDA_SOURCE
  { cg_hasObjective, "cg_hasObjective", "0", CVAR_TEMP, RANGE_BOOL },
  { cg_currentObjective, "cg_currentObjective", "", CVAR_TEMP, RANGE_ALL },
#endif
// ...
};

Modifying cgame/cg_servercmds.c

Now we need to add support for a new command. This command will be responsible for displaying the objective text to the user. I have gone with (for the time being) a very simple implementation which just draws the current objective to the screen for a few moments.

Open cgame/cg_servercmds.c and locate the definition for CG_ServerCommand. Below the if-statement which handles the cp command, add:

#ifdef IDA_SOURCE
if( !strcmp( cmd, "oi" ) ) {
  Q_strncpyz( text, CG_Argv( start + 1 ), sizeof( text ) );
    if( strlen(text) > 1 && text[strlen(text) - 1] == '\n' ) {
    	text[strlen(text) - 1] = '\0';
   	}
    CG_ReplaceCharacter( text, '\n', ' ' );
    CG_Printf( "[skipnotify]%s\n", text );
    for( i = 0; i < CG_MaxSplitView(); i++ ) {
    
    	if( localPlayerBits == -1 || ( localPlayerBits & (1 << i ) ) ) {
        	CG_PrintObjective( i, CG_Argv( start + 1 ), SCREEN_HEIGHT * 0.10, 0.25 );
        }
   	}
    return;
}
#endif

Modifying cgame/cg_draw.c

Lastly, we need to add the function which actually handles displaying our objective text to the screen. Open cgame/cg_draw.c, and locate the definition for CG_GlobalCenterPrint. Below it, add the following:

void CG_PrintObjective( int localPlayerNum, const char *str, int y, float charScale)
{
  localPlayer_t *player;
    player = &cg.localPlayers[localPlayerNum];
    if( cg.numViewports != 1) {
    
    	charScale *= cg_splitviewTextScale.value;
    } else {
    
    	charScale *= cg_hudTextScale.value;
    }
    
    player->objectiveTime = cg.time;
    player->objectiveY = y;
    player->objectiveCharScale = charScale;
}

static void CG_DrawObjectiveString( void )
{
  float *color;
    if( !cg.cur_lc->objectiveTime) return;
    color = CG_FadeColor( cg.cur_lc->objectiveTime, 5000);
    if( !color ) return;
    CG_SetScreenPlacement(PLACE_CENTER, PLACE_TOP);
    CG_DrawStringAutoWrap(SCREEN_WIDTH / 2, cg.cur_lc->objectiveY, cg_currentObjective->string, UI_CENTER|UI_VA_CENTER|UI_DROPSHADOW|UI_GIANTFONT|UI_NOSCALE, cg.cur_LC->objectiveCharScale, 0, 0, cgs.screenFakeWidth - 64);
}

Finally, we need to call CG_DrawObjectiveString so that the active objective gets drawn to the users screen. Locate CG_Draw2D, and add a call to this function:

void CG_Draw2D( stereoFrame_t stereoFrame, qboolean *voiceMenuOpen)
{
  // ...
    #ifdef IDA_SOURCE
    CG_DrawObjectiveString();
    #endif
    // ...
}

At this point you should be able to compile the code without errors.

Next steps

You will probably want to add the new entity definition to the file used by your map editor of choice -- I will not be covering that here, as it is a fairly simple process. I use TrenchBroom as my map editor, so I simply made a copy of the builtin entities.ent file used for Quake 3 and added a new point entity definition with the appropriate name and information.

You should still be able to add the new entity to a map to test it out by setting the key / value pairs in the editor.

Usage

This entity isn't very useful on it's own. It is intended to be paired with other point and brush entities to handle logic within the level. The map "Edge of Forever," made by Simon "Sock" O'Callaghan is a great example of what can be done using these entities.

For my current sandbox map, I have set up a circuit for three simple objectives:

  1. Pickup an item
  2. Interact with a button
  3. Interact with the same button, again.

The logic is controlled by the following circuit:

objectives_example.png

The checkered boxes are buttons. The two farthest from the viewport are set such that they can be activated by the plasma shooter, while the first one can only be activated by picking up the required item thereby enforcing that the player must pick up the item before interacting with the button in the map.

Each button is connected to the relays on either side. One set of relays activates the objectives, causing them to be marked as completed; the other set activates a timer, which serves to keep the button activated once the objective has been completed. I believe that the set of relays on the left is not necessary, but I have kept them because it allows me to have clear and descriptive targetname keys. Lastly, the relay closest to the viewport is connected to a one-shot trigger which encompasses the player spawnpoint and activates the first objective (This was for testing purposes -- an objective can be marked as active at start by setting the spawnflags).

The objectives themselves are also connected to one another. This is what allows the completion of one objective to mark the subsequent objective as "active."

Upon completion of the final objective, a signal is sent to another entity which ends the level.

Challenges

I had to go through a few failed iterations at implementing this before I got something that I was roughly happy with for this prototype. In fact, part of my previous attempts are still around in the form of the Cvar's that were added as part of this write-up. These can be done away with by modifying the oi command to accept arguments, and store those arguments within the localPlayer_t structure for use by CG_DrawObjective().

Room for improvement

I am certain that there are much better ways to go about implementing this, and I plan on refactoring as I move forward with development, as this doesn't quite remedy some of the more headache-y aspects that need to be taken care of in the map building process that I was hoping to fix. I only started this project recently, and I am in the process of prototyping all of the core systems. I would like to come up with a better way to determine the order of objective completion that does not require a unique logical circuit to be setup within the map.

For such a system, I plan on adding a new field to the gentity_t structure to link entities together by either name, or ID. Prior to the start of each level, these entities would be linked together into a linked list, akin to how teaming works for other entities.

Another shortcoming in this system as it exists now is that there is not currently a way to "fail" an objective -- they are only active, inactive, or completed. I have some ideas on how I can implement different statuses for objectives, but have not yet gotten around to testing them at this point in time because they are not yet necessary at this point in the project.

Key learnings

I feel that I have learned, above all, what changes can be made to improve and extend the functionality of objectives. However, it was also a learning experience with regards to how the existing entities can be used to implement both simple and complex logic, as well as how the client and server communicate to eachother through the command system.

Final thoughts and next steps

I would love to spend more time fleshing out this system right now, but there is quite a bit that I need to work on for this project -- so for the time being I am focusing mostly on prototyping various things as I deem them necessary. Eventually I will revisit objectives, but that will be long after I get other more critical components implemented.

Discover and read more posts from Garrett Bates
get started
post commentsBe the first to share your opinion
Show more replies