 |
 |
Adding Homing Missiles to Quake III Arena

Posted by icarobr ยท Sep 15, 2026 05:00am

 |

The homing missile is the mod most people write first, and there are good reasons for that. It touches the entity list, the think scheduler, a console command and the trace system, and it does all of it in about a hundred lines spread across four files. You do not need to understand any of those systems yet to get it working.
Before you start you need the Quake III Arena game source, a toolchain that builds either the QVM or a native game library, and a copy of the game to test against. Every file mentioned below is in code/game.
Adding the flag
Open g_local.h and find the clientPersistant_t struct. In English: when a player is killed and then placed back in the level, the variables in this struct keep their values. That is what you want here, because a player who has switched homing on should still have it on after respawning. Add one line after teamInfo:
qboolean teamInfo;
qboolean homing_status;
Two functions Q3 does not have
Quake II shipped a function called findradius, which hands back the entities within a given distance of a point. Q3 is missing it, so you will have to add it yourself. Load up g_utils.c and add the following code to the end:
gentity_t *findradius( gentity_t *from, vec3_t org, float rad )
{
vec3_t eorg;
int j;
if (!from)
from = g_entities;
else
from++;
for ( ; from < &g_entities[level.num_entities]; from++)
{
if (!from->inuse)
continue;
for (j = 0; j < 3; j++)
eorg[j] = org[j] - (from->r.currentOrigin[j] +
(from->r.mins[j] + from->r.maxs[j]) * 0.5);
if (VectorLength(eorg) > rad)
continue;
return from;
}
return NULL;
}
The function returns one entity per call and picks up where it left off, so you keep calling it until it returns NULL. Also add this next bit of code after the findradius function:
qboolean visible( gentity_t *ent1, gentity_t *ent2 )
{
trace_t trace;
trap_Trace(&trace, ent1->s.pos.trBase, NULL, NULL,
ent2->s.pos.trBase, ent1->s.number, MASK_SHOT);
if (trace.contents & CONTENTS_SOLID)
return qfalse;
return qtrue;
}
It draws a line between the two entities. If that line runs into a wall, the function returns false, and otherwise it returns true. Now add the two declarations to the bottom of g_local.h, so that the rest of the program is allowed to call them:
qboolean visible( gentity_t *ent1, gentity_t *ent2 );
gentity_t *findradius( gentity_t *from, vec3_t org, float rad );
The console command
Open g_cmds.c and insert the following a line or so above the ClientCommand function:
void Cmd_SetHoming_f( gentity_t *ent )
{
if (ent->client->pers.homing_status == 1)
{
trap_SendServerCommand( ent - g_entities,
va("print \"Homing Missiles are off.\n\"") );
ent->client->pers.homing_status = 0;
}
else
{
trap_SendServerCommand( ent - g_entities,
va("print \"Homing Missiles are on.\n\"") );
ent->client->pers.homing_status = 1;
}
}
This checks whether homing missiles are currently switched on. If they are, it turns them off and says so on the player's console, and if they are not, it does the opposite. Now find the line inside ClientCommand that dispatches Cmd_Start_f, and directly after it insert:
else if (Q_stricmp (cmd, "homing") == 0)
Cmd_SetHoming_f (ent);
This compares what the player has typed against the word between the quotation marks, and calls Cmd_SetHoming_f when the two match. The first part of the mod is now written.
The missile think function
Open g_missile.c and add the following below the #define MISSILE_PRESTEP_TIME 50 statement. This is the missile's think function, and it is where the work happens:
void G_HomingMissile( gentity_t *ent )
{
gentity_t *target = NULL;
gentity_t *rad = NULL;
vec3_t dir, dir2, raddir, start;
while ((rad = findradius(rad, ent->r.currentOrigin, 1000)) != NULL)
{
if (!rad->client)
continue;
if (rad == ent->parent)
continue;
if (rad->health <= 0)
continue;
if (rad->client->sess.sessionTeam == TEAM_SPECTATOR)
continue;
if ((g_gametype.integer == GT_TEAM || g_gametype.integer == GT_CTF)
&& rad->client->sess.sessionTeam
== ent->parent->client->sess.sessionTeam)
continue;
if (!visible(ent, rad))
continue;
VectorSubtract(rad->r.currentOrigin, ent->r.currentOrigin, raddir);
raddir[2] += 16;
if ((target == NULL) || (VectorLength(raddir) < VectorLength(dir)))
{
target = rad;
VectorCopy(raddir, dir);
}
}
if (target != NULL)
{
VectorCopy(ent->r.currentOrigin, start);
VectorCopy(ent->r.currentAngles, dir2);
VectorNormalize(dir);
VectorScale(dir, 0.2, dir);
VectorAdd(dir, dir2, dir);
VectorNormalize(dir);
VectorCopy(start, ent->s.pos.trBase);
VectorScale(dir, 400, ent->s.pos.trDelta);
SnapVector(ent->s.pos.trDelta);
VectorCopy(start, ent->r.currentOrigin);
VectorCopy(dir, ent->r.currentAngles);
}
ent->nextthink = level.time + 100;
}
The while loop calls the findradius function you added earlier, and the code between the braces runs once for every entity within 1,000 units of the missile. Each of the tests inside it throws out a target you do not want. The first asks whether the entity is a player at all; the second asks whether it is the player who fired the rocket; the third asks whether that player is dead; the fourth asks whether it is a spectator. The fifth applies only in team games and asks whether the target is on the shooter's own team, and the last asks whether the target can actually be seen. Whatever survives all six is measured, and the nearest one is kept.
If a target was found, the block underneath steers the missile, and it does so with a series of vector manipulations. It takes the direction the missile is already travelling, adds a fifth of the direction to the target, normalises the result, and sends the missile down it at 400 units per second. The 0.2 in the VectorScale call is the turn rate, and a larger number turns the missile harder. Finally the think time is set to 100, so the search runs again a tenth of a second later.
One correction against the listing this is taken from. The team test has to compare the target against the player who fired, which is ent->parent. The original compares it against rad->parent, and because a client entity has no parent, that reads a null pointer the first time two players on the same team meet in a team game.
Firing the right rocket
What you must do now is modify the fire_rocket function, which is in the same file. It has to check the flag and set the correct think function, damage and velocity:
if (self->client->pers.homing_status == 1)
{
bolt->nextthink = level.time + 60;
bolt->think = G_HomingMissile;
bolt->damage = 75;
bolt->splashDamage = 100;
bolt->splashRadius = 90;
}
else
{
bolt->nextthink = level.time + 15000;
bolt->think = G_ExplodeMissile;
bolt->damage = 100;
bolt->splashDamage = 100;
bolt->splashRadius = 120;
}
A homing rocket does 75 points of direct damage instead of 100, and it carries a smaller splash radius as well. It is going to hit far more often than an ordinary rocket, so the numbers come down to account for that.
Tuning it
Build the game code, load a map and type homing at the console. Four numbers decide how the missile behaves: the 1,000 unit search radius, the 0.2 turn rate, the 400 unit speed, and the 100 millisecond think interval. A slow missile with a high turn rate will follow a player around a corner but can be outrun in a straight line. A fast one with a low turn rate is simple to dodge in the open and very hard to escape in a corridor. Change one number at a time, and play a round after each change.
|
|
 |
 |
|
 |
 |
 |
| No blog post has a picture yet. |
|
 |
 |
 |
 |
IRC: #func_arena @ irc.libera.chat
Discord: Map-Center
41 fraggers right now. Be nice to others.
|
|
 |
 |
|