ozbot-re is the q2repro (Quake II re-release / KEX-era,
40 Hz) port of ozbot. It lives entirely inside the game DLL (gamex86_64.dll, x64) —
no engine changes — and keeps ozbot's core idea: the bot's code and design are authored entirely by
an AI agent (Anthropic's Claude), in an autonomous build → simulate → analyze → improve loop, and its
navigation is self-learned by playing, not hand-drawn.
What makes this port distinct is that it is hybrid. For a small number of trick traversals the
self-learned follower physically cannot reproduce — the q2dm1 Megahealth strafe-jump, the upper-Railgun
walkway, a chaingun-ledge descent — ozbot-re replays short human-recorded input clips ("playbooks")
stitched into the nav graph as first-class links. And the shipped bot runs on human-curated nav
snapshots — self-learned graphs matured through play and frozen per map (baselines/nav_shipped/,
with targeted human curation) — a layer ozbot deliberately forbids: it stays strictly cold-only,
because shipping a tuned nav would contradict its all-AI identity. So navigation here is
learned-by-playing, curated, and demonstration-augmented; the AI still writes every line of code,
and combat and goal selection remain fully self-tuned. That hybrid is the headline capability of the RE
rig, and it unlocked the first item the 10 Hz bot could never reach.
| ozbot (original) | ozbot-re (this repo) | |
|---|---|---|
| Engine | q2pro, 32-bit / x86 | q2repro (re-release), 64-bit / x64 (gamex86_64.dll, PE 0x8664) |
| Tick rate | 10 Hz | 40 Hz (10 Hz brain, 40 Hz body) |
| Hermeticity | — | every launch passes com_rerelease -1 (q2repro auto-detects Steam/GoG/OneDrive) |
| Nav baseline | cold-only — self-learned, no shipped/curated graph (a hard identity requirement) | ships a human-curated / matured nav per map (baselines/nav_shipped/), benched against a cold graph |
| Hand-authored content | none | human-recorded playbooks for a few trick moves |
| Fastsim exe | q2proded_fast.exe |
q2reproded.exe (built from q2repro) |
| Gamedir | engine/ozbot/ |
engine/ozbotre/ |
10 Hz brain, 40 Hz body. The vanilla game logic (weapons, animations, damage cadences) and the bot's
combat decision layer run on FRAMESYNC keyframes (every 4th frame = 10 Hz); physics, steering, and
movement execution run at the full 40 Hz tick. Per-tick bot dynamics are normalized with
BOT_TICK_RATIO / Bot_TickGain (see src/bot.h) — never leave a raw per-tick constant, it will be 4×
off at 40 Hz. sv_fps 40 is the standard; it is read once per InitGame, and without it (or on an
engine lacking variable-FPS) everything runs vanilla 10 Hz and is bit-identical to the pre-port DLL —
that byte-for-byte identity was the port's regression gate.
A new metrics epoch — do not compare to 10 Hz history. 40 Hz baseline (16 seeds × 90 s):
~418 pickups / 47% ITEM / 235 frags, versus the same build at 10 Hz: 488 / 53% / 157. The +50% kill
intensity is emergent and provably symmetric — fire rates are unchanged (tools/verify_timing.py
confirms all 8 weapon periods identical at 10 vs 40 Hz); frags-per-fire rises +67% because the smoother
40 Hz velocity tracks better between the 10 Hz aim samples. The lower ITEM% is death-interrupted attempts,
not worse navigation (giveups/pathfails flat, standing re-decides ~1%).
The live cross-map scoreboard (all 8 q2dm maps, regenerated by tools/benchmark.py) lives in
STATS.md; each snapshot runs the repro rig at a fixed seed against two nav
baselines — shipped (the real bot on its hand-seeded navs) and cold (the same build learning
from a scratch graph) — so rows isolate code changes and the gap shows what the manual nav curation
is worth per map. Add one with py tools/benchmark.py --note "<what changed>".
Some q2dm1 items sit behind maneuvers the learned follower can't execute (a precise strafe-jump onto a 120u ledge, a one-tile-wide walkway). Playbooks solve exactly those, by replaying a human's recorded inputs:
- Record —
record_inputs.baton the q2repro client logs the human's per-usercmd stream at full 40 Hz fidelity (bot_inputlog). Bot segments can be captured withbot_cmdlog 1. - Bake —
py tools/make_playbook.py <log.jsonl> --slot N --start T0 --end T1 --name mh_jump --out engine/ozbotre/playbooks/q2dm1.pbksegments the capture, resamples the usercmd stream to the 25 ms tick grid (OR-ing momentary buttons so brief jump presses survive), and emits per entry an anchor (origin/yaw/velocity preconditions + tolerances), the tick-indexed usercmd stream, the expected origin timeline, and the exit point. - Run —
bot_playback.cregisters each entry as aNAV_LINK_PLAYBOOKlink so A* routes through a recorded move like any other capability link (cost = duration). An approach controller drives the bot into the anchor tolerance, then does time-driven open-loop replay with a drift monitor against the recorded origin timeline; on divergence it aborts →Nav_PenalizeLink→ normal repath (aborted replays self-select out).bot_playbookis default on and inert without a.pbk.
The canonical q2dm1.pbk carries 6 entries: mh_jump, rl_walkway, cg_descend, ya_box_jump,
djump_rockets, bhop_rockets. Two hard-won executor lessons live in the bot_playback.c header:
pure position-following is hopeless (the cursor must be time-driven with bounded slip — a position
follower pins on the pre-launch tick and never jumps), and rail-matching needs a capped closed-loop yaw
bias over the open-loop stream.
The port ran as a phased campaign, each phase gated:
- R0 — engine. q2repro built x64 with
-Dvariable-fps=true; fastsim patch ported tick-rate-aware (injects onesv_fps-sized tick per loop iteration).com_rerelease -1required for hermeticity. - R1 — 10 Hz parity. x64 DLL through the game3 proxy: same-seed md5 bit-exact, and an 8-seed
parity vs the q2pro rig (268/265 pickups, 55%/55% ITEM, 79/78 frags).
run_parallel --reprois the rig switch. - R2 — variable FPS.
GMF_VARIABLE_FPS+ FRAMETIME/FRAMESYNC/ANIMTIME conversion, proven bit-invisible atsv_fps 10and timing-identical (fire periods + respawn scheduler) at 10 vs 40 Hz. - R3 — bot at 40 Hz. Per-tick dynamics normalized; combat decisions FRAMESYNC-keyframed (per-tick decisions are worth +28–41% frags from fresher information). Established the new metrics epoch above. An A/B of also keyframing combat movement showed no lethality gain at a pickup cost → rejected; the 10 Hz-brain/40 Hz-body split is the shipped shape.
- R4 — playbooks. Full pipeline shipped and validated with bot-recorded segments (57 engages → 26
completed replays over 8×180 s; failed entries self-penalize; no stuck bots).
NAV_MAX_LINKSraised 8→12 so playbook links aren't dropped on saturated nodes. Reference:baselines/validation-q2dm1.pbk. - R4b — Megahealth jump baked. From 7 clean human takes: the q2dm1 route is a pure strafe-jump
(launch pad
(688,1168,792)→ upper ledge → leap onto the mega). Result:item_health_mega0 → 10 across 8×90 s — the first previously-impossible item (control 0; unreachable without the replay), ~85% climb success, headline metrics flat.
Since the port: rl_walkway unlocks the upper Railgun (0 → 5, no_path → ok) — the first
narrow-walkway win. The "stuck above the chaingun" stall was fixed in two parts — a Bot_LiftThink
ascent-gate (lift-timeout −48%) plus a cg_descend rescue playbook with a per-entry dwell gate
(stall −24%, giveups −7%). 40 Hz humanization polish landed: bot_aimsmooth (40 Hz view-glide toward the
10 Hz aim, teleports −75%, kills neutral) and bot_gazelife. bot_reroute shipped (giveup −0.5 pt).
q2dm3 nav curation (2026-07-15). Human input captures under engine/ozbotre/logs/q2dm3/ diagnosed a
vertically fragmented graph (floor only reached z≤42; PLAT=0 vs ~65 on q2dm1/2). Surgical ONAV edits
via tools/nav_edit.py — not playbooks — added a func_plat column and ladder1/ladder5 climb-mouth
JUMP hops so A* routes into the ladder before the vertical hop (bot_ladder / bot_lift execute).
The win is upper-item reachability, not headline ITEM%: shipped-solo now picks up Railgun (16× in the
16×90s bench; cold-solo still 0) and Combat Armor appears in dm, while Megahealth’s ladder2→ledge path
and rj1 stay deferred as pure nav (climb stalls / no rocket-jump). A mh_ladder playbook from
logs/q2dm3/dm3-mh.jsonl is shipped in playbooks/q2dm3.pbk — MH is graph-reachable and bots
engage, but open-loop replay still drift-aborts on the strafe-into-ladder segment (needs a
cleaner take or looser executor). Shipped pin
baselines/nav_shipped/q2dm3.nav now diverges from cold — see STATS.md.
Asymmetric-harness note. In symmetric self-play, death is zero-sum, so raw "survive longer" behaviors
can't show a net win. bot_survive (seek health + flee when low) was built and tested for exactly this,
and proven counterproductive (deaths +11.8%) — it is default off. bot_survivetest is the
id-parity A/B tool (even bots survive, odd control) kept for future asymmetric measurement.
Inherited from ozbot. All of ozbot's validated wins carry over on the same codebase — route-cost scoring, swim/lift/strafe-jump locomotion, decisiveness, target-leading, fight-or-flight, and the 6-behavior humanization stack. See ozbot's README for that full history and the documented negative results; they are not re-listed here because their numbers belong to the 10 Hz epoch.
The RE engine is 64-bit — the DLL must be built x64 (MSVC via vcvarsall.bat x64; VS2022).
Verify a built DLL's PE machine is 0x8664 if in doubt.
build.bat :: compile src/*.c -> dist/gamex86_64.dll (x64)
deploy.bat :: copy dist/gamex86_64.dll -> %Q2DIR%/ozbotre/
run_server.bat :: build + deploy + launch a dedicated server with bots
play.bat :: launch a q2repro listen server you can play IN / chase-cam the bots
record_inputs.bat :: q2repro listen server with bot_inputlog on + a synced demo (capture YOUR inputs)
run_parallel.bat :: build + deploy + N parallel headless sims + merged analysis (pass --repro)
build_engine.bat :: build q2repro (x64, meson) -> %Q2DIR%/q2reproded.exe + q2repro.exe
build_gen_nav.bat :: PyInstaller-freeze tools/gen_nav.py -> dist/gen_nav.exe (no-Python nav generator)%Q2DIR% defaults to the in-repo engine (this folder's own runtime). The build is self-contained — the vanilla
Quake II v3.19 headers live in src/ (quake2-source is a reference mirror, not a build input); the
analysis tooling (pure-Python stdlib) is this repo's own tools/. Every launch passes com_rerelease -1
and +set sv_fps 40 (all scripts and run_parallel --repro do this for you).
Typical A/B run (16 seeds is the RE combat standard; always --repro):
run_parallel.bat --repro --fastsim --instances 16 --seconds 90 --bots 5 --seed 700 --cvar sv_fps 40With --fastsim, --seconds counts simulated game seconds (each server self-quits via bot_quitafter),
so every seed simulates the same game time regardless of CPU load. Fastsim is bit-exact vs real time.
RE-specific / changed knobs (defaults shown):
| Cvar | Default | Meaning |
|---|---|---|
bot_playbook |
1 | replay recorded input clips as NAV_LINK_PLAYBOOK links (inert without a .pbk); aborted replays self-penalize |
bot_cmdlog |
0 | log bot usercmds in the input-log schema (for recording bot segments to bake) |
bot_navlearn |
1 | runtime nav learning + autosave; 0 = play a fixed graph (a NAVHDR_FROZEN nav is frozen regardless) |
bot_teleport |
1 | seed one-way NAV_LINK_TELEPORT links (misc_teleporter → _dest) at map load; regenerated each load, never saved |
bot_aimsmooth |
1 | 40 Hz view-glide toward the 10 Hz aim target (anti-judder; teleports −75%) |
bot_gazelife |
1 | glance around between fire windows (40 Hz humanization) |
bot_aimflick |
1 | flick-speed cap multiplier (1 = stock 20–60°/tick) |
bot_reroute |
1 | penalize the stalled hop on a pure-nav giveup, so the next route avoids it |
bot_survive |
0 | survival instinct (seek health + flee when low) — counterproductive, kept off |
bot_survivetest |
0 | diagnostic: id-parity survival A/B (even bots survive, odd control) |
engine sv_fps |
40 | tick rate; read once at InitGame — never flip mid-session; 10 = vanilla, bit-identical to pre-port |
engine com_rerelease |
−1 | mandatory: keep q2repro hermetic (ignore auto-detected Steam/GoG/OneDrive installs) |
The full shared knob set — bot_count, bot_skill, bot_pathcost, bot_goalbudget, bot_budgetcap,
bot_itemfail, bot_swim, bot_lift, bot_strafejump, bot_claim, bot_decisive, bot_rollout,
bot_lead, bot_flee, the bot_gaze/turnrate/aimtexture/fov/hop/fidget humanization stack, and the
bot_*test id-parity diagnostics — is identical to ozbot; see ozbot's README for
their descriptions.
Server console: sv bot_add N / sv bot_remove N / sv bot_clear. The learned graph saves to
<gamedir>/nav/<map>.nav (autosaved ~30 s) in the ONAV v2 format — a uint32 header flags word
after the node count (v1 files still load, treated as learnable); a graph with the NAVHDR_FROZEN bit is
played as-is and never re-saved. Playbooks live in <gamedir>/playbooks/<map>.pbk; telemetry to
<gamedir>/logs/<map>_<ts>.jsonl. To bootstrap a brand-new map, just run bots on it — the graph
self-builds (bot_navlearn 1, always-on); playbooks are added per map as you record them. For
surgical nav curation (add/remove links or seed climb mouths without bulk maturation), use
py tools/nav_edit.py against an ONAV file — same workflow that produced the q2dm3 shipped pin
(diagnose with traj_view / input_view on a human capture, then edit).
Runtime nav learning is always-on (bot_navlearn 1) and autosaves, so a brand-new map self-builds its
graph on first server run. To ship a right-sized nav for your rotation, bake it frozen:
- Bake —
gen_nav.exe q2dm3 --out engine/ozbotre/nav/q2dm3.nav --report q2dm3.json. The standalone generator (no Python needed — build once withbuild_gen_nav.bat, which PyInstaller-freezestools/gen_nav.py; needspy -m pip install pyinstaller) matures-to-peak: it grows the graph in cold checkpoints of increasing duration, probes solo item-collection (COLLECT%) at each with the graph frozen, and emits the PEAK graph stampedNAVHDR_FROZEN— the map-specific node-count sweet spot, not max coverage. It auto-detects the engine + DLL. - Review the report — the JSON lists the peak (nodes, COLLECT%), the full checkpoint curve, and a
gated-item list: reach-oracle items still
no_path/gated — your to-do list for playbooks or surgicalnav_edit.pylinks. - Augment (optional) — record playbooks or add
nav_edit.pylinks for the gated items; drop the.pbkatengine/ozbotre/playbooks/<map>.pbk. - Ship — bundle the loose
nav/<map>.nav(frozen, so a live server never over-grows it past its sweet spot) + optionalplaybooks/<map>.pbknext to the DLL. Navs are raw files (tens of KB/map), not pak/VFS. Unbaked maps simply self-build + autosave on first run.
Freeze/unfreeze any nav by hand with py tools/nav_edit.py freeze <file.nav> (--unfreeze); dump shows
[FROZEN].
Based on the id Software Quake II v3.19 game source, licensed under the GNU General Public License v2.
The bot code (src/bot_*) is released under the same license.