Skip to content

qw-ctf/rtx

Folders and files

NameName
Last commit message
Last commit date

Latest commit

 

History

151 Commits
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 

Repository files navigation

rtx

A native-Rust QuakeWorld game module with Unreal Tournament-style movement layered on top of faithful QuakeWorld gameplay.

It reimplements the original QuakeWorld qwprogs.dat logic (the qw-qc QuakeC) as a cdylib that the server loads as a pr2 native game module (GAME_API_VERSION 16) in place of qwprogs.dat — the same host ABI KTX uses. The gameplay mirrors stock QuakeWorld; the movement additions are the reason this exists.

Unreal Tournament 4 influences

Mechanics inspired by Unreal Tournament 4, layered on top of QuakeWorld. Each is a cvar; all are server-authoritative. Set any to 0 to disable.

Movement — air game & wall dodge

cvar default what it does
rtx_doublejump 1 A second jump in mid-air, once per air travel. Gated so a jump tapped just before landing can't steal it — QuakeWorld bunny hopping is preserved.
rtx_walljump 1 Kick off a wall you jump into: your velocity is mirrored across the wall and you launch out-and-up — UT's wall dodge. Repeatable; geometry-limited.
rtx_elevator_jump 2 Jumping off a rising lift folds the lift's speed into your jump (lift_speed × cvar + base), so you launch higher the faster it moves. It's a multiplier: 0 off, 1 = the lift's true speed, 2+ for more air.

Combat — the shock combo

cvar default what it does
rtx_shootable_grenades 1 Shooting a live grenade in flight detonates it. This is QuakeWorld's take on UT's Shock Rifle combo — where the slow energy ball from alt-fire is set off by the fast hitscan beam. Here the grenade is the slow projectile, and any shot that damages it triggers the blast.

A note on prediction

