Spaces:
Running
A newer version of the Gradio SDK is available: 6.20.0
Working in OpenRA-Bench (Claude Code / Codex / any coding-agent guide)
OpenRA-Bench is a rigorous LLM-agent RTS benchmark on a Rust engine. If you are an AI coding agent creating, validating, fixing, or extending a scenario pack, read this once and treat the linked docs as binding.
The bar (apply to every scenario you touch)
No defect. No cheat. Every lazy / brute / stall / blind / shortest-path / wrong-route / spam policy must LOSE on every level and every hard seed (1โ4). The intended capability policy must WIN. Non-win must be a real reachable timeout LOSS โ never a draw.
A scenario is defective if any of the following hold:
- The win predicate is satisfiable by a play that ignores the advertised capability (the "laziest play wins" inversion).
within_ticksorafter_ticksis set above the tick reachable withinmax_turns; the deadline never bites โ the episode times out as a DRAW, not a LOSS. The engine constant isDEFAULT_TICKS_PER_STEP = 30(openra-train/src/env.rs:33), so a non-interrupt-mode pack reachestick โ 30ยทmax_turns. Interrupt- mode runs (any pack with a non-emptyinterrupts:block) advance 1โmax_ticksticks per turn (max_ticksdefaults to 5 in the bench call site,openra_bench/eval_core.py), so per-turn tick advance is variable โ read the actual value frominfo["ticks_advanced"]instead of assuming it. The historical "engine advances ~90 ticks per decision turn" estimate is wrong; triaged indocs/ENGINE_FOLLOWUPS_TRIAGE.mdfinding #1.- There is no
fail_condition, or it only triggers on full force-wipe; a stall / preserve / partial outcome silently draws. - The intended capability is not solvable inside the declared budget (a scenario nobody can win is also defective).
- The engine auto-terminates on enemy-elimination before the win/fail
is evaluated (mitigation: place an unarmed high-HP enemy
factmarker at the objective). - Actors are placed outside the map's playable bounds (engine panics).
- The pack is
UPGRADEDintests/test_hard_tier.pybut its hard tier does not produce โฅ2 distinct seed-driven spawns (or there is no documentedNOT_APPLICABLEreason).
Authoritative docs (read these, in order)
SCENARIO_REVIEW_CHECKLIST.mdโ the closer-look methodology (A solvency / B stability / C capability) you follow step by step to create or validate a pack.SCENARIO_QUALITY.mdโ the whole-suite no-cheat pass summary, the recurring defect classes the pass eliminated, and the predicate-idiom recipe (which predicate makes which capability load-bearing), plus engine footguns to avoid.openra_bench/scenarios/win_conditions.pyโ the predicate grammar. If you add a new predicate, you must also add a_PHRASES/_REGION_PHRASEStranslation inopenra_bench/game_knowledge.py(the suite testtest_all_predicate_keys_have_a_translationenforces this).openra_bench/botgen.py+openra-sim/src/scripted_bot.rsโ the scripted opponents (hunt | rusher | patrol | turtle | guard) declared per-pack asenemy: {bot_type: <name>}.guardis the leashed defender used by the bait/decoy/lure idioms.- The 21 no-cheat-redesign commits on
mainare worked examples of every capability/predicate/bot combination. Browse withgit log --oneline --grep "no-cheat redesign"and read the bodies.
Engine facts you must internalise
- Ticks/turn: non-interrupt mode advances exactly
DEFAULT_TICKS_PER_STEP = 30ticks perenv.step()(openra-train/src/env.rs:33). Max tick atmax_turnsโ30ยทmax_turns. Interrupt mode (step_until_event, used wheneverinterrupts:is non-empty) advances 1โmax_ticksticks per turn (variable; defaultmax_ticks = 5) and breaks on the first signal โ readinfo["ticks_advanced"]rather than computing arithmetically. Anywithin_ticks/after_ticksabove the reachable tick is inert (won't bite) โ draw degeneracy. - Engine auto-done: the engine sets
done=Truewhen all enemy actors are eliminated, or sometimes when an agent unit reaches an enemy-key location. Without a persistent enemy actor a win-by-reach scenario can end as DRAW. Put an unarmed high-HP enemyfactmarker at the objective. - Own-unit
actor_typesurfaces inunits_summary(unit_type_count_eq / _gtework). Predicates relying on it are valid. power_surplus_gte/power_provided_gtenow work (historical footgun fixed). Pre-placed scenario buildings used to be invisible to the player'sPowerManagertrait because onlyorder_place_buildingupdated it, so the obs reportedpower_provided = power_drained = 0. The engine now recomputes the totals from the live building actors at snapshot time, honouring thePowerDowntoggle (World.powered_down). SeeOpenRA-Rust/openra-sim/tests/test_power_signals.rsandtests/test_power_signals_python.py. The newpower_provided_gtepredicate (gross provided, ignores drains) is the anti-cheat floor for load-shedding packs โ seebuild-power-down-defensive.deploynow works for scenario-declared MCVs (the historical "unimplemented" footgun was a two-bug interaction:classify_actorinopenra-sim/src/gamerules.rsreturnedVehiclefor MCV, and the env.rskind_for_unit_typefallback defaulted toInfantry. Both fixed; seetests/test_mcv_deploy.py.) Scenario actor{type: mcv}+Command.deploy([mcv_id])removes the MCV, creates an agentfact, and re-enables Building/Defense production queues โ so a build-radius scenario can launch from a single starter MCV.- Scripted bot
guard: holds its post (spawn_cell), auto-fires in range, lunges at the nearest foe withinGUARD_AGGRO โ 16, snaps back pastGUARD_LEASH โ 18โ the bait-able-defender idiom proven in #4 / #6 / #7 / #15 / #18. spawn_pointfilter is PER OWNER (Wave-9openra-data/src/oramap.rs::expand_scenario_actors). Each owner (agent / enemy) activates the filter INDEPENDENTLY: if any actor of that owner declaresspawn_point, ONLY that owner's actors whosespawn_pointmatches the chosen one are kept; that owner's actors WITHOUTspawn_pointare filtered out. Idioms:- Pre-Wave-9 (agent-side axis, every existing pack): declare
spawn_pointon agent actors โ seedโspawn round-robin varies the AGENT corner. Duplicate any persistent base/garrison agent actors across BOTH spawn groups at identical coords. Enemy actors don't declarespawn_pointโ enemy filter inactive โ all enemies place every seed (back-compat). - Wave-9 (enemy-side axis, e.g.
adv-rps-counter-pick): declarespawn_pointon enemy actors only โ the env'snew_with_spawn_pointfalls back todistinct_enemy_spawn_pointsand round-robins the seed across enemy compositions while the agent base stays fixed. Persistent per-seed enemy markers (e.g. a far-cornerfactfor engine auto-donemitigation) MUST be duplicated across every enemy spawn group, mirroring the agent-side idiom.
- Pre-Wave-9 (agent-side axis, every existing pack): declare
silois NOT MustBeDestroyed โ using it as an objective landmark allows premature engine auto-donewhen the other MustBeDestroyed buildings fall. Usebarr/proc/powr/factfor landmark anchors. (Wall-as-obstacle role is fine.)after_ticksin a WIN clause is structurally incompatible with ConquestVictoryConditions โ the engine auto-dones the second the last enemyMustBeDestroyedbuilding falls, before theafter_tickswindow opens, collapsing the run to DRAW.after_ticksbelongs infail_condition. Encode timed-arrival semantics via distance/landmark positioning instead.move_unitsauto-fires opportunistically en route, and a moving unit is a normal target (engine fix, pinned byOpenRA-Rust/openra-sim/tests/test_moving_unit_takes_fire.rs). A unit executing aMoveactivity now shoots in-range hostiles in passing WITHOUT abandoning its move, and is itself hittable by in-range enemies โ there is no "sprint-invincibility" any more (a unit can no longer cross a kill zone untouched on a long move order). The opportunistic move-fire RESPECTS stance:stance:0HoldFire never fires while moving;stance:1ReturnFire fires only after taking recent hostile fire;stance:2/stance:3fire on the nearest in-range enemy. For perception packs with hidden enemies that must be discovered without combat, set the HIDDEN actors tostance:0themselves (defender side, not scout side).attack_uniton an out-of-sight target paths normally (engine fix, pinned byOpenRA-Rust/openra-sim/tests/test_attack_unit_no_teleport.rs). An explicitAttackorder against an enemy beyond weapon range now closes distance at the attacker's realMobilespeed (identical to a plainmove); the chase used to warp a full cell per tick (~24x for infantry), teleporting the unit dozens of cells in one decision frame.- Stance semantics are now four behaviourally-distinct gates
(engine fix, pinned by
OpenRA-Rust/openra-sim/tests/test_stance_semantics.rs+tests/test_stance_semantics_python.py):stance:0HoldFire โ never auto-engages, even when attacked.stance:1ReturnFire โ auto-engages an in-range enemy only after itself taking hostile fire within a 60-tick window (recently_received_firegate). A stance:1 unit next to a passive (stance:0) enemy holds fire indefinitely โ it does NOT open fire first. This is the load-bearing distinction thecombat-stance-mgmt-attack/def-stance-mgmt-hold-then-attackpacks exploit.stance:2Defend (the default whenstance:is omitted) โ auto-fires on the closest in-range enemy but never advances.stance:3AttackAnything โ auto-fires on in-range enemies AND, when none are in weapon range, advances toward the nearest visible enemy (the "hunt" path:order_movetoward the target, then the next-tick scan promotes the encounter to Attack). This is the only stance that opens new engagements by moving โ a scattered-enemy map can be cleared by a single stance:3 hunter chaining one hunt move per kill. Explicit agent orders (attack_unit/attack_move) always override stance. A stance flip is a real load-bearing verb:set_stance(units, 3)converts an idle ReturnFire formation into an active hunter.
scheduled_events:โ mid-episode scripted hooks (Wave-9 engine feature, pinned byOpenRA-Rust/openra-data/tests/test_scheduled_events.rs+tests/test_scheduled_events.py). A scenario may declare a top-level (or per-leveloverrides:)scheduled_events:list; each entry fires once whenworld_tick >= tick. Three event kinds:spawn_actorsโ inject new actors mid-episode (reinforcement waves).actors:is a normal actor list (count:expands). Spawned actors get fresh ids, so a perception count predicate (enemies_discovered_gte) treats them as additive. They are placed via the same path as initial scenario actors (Mobile/ Health for units, typed Vehicle/Turret components attached).destroy_actorsโ remove every actor matchingfilter:(owner:+ optional circularregion: {x, y, radius}).shorten_deadlineโ clamp the episode'smax_ticksDOWN tonew_max_ticks(never grows it). Parsed byoramap.rs::read_scheduled_events, fired byenv.rs::fire_scheduled_eventsafter eachprocess_frame. This is the only way to test information-FRESHNESS perception (the scout-cycle idiom) โ a hidden enemy placed only at t=0 cannot exercise "re-observe a stale region". Worked example:scout-cycle-keep-info-fresh. Footgun: a scenario-declaredstance:3AGENT combat unit auto-hunts the whole map; for a perception pack keep the agent's combat armstance:0so a stall policy can't win for free by self-delivering the army.
reveal_map:โ the no-fog perception cell (pinned byOpenRA-Rust/openra-data/tests/test_reveal_map.rs+tests/test_perception_ablation.py). A scenario may declare a top-levelreveal_map: true; the agent player then observes the whole map with NO fog of war โ every enemy actor is reported regardless of shroud andexplored_percentis 100. Parsed byoramap.rs::parse_scenario_yaml(mirrorsspawn_mcvs), applied inenv.rs(is_visible_toshort-circuits true for the agent;refresh_explored_cellsfills the playable rectangle). This is the no-fog half of the perception ablation grid. The benchfog_modespans 3 observation channels ร 2 fog states = 6 cells (openra_bench/scenarios/schema.py::PERCEPTION_MODES):structuredโ text briefing + a text 'Unexplored regions' block; no image.visionโ text briefing + PNG minimap. NOTE the briefing already enumerates units/enemies with coordinates, so the image is a redundant SUPPLEMENT โvisiondoes NOT isolate image-reading.imageโ image-PRIMARY:prompt_v2.briefing_image_primaryredacts every coordinate from the text; the labelled minimap (render_tactical_minimap(..., unit_labels=...)) is the sole spatial source. The model references units by the on-map handle (tank-1,enemy-2);agent._to_commandsmaps the handle back to the engine id. The clean "can the model read a minimap" probe. Each channel has a fogged form and a-clear(no-fog) form. A-clearcell is a perfect-information CONTROL that isolates the perception cost โ a stall/observe policy WINS a-clearperception pack (perception removed), so the no-cheat bar applies ONLY to the fogged cells, never the-clearones. Run the full grid withrun_eval --perception-sweep(expands everypack:levelintopack:level:<mode>); the human Play tab stays on the canonicalvision(fogged) modality.
- Handoff ablation (
openra_bench/handoff.py,run_eval --handoff-sweep). AHandoffControllerlets aprefixcontroller play the first K turns, then the model inherits the live game state ("take over from here" โ a pure STATE handoff, no transcript). Astallprefix hands the model a losing position (recovery test); a replayed winning trajectory (TrajectoryController, sourced from a--handoff-bankof Playback runs) hands it a winning one (capitalize-on-advantage). Sweep cells arepack:level:handoff-{base,bad,good}. Every result carries apassivitystat โ the fraction of the model's turns spent onobserve/stoponly โ the freeze-and-panic signal. A replayed trajectory MUST come from the samepack:level:seed(engine actor ids are seed-deterministic). pboxcosts 600 (not the 400 some old specs assumed); defense and infantry are SEPARATE production queues so an efficient policy queuesbuild('pbox')andbuild('e1')in parallel from turn 1.pboxis now an active direct-fire tower (engine fix, pinned byOpenRA-Rust/openra-sim/tests/test_pbox_fires.rs+tests/test_pbox_fires.py). RA'spboxis anAttackGarrisoneddefense โ in C# its fire comes from infantry loaded into itsCargo, so the YAML carries NO directArmamenttrait. The engine does not model garrisoning, so a BUILTpboxused to stand inert (the auto-target loop'sweapons.first()returnedNone).GameRules::from_rulesetnow assigns the canonical RA anti-infantry pillbox weaponM60mg(Damage 1000 ร Burst 5, ReloadDelay 30, Range 4c0, anti-infantryVersus None:150โ a burst one-shots ane1) to garrison-only ground-turret defenses (pbox,hbox) when they carry no explicitArmament.M60mgis weaker and shorter- ranged than thegunturret'sTurretGun(Damage 6000, Range 6c512), matching the pbox's role as the cheap anti-infantry pillbox. Defense packs can now make the pbox load-bearing via aunits_killed_gteclause (a built pbox is the kill source). Thedef-walls-vs-towersidiom โ ascheduled_events: spawn_actorsrush injected AFTER the defense has time to build serially, with NO pre-placed agent combat screen โ is the way to make a build-pbox policy genuinely WIN via pbox kills while stall / wrong-placement still LOSE.- Multiple production buildings of the same category produce IN
PARALLEL (engine fix, pinned by
OpenRA-Rust/openra-sim/tests/test_parallel_production.rs+tests/test_parallel_production.py). The production tick advances a category's queue once per completed, alive production building of that category โ twoweapadvance the Vehicle queue twice per tick, so two war factories roughly DOUBLE vehicle output (likewisetent/barrโ Infantry,hpad/afldโ Aircraft,spen/syrdโ Ship). Before the fix the engine modelled production as ONE per-player queue per category and a 2nd factory added zero throughput. Building / Defense queues (fed by the construction yard) keep single-stream semantics. This makes "build a 2nd factory to hit a throughput quota" a real load-bearing capability โ seebuild-production-throughput-multibuilding. NOTE: the 2nd factory only helps if there is CASH to feed both queues; a cash-starved play (e.g.econ-buy-vs-build-decision, where a 2nd weap leaves only ~1 tank's worth of residual cash) is still a losing CAPEX trap โ the fix does not break that pack's bar. place_buildingdoes NOT enforce build-adjacency โ orders work at arbitrary in-bounds coords. Forward-base / far-region building is solvable with a singlebuild + place_building.facthas cost 0 โ not buildable viaStartProduction(engine gates oncost > 0). Useprocas the "second base seed" in expand-arm objectives.health:on a pre-placed actor NOW WORKS (historical footgun fixed). The Rust scenario parser (oramap.rs::RawScenarioActor/ScenarioActor) used to parse onlyactor_type / owner / position / count / spawn_point / stanceand silently dropped ahealth:line, so an actor placed withhealth: 40spawned at full HP. The parser now carrieshealth: N(an HP PERCENTAGE, 1-100) through to the spawned actor'sHealthtrait (hp = max_hp * N / 100, clamped โฅ1). Pre-placed damaged buildings/units are the basis for the repair-triage / disaster-recovery idiom. Pinned byOpenRA-Rust/openra-data/tests/test_actor_health.rs+tests/test_actor_health_field.py.- Building actor ids ARE surfaced for
repair/sell/power_down/set_primary(historical footgun fixed).RustObsAdapter.render_state()used to buildown_buildingsas{type, cell_x, cell_y}โ it dropped the engine actor id, soprompt_v2assignedid = list-indexandenv.rs::resolve_ownedrejected the bogus id โ no agent could target a building. The adapter now includes the REAL engineid(plushp/is_primary) inown_buildings/buildings_summary, mirroring howunits_summarykeeps the real unit id. Pinned bytests/test_repair_building_id.py. not own_units_gte:1mis-fires on turn 1 when the agent starts unit-less (documented footgun fromeconomy-force-buildup). Useafter_ticks+not has_building:factfor the unit-less start fail clause instead.- Certain mid-map cells silently fail to place enemy clusters
(e.g.
(50,20),(60,28),(90,30)observed by A7); nearby cells ((60,10),(100,30),(50,19)/(50,21)) work. Likewisee1at some cells doesn't surface inenemy_positionsโe3does. For perception packs, usee3for hidden clusters and verify cluster cells on a smoke run before authoring against them. place_building('proc')now auto-spawns the new harv at the NEW proc's footprint and binds it to the closest refinery by PATH DISTANCE (engine fix, pinned byOpenRA-Rust/openra-sim/tests/test_proc_auto_spawn_at_new_proc.rstests/test_proc_auto_spawn_python.py). Historical footgun: the engine routed the auto-harv throughfind_spawn_location, which sorts candidates by(!is_primary, id)โ so a 2nd proc placed far from the 1st always materialised its harv at the LOWEST-ID proc, andfind_refineryreturned the lowest-id proc unconditionally. The combined effect: expansion to a contested patch was a no-op (the new harv trekked back to the old refinery, and the old harv kept depositing at the old refinery). The fix: a newspawn_unit_near_building(actor, unit_type, owner, building_id)anchors the spawn scan on the NEW proc's footprint, andfind_refinery_from(owner, cell)picks the proc with the shortest A* path fromcell(with fallback to Chebyshev-nearest then lowest-id). A 2nd refinery placed near a contested patch now produces real throughput. Existing harvesters do NOT re-snap to the new proc โ the re-resolve only fires when the stored refinery id is stale (proc destroyed / never existed). To reroute live harvesters, the agent mustset_primaryon the new proc or sell the old one.
- Thief
Infiltrateis a no-op against any non-proc/ non-siloenemy building (engine match-arm intent). The thf walks to the target, is consumed, and 0 cash is drained. The Python tool description (infiltrate) already documents this: the cash-drain branch is gated onproc | silo. Bench scenarios that want the thief to load-bear must direct it at a refinery or silo specifically. stance:0HoldFire defenders never return fire even when attacked โ engine-intended (pinned bytest_stance_semantics.rs::test_stance_0_holds_fire). The defenders die silently. For a defense scenario where the model is expected to flip stance under threat: pre-place defenders atstance:0, exposeset_stanceintools:, and gate the win on combat damage so a stall play (no stance flip) loses by having the base destroyed without resistance.- Per-player starting cash is now plumbed end-to-end (engine
fix, pinned by
OpenRA-Rust/openra-sim/tests/test_per_player_starting_cash.rsOpenRA-Rust/openra-data/tests/test_per_player_starting_cash.rstests/test_per_player_starting_cash.py). A scenario YAML'sagent: {cash: N}/enemy: {cash: M}is honoured per slot; back-compat path (neither override set) falls back to the top-levelstarting_cash:. This is the wiring the thiefspec-thief-steal-cashand asymmetric-econ packs depend on.
Command.fire_superweaponis the only superweapon verb (no otherCommand::*variant fires nukes / iron curtain / chrono). Tool entry:fire_superweapon{kind, target_x?, target_y?, target_id?}. End-to-end pin:tests/test_superweapons_python.py(Python) +OpenRA-Rust/openra-sim/tests/test_superweapons.rs(Rust). The engine validates (a) the agent owns a launcher building of the matchingkind, (b) the weapon is fully charged (charge time is hard-coded 100 ticks per kind for tests; real-play values live ingamerules.rs); a failed validation is logged and the order is dropped silently. Nuke needstarget_cell; iron curtain needstarget_idonly; chrono needs both (target_cell= destination,target_id= friendly actor to teleport).
Engine blockers: fix the engine, do not compromise the pack
When authoring a pack you may discover that the intended capability
cannot be expressed in the current engine โ e.g. an order is a
no-op, a stance doesn't behave as advertised, or some specific
order/predicate isn't surfaced. (Two historical gaps are now
closed: enemy actors DO honour spawn_point per-owner, and
scheduled_events: provides the mid-episode scripted-event hook โ
see the feature notes above.) The correct move is to fix the
engine, not to retire the pack, weaken the bar, or substitute a
different mechanic that masks the gap. Concretely:
- Reproduce the gap with a focused Rust or Python test.
- Add the test as a failing test in
OpenRA-Rust/openra-sim/tests/(oropenra-data/tests/, ortests/test_<feature>.pyon the bench side) and make it pass with the minimum change. - Rebuild the wheel:
cd OpenRA-Rust && PATH=$HOME/.cargo/bin:/opt/anaconda3/bin:$PATH maturin develop --release(verify theInstalled openra_trainline printed โ maturin can exit 0 while cargo failed). - If the change needs a new predicate or signal, also add the
_PHRASEStranslation inopenra_bench/game_knowledge.py(the suite testtest_all_predicate_keys_have_a_translationenforces this). - Ship the engine change + the pack in separate commits on the same push so reviewers can see "this engine gap was closed to unblock this pack".
- Update this CLAUDE.md to remove the footgun note (or restate it as a now-fixed historical pitfall).
Only retire a pack if the engine fix is genuinely out of scope โ e.g. requires a multi-week refactor or contradicts an explicit design constraint. In that case open a task with the gap, the attempted fix, and what would be needed, rather than silently retiring.
How to validate (deterministic, no model / no network)
For each level + each hard seed (1โ4) run scripted policies via
openra_bench.eval_core.run_level:
from openra_bench.scenarios import load_pack
from openra_bench.scenarios.loader import PACKS_DIR, compile_level
from openra_bench.eval_core import run_level
c = compile_level(load_pack(PACKS_DIR / "<pack>.yaml"), "easy")
res = run_level(c, my_policy_fn, seed=1)
print(res.outcome, res.turns, res.signals.units_lost)
Cover at minimum: stall (only Command.observe()), a brute
beeline to the objective, a greedy / wrong-path if applicable,
and the intended capability policy. The bar (above) must hold.
No model / OpenRouter / network runs are needed for validation โ
scripted policies are sufficient and free.
Working on main (PRs vs direct push)
- The default branch is
main. - Direct pushes to
mainare reserved for the user's batch parallel-agent workflow. If you are a one-off agent invoked outside that flow, branch first and open a PR; do not push tomainwithout explicit user authorization. - Commits must NOT include a Claude / AI co-author line.
- The shared engine wheel is rebuilt via
cd OpenRA-Rust && PATH=$HOME/.cargo/bin:/opt/anaconda3/bin:$PATH maturin develop --releaseโ verify theInstalled openra_trainline actually printed (maturin can exit 0 while cargo failed).
Don'ts (lessons from the cadence)
- Don't add a predicate to
win_conditions.pywithout a_PHRASEStranslation ingame_knowledge.py. - Don't
git add -A/git commit -aโ concurrent agents may have uncommitted edits to shared files; stage only your own files. - Don't compensate for model weakness or over-engineer; only fix real scenario defects, and keep the established idioms.
- Don't edit
SCENARIO_QUALITY.md/docs/scenarios.htmlin a per-scenario commit โ the main session regenerates them at the end. - Don't edit
OpenRA-Rust/(the engine) inside a scenario task โ flag engine needs in your report instead.