Fight Caves RL asks whether a reinforcement-learning agent can learn the entire Old School RuneScape Fight Caves from scratch. A successful episode lasts 63 waves. Early waves introduce one enemy at a time; later waves combine melee, ranged, and magic attackers; the final wave adds TzTok-Jad, whose attacks must be answered with the correct protection prayer while healers enter the arena. The policy receives game state, chooses game actions, and receives a scalar reward. It is not given demonstrations or a scripted solution.
The project is more than a training script. I built a deterministic C simulator, a reinforcement-learning interface, a recurrent PPO training system, experiment tracking, and a 3D viewer that can run a human player or a trained checkpoint through the same action contract. Most of the work was spent making the environment faithful enough to require the real strategy, fast enough to run billions of steps, and observable enough that a neural policy could learn that strategy.
[Insert screenshot: the Raylib arena during a late wave, with the agent, collision geometry, active enemies, overhead prayers, and policy telemetry visible]
1. Defining the problem
The Fight Caves is a useful long-horizon control problem because success depends on several kinds of reasoning at once. The agent must preserve limited food and prayer points, recognize which incoming attack is dangerous, switch protection prayers before a hit is locked in, choose targets, move around collision, exploit line of sight, and avoid spending thousands of ticks on a locally safe but globally useless behavior. A mistake on wave 60 can erase the value of every correct decision made before it.
“From scratch” has a precise meaning here. Training begins from randomly initialized policy weights. There is no behavior-cloning dataset, no expert trajectory, and no hard-coded phase controller that takes over for Jad. Curriculum and reward shaping can change which states the policy encounters and which outcomes are reinforced, but the policy still has to infer the action sequence from interaction.
What counts as a solution
I track several outcomes rather than reducing the run to average reward. Wave-63 reach rate measures whether the policy can manage the preceding cave. Jad kill rate measures full completion. Resource use, damage taken, prayer correctness, idle time, and per-wave deaths help explain why a configuration succeeds or fails. This separation matters: a policy can produce a high shaped reward while stalling, or reach Jad consistently while failing the prayer-and-healer phase.
2. System architecture
The system has three runtime layers. fc-core is a pure C state machine containing the arena, waves, NPC behavior, collision, combat, observations, masks, and rewards. The PufferLib adapter exposes thousands of independent core states to PPO with contiguous buffers. The Raylib viewer reads the same state and writes the same actions, adding interpolation and diagnostics without owning game rules.
fc-core
Deterministic, rendering-free simulation with no allocation in the tick loop.
PufferLib 4
Vectorized environment buffers, recurrent PPO, evaluation, checkpoints, and experiment telemetry.
3D viewer
Human play, live policy playback, collision inspection, reward channels, and episode debugging.
Keeping rendering outside the simulator makes the training path headless and reproducible. Keeping the human and policy paths on one action interface provides a stronger check: if the viewer mutates state directly, it can appear correct while the agent is training against different rules. Here, a mouse click becomes the same movement-head values consumed by a training episode.
3. The deterministic simulator
An episode uses a 64-by-64 collision arena, one player, at most 16 active NPCs, 63 wave definitions, and 15 official spawn rotations. Each state owns its random generator and all mutable timers. Resetting two environments with the same seed and replaying the same action vectors should produce identical state transitions.
Tick ordering is part of the environment contract. Prayer is instantaneous and therefore processed before queued attacks resolve. Eating and drinking have cooldowns. Movement can alter distance and line of sight before NPC decisions. Pending hits carry the information needed to resolve later, rather than looking up a possibly changed attack style at impact time.
void fc_tick(FcState* state, const int actions[FC_NUM_ACTION_HEADS]) {
int prayer_active_at_tick_start =
(state->player.prayer != PRAYER_NONE);
clear_per_tick_flags(state);
process_player_actions(state, actions);
decrement_player_timers(&state->player);
fc_prayer_drain_tick(&state->player, prayer_active_at_tick_start);
for (int i = 0; i < FC_MAX_NPCS; i++)
fc_npc_tick(state, i);
fc_resolve_player_pending_hits(state);
for (int i = 0; i < FC_MAX_NPCS; i++)
if (state->npcs[i].active)
fc_resolve_npc_pending_hits(state, i);
check_terminal(state);
state->tick++;
}Fixed-size arrays replace per-tick heap allocation. That design trades some unused capacity for predictable memory access and removes allocator contention when 4,096 environments advance in parallel. It also makes state inspection straightforward: the viewer and test tools can copy or compare a state without reconstructing an object graph.
Combat fidelity without a full game client
The simulator implements the mechanics that affect Fight Caves decisions: attack speeds, ranges, accuracy, max hits, melee approach, projectile delay, protection-prayer snapshots, line of sight, NPC footprint collision, wave spawning, Jad attack commitments, healer behavior, food, prayer potions, and death. It deliberately omits unrelated RuneScape systems. The goal is a controlled task model, not a second general-purpose client.
This boundary is important when interpreting the result. A high clear rate demonstrates learning inside this mechanics model. It does not prove direct transfer to the live client, whose visual input, latency, interface, and unmodeled mechanics create a different observation and control problem.
4. Observation and action design
The policy receives 122 normalized float values. Seventeen describe the player: health, prayer, position, timers, active protection prayer, remaining supplies, incoming-hit counts, and selected target. Eight deterministic NPC slots contribute 12 values each, including position, health, distance, attack telegraph, timer, line of sight, pending style, and impact time. Nine values describe the wave and recent events.
/* The policy sees player state, eight NPC slots, and wave metadata. */
#define FC_OBS_PLAYER_SIZE 17
#define FC_OBS_NPC_STRIDE 12
#define FC_OBS_NPC_SLOTS 8
#define FC_OBS_NPC_TOTAL (FC_OBS_NPC_STRIDE * FC_OBS_NPC_SLOTS)
#define FC_OBS_META_SIZE 9
#define FC_POLICY_OBS_SIZE (FC_OBS_PLAYER_SIZE + FC_OBS_NPC_TOTAL + FC_OBS_META_SIZE)
/* Reward features are adjacent in memory, but hidden from the policy. */
#define FC_REWARD_FEATURES 19
#define FC_TOTAL_OBS (FC_POLICY_OBS_SIZE + FC_REWARD_FEATURES)
/* The simulator has two viewer pathfinding heads; training uses the first five. */
#define FC_NUM_ACTION_HEADS 7
#define FC_ACT_SIZES { FC_MOVE_DIM, FC_ATTACK_DIM, FC_PRAYER_DIM, FC_EAT_DIM, FC_DRINK_DIM, FC_MOVE_TARGET_X_DIM, FC_MOVE_TARGET_Y_DIM }When more than eight NPCs are alive, the observation contains the eight closest. Slots are sorted first by Chebyshev distance and then by spawn index. That tie-breaker is not cosmetic: unstable slot ordering makes the meaning of “attack slot 3” change between otherwise identical runs. Overflow NPCs remain active in the simulator even though they cannot be directly selected until they enter the visible set.
Training uses five categorical action heads. Movement has 17 choices: idle, eight one-tile directions, and eight two-tile run directions. Attack selects none or one of eight NPC slots. Prayer chooses no change, off, protect magic, protect ranged, or protect melee. Eat and drink heads control consumables. The core also defines target-tile heads for human click-to-move, but the training adapter disables those higher-level actions so PPO has to choose low-level movement.
Action masks
The adapter appends 36 mask values for the five training heads. A wall masks an illegal movement direction; empty NPC slots mask attack targets; exhausted supplies mask eating or drinking. Masks prevent the optimizer from spending probability mass on impossible actions. Prayer remains available because changing or disabling a prayer is often meaningful even when no attack is landing on the current tick.
5. Reward design and the behaviors it created
The core emits 19 reward features separately from the 122 policy inputs. The trainer converts those features into a scalar using configuration weights. This prevents reward-only facts from leaking into the policy while keeping experiments configurable: I can change the cost of wasted food or a Jad-heal event without recompiling observation logic.
FcRewardBreakdown out;
memset(&out, 0, sizeof(out));
fc_write_reward_features(state, out.raw);
/* A large mistake costs more than several small scratches. */
{
float dmg_frac = out.raw[FC_RWD_DAMAGE_TAKEN];
out.damage_taken =
dmg_frac * dmg_frac * 70.0f * params->w_damage_taken;
}
if (out.raw[FC_RWD_WAVE_CLEAR] > 0.0f) {
int cleared_wave = state->current_wave - 1;
if (cleared_wave < 1) cleared_wave = 1;
out.wave_clear = params->w_wave_clear * (float)cleared_wave;
}The published v35 recipe rewards damage dealt, kills, wave clears, correct protection, kiting, safe attacks, and Jad completion. It penalizes damage, death, incorrect prayer, wasted resources, invalid actions, unnecessary prayer, and very long stalls. Damage cost is quadratic in the fraction of health lost, so one catastrophic hit is worse than several small errors with the same total damage. Wave-clear reward scales with wave number because a late clear represents more progress and a rarer state.
The stalling failure
An early policy survived for more than 200,000 ticks without clearing the cave. It learned that avoiding damage and collecting survival-related reward was safer than advancing. This was a genuine optimizer success against a flawed objective. I added explicit progress diagnostics, bounded episodes, delayed stall penalties, and outcome-based evaluation so “alive” could no longer stand in for “solving the task.”
Why prayer reward alone did not work
A direct correct-prayer term initially produced a better score without producing reliable prayer behavior. The policy lacked enough information about committed styles, arrival timing, line of sight, and overlapping threats. Adding pending-hit style and time-to-impact features addressed the information problem. Reward shaping became useful only after the observation made the decision identifiable.
6. Training at scale
The policy is a three-layer MinGRU with a 256-unit hidden state trained using PPO. Recurrence matters because the current vector does not encode every past event: the network benefits from remembering target changes, previous movement, prayer transitions, and the sequence leading into a wave. PPO collects experience from 4,096 environments across two buffers and updates the policy in batches.
[vec]
total_agents = 4096
num_buffers = 2
[train]
total_timesteps = 3_000_000_000
learning_rate = 0.0009
gamma = 0.996327
gae_lambda = 0.96405
clip_coef = 0.1783
ent_coef = 0.02423
vf_coef = 1
max_grad_norm = 0.25
[policy]
hidden_size = 256
num_layers = 3The configuration shown above is abbreviated, but it captures the scale and principal optimizer values. The published sweep selected a learning rate of 9e-4, discount factor of 0.996327, GAE lambda of 0.96405, entropy coefficient of 0.02423, clipping coefficient of 0.1783, and gradient-norm cap of 0.25. At approximately 1.9 million environment steps per second, a three-billion-step run completes in roughly 26 minutes on the measured RTX 5070 Ti system.
High throughput changes the experiment loop. A configuration can be tested to late-game behavior in minutes, and broad sweeps become practical. It also makes silent correctness errors more expensive: a missing collision file or wrong terminal observation can produce billions of invalid transitions before a curve makes the fault obvious.
[Insert screenshot: a Weights & Biases plot showing Jad kill rate, wave-63 reach rate, throughput, and resource use across the v35.1 run]
7. Integration bugs that changed the result
Several of the most consequential problems were outside the neural network. They are worth documenting because a reinforcement-learning result is only as valid as the transition data that reaches the optimizer.
- A curriculum helper advanced the environment by repeatedly calling the normal step function. Directly setting the intended wave removed unintended state transitions before the episode began.
- A collision asset was not found on one launch path, so training silently used an open arena. Path discovery and startup checks were tightened; removing permissive fallbacks entirely remains a useful hardening step.
- Shared static BFS scratch memory caused failures when thousands of environments ran concurrently. Moving scratch state out of the shared global path restored environment isolation.
- The adapter initially risked writing the first observation of the reset episode over the terminal observation. Writing reward and terminal state before reset preserved the transition that PPO actually needs.
- The 3D path exposed reversed triangle winding, coordinate mismatches, duplicate render passes, and terrain-color errors. These did not change headless training, but they made policy inspection misleading until fixed.
/* Step the simulation and derive this transition's reward. */
fc_step(&env->state, actions);
env->rewards[0] = fc_puffer_compute_reward(env);
env->ep_length++;
/* The agent must see the terminal state observation,
* not the next episode's reset observation. */
fc_puffer_write_obs(env);
if (fc_is_terminal(&env->state)) {
env->terminals[0] = 1.0f;
/* Episode metrics are recorded here; reset follows externally. */
}The terminal-observation issue is a small ordering bug with large statistical consequences. If the value function is trained on the reset state while the reward belongs to the death or completion state, the target joins two unrelated episodes. The run may still improve, which makes this class of error harder to detect than a crash.
8. Experiment progression
The result came from a sequence of measured revisions rather than one final configuration. Early versions established cold-start progress to wave 60. Later versions learned Jad, corrected observation gaps, removed score-inflating reward, and tested PPO settings. The table records milestone configurations from the repository's run history.
| Version | Change or result | What it established |
|---|---|---|
| v21.2 | First cold-start wave-60 policy | The agent could learn the long pre-Jad sequence without demonstrations. |
| v22.1 | 1.5% Jad kills from a warm start | The terminal phase was learnable, but still rare and unstable. |
| v28.8 | 59.1% peak Jad kill rate | Observation, reward, and arena revisions produced a reliable full-cave policy. |
| v29.1 | Priority ablation reduced compute by 33% | A removed sampling mechanism matched or improved deployment quality. |
| v32 | 81.0% after Jad-heal penalty | Directly controlling the healer failure mode improved completion by 15.4%. |
| v34 | 200-trial Protein sweep; best 88.6% | Broad hyperparameter search selected the v35 training region. |
| v35.1 | 94.9% peak; ~99% wave-63 reach | Published best full-supply configuration. |
| v36 | 80.8% peak with no consumables | The learned control strategy was not only inventory management. |
The v29.1 ablation is a useful example of why removal experiments matter. A prioritized sampling feature added compute and complexity, but setting its priority coefficient to zero preserved or improved the metric that mattered. The simpler training path was faster and easier to interpret.
9. Published result and late-training degradation
| Run | Peak Jad kill | Peak step | Interpretation |
|---|---|---|---|
| v32 | 81.0% | 1.98B | Strong pre-sweep baseline. |
| v34 best | 88.6% | 2.03B | Best configuration from the 200-trial sweep. |
| v35.1 | 94.9% | 1.215B | Current published peak. |
v35.1 exceeded 90% Jad kills for a substantial portion of training, roughly 1.1 to 1.6 billion steps. It later degraded, reaching about 62% near three billion steps under the constant 9e-4 learning rate. For deployment I therefore preserve peak evaluation checkpoints instead of assuming the final optimizer state is best.
This degradation remains an open optimization problem. Candidate tests include learning-rate decay after the first stable peak, stronger checkpoint selection, lower late-stage entropy, and multi-seed comparison. Reporting only the best point would hide this behavior; reporting only the final point would hide the policy the run actually discovered.
10. Loadout experiments on the development branch
A later development branch applies the same training setup to nine equipment loadouts. These runs are exploratory and are not the v35.1 published result. Each sweep trains to 1.5 billion steps with 4,096 environments, two buffers, and the 256-unit, three-layer MinGRU.
| Development condition | Loadout | Jad kill | Wave-63 reach |
|---|---|---|---|
| Full supplies, final | sota_tbow | 91.0% | 97.0% |
| Full supplies, final | armadyl_crossbow | 1.1% | 97.2% |
| No supplies, peak | tbow_masori | 64.1% | 86.9% final |
| No supplies, final | armadyl_crossbow | 10.3% | 81.6% |
The crossbow result separates “reach Jad” from “kill Jad”: it traverses the cave reliably but lacks enough effective terminal-phase control or damage to convert. One no-supplies seed with the nominal best bow collapsed completely while another loadout remained strong. That is not evidence that the equipment is inherently worse. It is a reason to repeat seeds and inspect configuration interactions before making a claim.
This sweep also exposed stale native builds as an experiment-integrity risk. The development tooling now records a backend stamp and forces rebuilds when core inputs change, reducing the chance that a run name and its compiled simulator disagree.
[Insert screenshot: a grid comparing late-wave behavior and learning curves for the Twisted Bow, Bowfa, and Armadyl crossbow configurations]
11. What the project established
Working results
- A recurrent PPO policy can learn all 63 waves from randomized weights without demonstrations.
- The C backend sustains roughly 1.9 million measured simulation steps per second in the published setup.
- Observation contracts, action masks, and outcome metrics make learned behavior inspectable rather than treating reward as the only result.
- Human play, policy playback, and headless training share one simulator and action representation.
Limits and next experiments
- The model is a purpose-built simulator; transfer to the live client has not been demonstrated.
- Peak-to-final degradation needs scheduled optimization and multi-seed evaluation.
- Permissive asset fallbacks should become strict startup failures for benchmark runs.
- Loadout conclusions require repeated seeds and controlled statistical comparison.
The central lesson is that environment engineering and learning are inseparable. The largest gains did not come from making the network arbitrarily larger. They came from repairing missing information, removing misleading incentives, testing simplifications, preserving terminal transitions, and measuring the outcome directly. The trained policy is the visible result, but the durable artifact is a fast and inspectable experimental system for asking why it works.