These run server-side (in PlayerPreThink, before the engine's player move). Stock QuakeWorld client prediction doesn't know the rules, so each triggers a one-frame correction "pop" — the jump itself is authoritative and always happens, but predicting it smoothly would need a CSQC client mirroring the same pmove logic.

Grappling hook

Not a UT mechanic but a classic QuakeWorld one — the Wedge (Steve Bond) grappling hook, ported from purectf (minus its CTF team logic), with KTX's quieter sound: a one-shot throw/impact instead of the old looping chain rattle.

cvar default what it does
rtx_grapple 1 Every player spawns holding a grappling hook (no ammo). Fire to throw it; the hook sticks to walls — or players, who get dragged and lightly damaged — and reels you toward it while you hold fire (a moving anchor carries you along). Select it with impulse 22 or by double-tapping impulse 1 (toggles axe ↔ hook). 0 disables it.
rtx_weapons axe hook sg ssg ng sng gl rl lg The weapons the server runs with, as a space-separated token list (axe, hook, sg, ssg, ng, sng, gl, rl, lg). A weapon absent from the list is removed everywhere: its map pickup (weapon_*) never spawns and it's stripped from every spawn kit, so it can't be picked up or fired (bots included). Set e.g. rtx_weapons "axe sg rl" for a rockets-first server. Unknown tokens (e.g. coil) are ignored; hook composes with rtx_grapple (both must allow it). Map pickups update on the next map load, spawn kits on the next respawn.

Throw and reel speeds are tunable via rtx_hook_speed (default 1.25) and rtx_hook_pull (default 1.0) — purectf's hookspeed/hookpull, each a multiplier on its base speed.

It reels server-side too, so it shows the same one-frame prediction pop as the movement features above. The hook's models and viewmodel must be in the gamedir: progs/{star,bit,v_star}.mdl. Bots use it to navigate — see Bots, where the navmesh grows hook links a bot swings across.

Game modes

Gameplay has two orthogonal axes. The game mode (rtx_mode) is the ruleset; the match composition (rtx_match) is how the match is organized. They're independent: deathmatch and CTF each run under any composition (open free-for-all, or a locked 2on2/4on4/…), while Rocket Arena and midair are inherently duel rulesets. Modes are pluggable: each one overrides only the policy it changes — ruleset (round/damage/respawn rules), spawns, loadout, and the bot brain — behind a small GameMode trait (src/mode/). Deathmatch is just the baseline mode, so adding a mode doesn't touch the generic gameplay or bot code.

cvar default what it does
rtx_mode dm Ruleset: dm = deathmatch (stock behaviour). ra = Rocket Arena. midair = airborne-only rocket DM. ctf = Capture the Flag. race = timed KTX race routes (bhop/speed-jump harness).
rtx_match (auto) Composition, orthogonal to the mode. Empty = the mode's natural default (dm → open FFA, ctf → open 2-team pickup, midair → 1on1 duel). ffa = open free-for-all. A team format (1on1/duel, 2on2, 2on2on2, any NonM…) = a locked N×M match. Ignored by ra; CTF clamps it to 2 teams.
rtx_ra_countdown 3 Rocket Arena: seconds of spawn-protected countdown before "FIGHT".
rtx_midair_minheight 40 Midair: minimum height (units) above the floor for a victim to count as airborne.
rtx_midair_kb_ground / rtx_midair_kb_air 6 / 3 Midair: rocket knockback multipliers for grounded vs airborne victims (ground is stronger, to launch players up).
rtx_match_countdown 3 Team match (rtx_match format) / CTF: seconds of spawn-protected countdown after the match-start map reload before "FIGHT".
rtx_capturelimit 8 CTF: captures a team needs to win (0 = no limit, ends on timelimit).
rtx_race_route 0 Race: which of the current map's routes to run (0-based, clamped). Read live — changing it moves everyone to the new route's start.
rtx_runes 0 CTF runes: 0 = on (Haste adds move speed), 1 = off, 2 = on without the speed boost.
rtx_ctf_tossflag / rtx_ctf_tossrune 0 / 0 CTF: allow tossing your carried flag (impulse 26) / held rune (impulse 24).
rtx_dropitems 0 Any mode: let players hand items to teammates — a capped ammo backpack (impulse 20; up to 20 shells / 20 nails / 10 rockets / 20 cells, deducted from you) and your current weapon (impulse 21; drops it as a pickup and switches you to your next-best gun — the axe, single shotgun, and grapple stay). Ported from purectf.

ra — Rocket Arena. Round-based 1v1 duels following the classic arena loop (ported from the Frogbot-Rocket-Arena QuakeC, minus its clan-arena team machinery). Two players fight in the arena at a time; everyone else waits in the audience (the info_player_deathmatch spots — the stands) and roams there. Each round the fighters spawn with a full loadout (all weapons, full ammo, red armour) inside the arena (the info_teleport_destination spots), are invulnerable through a short countdown, then fight. During the countdown you can move to position but can't fire yet (a screen blink if you try); at "FIGHT" weapons go hot. Getting killed drops you to the audience; the winner stays — kept in place and topped back up to full — and faces the next challenger pulled from the front of the audience queue (losers go to the back), so the arena is always a fresh duel. On a plain deathmatch map with no teleport destinations it falls back to DM spawns so the mode still runs. Bots play it fully — see below.

midair — airborne-only rocket DM (modeled on KTX's midair). Everyone spawns with a rocket launcher (+ axe), 255 rockets, red armour and 250 health, at normal gravity. A direct rocket on an airborne victim is an instant kill; on a grounded victim it deals no damage but delivers a hard knockback that launches them skyward — so you rocket someone up, then airshot them out of the air. Non-rocket damage is harmless, and your own rockets never hurt you but still fling you (free rocket-jumps). Kills score by how high the victim was (vertical distance from where you fired): bronze/silver/gold/platinum for +1/+2/+4/+8 at >0/256/512/1024 units, announced as an airshot line. By default midair runs as a 1on1 duel (a structured match — see below); set rtx_match ffa for a continuous free-for-all, or a team format like 2on2. Bots play it — they hunt the nearest enemy, launch grounded targets and airshot them.

Match composition — rtx_match. Orthogonal to the mode, this picks how the match is organized. Empty (the default) uses the mode's natural composition; ffa forces open free-for-all; a team format (1on1/duel, 2on2, 2on2on2, any NonM…) picks N teams of size M (2on2on2 = three teams of two) and applies to deathmatch and CTF alike. A team composition gives each team a colour (red/blue/green/…) and its info_player_teamN spawns (DM spawns as fallback), turns on friendly fire (teamplay), and — for a plain team deathmatch — has teams frag to the fraglimit. The lifecycle is warmup → start → live → results: in warmup everyone plays and is auto-balanced onto the smallest team; typing start in the console reloads the map (fresh entities) and runs a countdown, locking the roster; play then runs to the limit and returns to warmup. A structured format (teams × size) locks exactly that many seats at start (humans before bots, rebalancing the sides); anyone over the count — and any late joiner — is benched as a harmless spectator (axe only, damage refused, roaming the stands) until the next warmup. Bots fill exactly the empty seats and freeze once the match is live. Players who drop and reconnect are reattached to their team. Bots target only the other side.

ctf — Capture the Flag (modeled on purectf), built on the composition layer above. Two teams (red/blue) each own a flag at a base (item_flag_team1/item_flag_team2). Grab the enemy flag, carry it to your base while your flag is home, and it's a capture (+1 to your team, +15 frags to the carrier). Touch your own flag where it lies to return it (+1); a dropped flag also auto-returns after 40 s, and a killed carrier drops it where they fell. Teams win at rtx_capturelimit. It uses the same match lifecycle as team DM — warmup → start (map reload + countdown) → live → results — with friendly fire via teamplay, the grapple handed out for movement, and CTF bots that split into roles: most attack (grab the enemy flag and run it home), while a minority (about a third, at least one) defend — holding the base, intercepting attackers that close on it, chasing down whoever steals the flag, and re-touching a dropped flag to return it. A carrier always runs home regardless of role. Full purectf scoring is in: capture +15, teammates +10, plus the frag-carrier, carrier-protect, flag-defense, and return/frag assist bonuses. The four runes (Resistance, Strength, Haste, Regeneration — rtx_runes) spawn at DM points, one per player, dropped on death; the flag and rune can be tossed (rtx_ctf_tossflag / rtx_ctf_tossrune, impulse 26 / 24). CTF requires the flag model progs/flag.mdl (and the rune models progs/end1-4.mdl) in the gamedir.

race — timed KTX race routes. Run a course from its start pad through its checkpoints to the finish, timed per runner — and, first and foremost, a sanity harness for the bot movement system: most race routes are unfinishable without bunnyhop-accumulated speed, so a bot finishing (or timing out on a named leg) is a live regression check on the speed-jump / bhop machinery. Routes load from two sources, exactly as KTX does: race_route_start / race_route_marker entities embedded in the map (race11–20, race32c), and external race/routes/{mapname}.route command files (race1–10, ztricks, ztricks2 — copy KTX's examples into the gamedir). Everyone spawns on the active route's start pad (axe only, KTX raceWeaponNo); the clock starts when the runner leaves the start box, checkpoints must be touched in order, the finish broadcasts the time, and race_route_timeout without finishing resets the run. Because race maps are authored for stock movement + bunnyhop only, the mode reports stock_movement_only: double jump, wall jump, elevator jump, grapple and rocket jumps are switched off — both as live mechanics and as navmesh links — so bots must reach everything by bhop and speed jumps, and a failed pathfind is honest. At map load the mode prints a routability report, one PASS/FAIL line per route, answering whether every leg is traversable with race-legal movement (needs rtx_bot_bhop 1 for the speed-jump links; it warns if that's off). Switch routes live with rtx_race_route. Known gap: routes that gain height via a slope launch (hitting an angled ramp at tremendous bhop speed, the engine redirecting horizontal velocity upward) or that chain speed carried between jumps across a long traverse are not yet modelled in the navmesh — the routability report names the exact legs — so those FAIL today; that's the next feature, best built and tuned against a live server.

Bots

Navmesh-driven bots that need no per-map waypoint files — the navmesh is generated from the map's BSP clip hull when the map loads. Bots are real client slots: the engine runs their input through the same player-move code as humans, so gravity, stepping, and jumps come for free.

cvar default what it does
rtx_bot_count 0 How many bots to keep on the server. The population is reconciled to this count (spawning/removing as needed), leaving room for humans. Bots only spawn once the map's navmesh is built.
rtx_bot_alone 0 Keep bots on the server even when no humans are connected (0 = bots leave an empty server; 1 = they stay and play it out).
rtx_bot_skill 3 Bot skill (0–7): tightens aim, speeds how fast a bot turns/tracks, widens its view cone, and shortens its reaction time.
rtx_bot_pacifist 0 Make bots not fight in any mode — they just trail the nearest human around the map (for experimenting). 0 = bots play the mode normally.
rtx_bot_greed 1 Let a fighting bot break off to grab a compelling nearby pickup — a powerup (quad/pent), a weapon it lacks, or a big health/armor swing — while it can't see its target, instead of only chasing the enemy. 0 = bots only pursue items when the mode leaves the brain idle.
rtx_bot_fov 120 View cone (full angle, degrees) within which a bot can see a target; widened with skill. A nominated enemy outside the cone (or behind cover) isn't engaged until seen, heard firing, or felt as damage. 0 = 360° sight (the old always-aware behavior).
rtx_bot_reaction 0.4 Base reaction delay (seconds) a target must stay in sight before the bot acts on it; shortened with skill (floored so even skill 7 isn't instant). 0 = react instantly.
rtx_bot_teamwork 1 In team compositions / CTF, coordinate: spread targets across the enemy team instead of dogpiling the nearest, don't race teammates to the same item, escort a friendly flag carrier, and stagger defender posts. 0 = each bot decides independently. No effect in open free-for-all.

In open play each bot hunts and frags the nearest player — everyone's an enemy, so a bots-only server plays itself — pathing to them and, once in sight, aiming and shooting via the shared combat layer (retreating when hurt, grabbing items it passes over). Set rtx_bot_pacifist 1 — in any mode — and they stop fighting and just tail the nearest human instead. With nothing to chase and no human to follow, a bot roams to a random reachable spot rather than standing on its spawn.

A bot doesn't fight a target it hasn't actually perceived: an enemy has to be seen (inside the bot's view cone rtx_bot_fov, with line of sight, held for a rtx_bot_reaction beat), heard firing nearby, or felt as incoming damage before the bot engages it — so instead of psychically beelining an unseen enemy it patrols and collects until real contact, and when it loses sight it hunts the last spot it saw them for a few seconds before giving up rather than tracking them through walls. Aim is loosest on first glimpse and while moving, tightening as it holds a target in view. In team and CTF modes (rtx_bot_teamwork) a team spreads its fire across the enemy side instead of all piling on the nearest, doesn't race itself to the same pickup, escorts a teammate carrying the flag, and staggers its defenders around the base.

Navigation is loop-resistant: when a bot fails to traverse a link (a jump it keeps undershooting, a spot it wedges on, a leg that makes no headway toward the goal) that link gets a temporary per-bot cost penalty, so its next path routes around the trouble instead of the planner handing back the identical dead route to retry until a timeout fires. A dash of per-bot path jitter also keeps two bots from treading an identical line.

Bots value pickups the way ktx does: each item's desire is the marginal effective-HP (health, armor), firepower (weapons, ammo), or flat dominance (powerups) it would give this bot right now, weighted by how soon it can reach and collect it (desire × (lookahead − t) / (t + 5)) — so they skip health at full, weapons they own, and ammo they're capped on, time an item's respawn, and prize the quad/pent above almost anything. When a bot reaches an item that hasn't respawned yet, it doesn't stand and twitch on the spot — it cruises a short walk around the spawn, panning the view to scan for enemies (which genuinely widens what it can see), and heads back to stand on the point just as the item returns. Dropped backpacks (a dead player's weapon + ammo, or a teammate's toss) are sought and collected the same way. With rtx_bot_greed on (the default), a bot in the thick of a fight will detour for the quad, a weapon it's missing, or a big health/armor pickup whenever its target slips out of sight — the enemy is just one more goal competing with the items. Otherwise (when a mode leaves the brain in charge) it pathfinds to the best reachable item pickup, or follows the nearest human (through doors, off ledges, across jumps, recovering after a missed jump). On open, roughly-straight stretches they bunnyhop (rtx_bot_bhop) — chaining jumps and air-strafing (sweeping the view while holding one strafe key) to exploit QuakeWorld's air-acceleration and build speed far past sv_maxspeed, weaving the heading toward the waypoint; they drop back to a normal gait for corners, ledges, combat, or the final approach to a goal. That speed unlocks speed jumps — the navmesh links gaps too wide for any normal or double jump, cleared by arriving at the takeoff with built-up bhop speed (a jump's reach = speed × airtime, and airtime is fixed, so faster = farther). Each such link's start is the runway itself, so a bot that takes it is guaranteed to run the whole accelerating approach before the leap — and it refuses to launch if it somehow reaches the edge too slow. These are the only way across a wide gap when the double jump is off. When rtx_doublejump is on, the navmesh also links the wider gaps and higher ledges a double jump reaches — the bot ground-jumps, then air-jumps near the apex to restack the arc and clear a gap a single jump can't (it also spends the air jump to recover an undershot ordinary jump). When rtx_grapple is on, the navmesh also grows hook links — edges a bot crosses with the grappling hook: it throws the hook at an anchor, reels to build speed, then releases mid-reel so the resulting velocity flings it along a parabola onto a ledge or across a gap a plain jump can't reach (a straight pull-up is just the degenerate case). Because the arc is deterministic, the links are found and verified when the map's navmesh is built by simulating the swing against the BSP, and A* prices them as travel time — so bots take a hook only when it beats the ground route. This measurably widens where bots can go on vertical/CTF maps. The mode can redirect the brain without touching it: in Rocket Arena bots fight — they path to the nearest enemy and, once they have line of sight, aim (leading the target for rockets), pick a weapon by range, strafe/retreat, and fire — and, when eliminated, roam the audience like everyone else. The combat layer (src/bot_combat.rs) is generic and reused by any mode that hands a bot an enemy. A bot's view lerps toward its target angle rather than snapping, so it turns naturally when spectated; both the turn/track speed and aim tightness scale with rtx_bot_skill (a low-skill bot visibly swings onto a target more slowly).

Bots also play the shootable-grenade game (above). Defensively they shoot down an incoming grenade — but only from outside its blast, weighed against their own health (the closer it is, the more health it takes to justify setting it off) — and a grenade too close to safely pop makes them run and hop clear instead of detonating it in their own face. Offensively they use splash weapons for position manipulation — if an enemy stands near lava, slime, a pit or a ledge, the bot sets off a blast so the outward knockback shoves them into the hazard, verifying the shove actually carries them across the edge before committing. It's a generic strategy — the blast point sits on the ground behind the enemy (away from the hazard) so the outward splash drives them in — with two deliveries: a rocket put straight onto that ground spot (no direct hit needed; a static point is easy to hit and works from any angle with a clear line to it), or a grenade lob→shoot combo when the blast must be arced over the enemy to reach it — aim a ballistic arc (solved from the launcher's fixed speed/loft against gravity), lob, switch to a hitscan gun, and detonate in flight. With no hazard the grenade combo becomes a plain airburst. All of it is safety-checked — never self-splash, never a teammate, never a shove the wrong way, never a lob into a wall.

They also throw indirect bank shots at enemies with no line of sight: a solver simulates a bouncing grenade against the map's collision hull (a real SV_RecursiveHullCheck trace, reflecting off surface normals with QuakeWorld's MOVETYPE_BOUNCE physics), searching launch angles for one whose ricochet path reaches the hidden enemy — then lobs it and lets the 2.5 s fuse detonate it around the corner (a launch-jitter robustness sweep rejects knife-edge angles the throw's spread would spoil). It's gated to stay honest and rare — a recently-seen, slow target and higher bot skill — with flag carriers worth a blind lob past the throttle.

Map rotation

Set rtx_maplist to a whitespace-separated list of maps and the server cycles through them in order each time a level ends (on timelimit/fraglimit, or when a team match finishes):

set rtx_maplist "dm2 dm3 dm4 dm6 aerowalk"

The next map is the one after the current map in the list (wrapping around; if the current map isn't listed, the rotation starts at the first entry). It takes precedence over a serverinfo nextmap. When a list is configured the end-of-level intermission scoreboard auto-advances after its pause instead of waiting for a player to press a button; with no list set, the stock behaviour is unchanged. Leave rtx_maplist empty (the default) to disable rotation.

Building

cargo build --release
cp target/release/librtx.so /path/to/server/qw/qwprogs.so   # or .dylib, .dll

The server needs pr2 / API 16 support. Prebuilt qwprogs.so / qwprogs.dylib artifacts are produced by the GitHub Actions build workflow.

License

Copyright © 2026 Daniel Svensson.

Licensed under the GNU Affero General Public License, version 3 or later (AGPL-3.0-or-later).

About

Pure slopathon

Resources

License

Stars

1 star

Watchers

1 watching

Forks

Releases

No releases published

Packages

 
 
 

Contributors