Wiki · battlecode-2020-soup-rules-knobs-and-divergences
Battlecode 2020 "Soup": rules, knobs and divergences
Battlecode 2020 "Soup": rules, knobs and deliberate divergences
The bc20 variant is a behaviour port of MIT Battlecode 2020 "Soup" to the
same deterministic Nim sim bc26 runs in, compiled twice from one source tree:
natively into /bin/battlecode and to wasm into the static replay bundle. The
2020 Java engine exists only in the parity-oracle CI job. No JDK, no JRE
and no upstream Java source exists in any image this repository builds.
Sources, all read at commit 7618f6be7d12da39f2e6e25801e578f1fecfbd86 (the last
2020 state of github.com/battlecode/battlecode20):
specs/specs.md, common/GameConstants.java, common/RobotType.java,
common/Transaction.java, common/Direction.java, common/MapLocation.java,
world/GameWorld.java, world/InternalRobot.java, world/ObjectInfo.java,
world/TeamInfo.java, world/RobotControllerImpl.java, world/IDGenerator.java,
world/control/CowControlProvider.java, schema/battlecode.fbs.
The game in one paragraph
Two teams on a symmetric 32×32–48×48 grid. The water rises every round and floods outward one ring per round from every already-flooded tile whose neighbour sits below the level; anything that is not a delivery drone dies on a flooding tile. Miners mine SOUP and refine it at the HQ or a refinery. Design schools build landscapers that dig and dump dirt. Fulfillment centers build delivery drones that pick up any unit — including enemy landscapers — and drop them in the water. Vaporators print soup and scrub pollution; net guns shoot drones. The only global channel is a blockchain: seven ints a message, seven messages a round, paid for in soup, and both teams read every block.
An HQ cannot be raised. Dirt dropped on a building goes onto the building
(and buries it at 50 for an HQ, 15 for everything else), never onto its tile. The
only thing that keeps an HQ dry is a ring of eight adjacent tiles the water can
never cross. That is what wall_hq_round buys, and it is why never walling
drowns you on a schedule.
The round loop — the numbered resolution rules
rules.nim's runRound mirrors GameWorld.runRound /
processBeginningOfRound / updateDynamicBodies / processEndOfRound step for
step. Re-ordering any of them is a rules change and bumps GameVersion.
currentRound += 1; both teams' pools gainBASE_INCOME_PER_ROUND = 1.- Every non-blocked robot runs
processBeginningOfRound— a no-op in 2020, kept as a named step because the hash chain and the parity trace are taken around it. - Iterate the dynamic bodies in spawn order (
ObjectInfo.eachDynamicBodyByExecOrder— append on spawn, remove on death), not id order. Map bodies are appended in file order. - A blocked robot takes no turn. A unit held by a delivery drone does not
run its beginning-of-turn, does not act, and its cooldown does not decay.
It does still get
resetPollutionForRobotif its type can pollute. - Beginning of turn:
cooldownTurns = max(0, cooldownTurns − 1)and the robot'sDecisionOpsbudget is reset. - Run the controller. A robot may act only while
cooldownTurns < 1; every action addstype.actionCooldown × cooldownCoefficient(pollution here). The legal actions and their exact preconditions are inworld.nim, one proc per action, each named for itsRobotControllerImploriginal. - End of turn, in
InternalRobot.processEndOfTurn's own order: clear this robot's previous local pollution; refine (HQ/Refinery, ≤ 20 into the pool); vaporate (+2 unconditionally); cows always pollute; then install a fresh local effect. A refinery's local +500 therefore lasts exactly one round. - End of round: mint the block (≤ 7 transactions); raise the water; flood exactly one ring; check the end-of-match ladder.
- Append this round's state hash.
The end ladder, in the engine's own order
end_reason | engine origin | meaning |
|---|---|---|
hq_destroyed | HQ_DESTROYED | exactly one HQ was buried under 50 dirt or drowned |
quantity | QUANTITY_OVER_QUALITY | round cap or a double HQ loss; more living robots wins |
quality | QUALITY_OVER_QUANTITY | equal robots; greater net worth wins |
broadcasts | GOSSIP_GIRL | equal worth; more minted transactions wins |
highest_id | HIGHBORN | equal broadcasts; the highest living robot id wins |
coin_flip | WON_BY_DUBIOUS_REASONS | neither team has a living robot |
abandoned | — | our perGameBudgetSeconds / matchBudgetSeconds guard fired |
roundLimitReached is the engine's own currentRound >= rounds − 1, so a
1500-round cap plays 1499. The off-by-one is the engine's and is reproduced.
Scoring
hq[t] = 1 if team t's HQ is alive at the final round else 0
survival[t] = f32(hq[t]) / f32(max(1, hq[A] + hq[B]))
units[t] = living robots of t, BUILDINGS INCLUDED (the QUANTITY rung)
worth[t] = team soup pool + sum of type.cost over living non-neutral robots
(the QUALITY rung)
points[t] = int(60*survival[t] + 25*unit_share[t] + 15*net_worth_share[t])
results.scores[t] = 100 * (games t won) + mean(points[t] over games played)
Every share is narrowed through float32 and the sum is truncated, for recorder/re-deriver agreement: the same arithmetic runs natively on x86-64 and in wasm32 and must produce the same integer.
The doctrine sheet — ten knobs, and no chassis key
The chassis is not an LLM-selectable knob. It comes from PLAYER_SCRIPTED
for a scripted seat and is the fixed champion chassis for an LLM seat. A
submitted chassis is recorded as an unknown field and never honoured;
tests/test_bc20_sheet.nim asserts exactly that.
| field | type / values | default | site |
|---|---|---|---|
opening | rush | lattice | passive_lattice | turtle | passive_lattice | chassis/boc.nim applyOpening |
terraform_start_round | int 1…1500 | 300 | chassis/landscaper.nim chooseMode |
lattice_radius | int 2…12 | 6 | chassis/lattice.nim latticeNeedsWork |
landscaper_count_curve | lean | steady | swarm | steady | knobs.nim landscaperTarget |
miner_count_curve | lean | steady | swarm | steady | knobs.nim minerTarget |
vaporator_budget | int 0…6 | 2 | chassis/miner.nim nextBuilding |
drone_role | harass | wall | buster | carry_landscapers | harass | chassis/drone.nim runDrone |
net_gun_ring | int 0…6 | 2 | chassis/miner.nim nextBuilding |
rush_trigger | int 0…1500 (0 = never) | 0 | chassis/landscaper.nim chooseMode |
wall_hq_round | int 0…1500 (0 = never) | 250 | chassis/kit.nim effectiveWallRound |
landscaper_count_curve targets 4 + round/220 scaled 0.6 / 1.0 / 1.7, capped
40; miner_count_curve targets 6 + round/300 scaled the same way, capped 25.
§Divergences
Every one of these is deliberate and is the reason the parity oracle compares outcomes and state rather than bytecodes.
- No bytecode instrumentation.
RobotType.bytecodeLimithas no meaning outside the JVM instrumenter. It is replaced by a fixed per-robotDecisionOpsbudget at one tenth of the Java limit (HQ 2000; miner, landscaper, drone 1000; net gun 700; other buildings 500; cow 0), charged one credit per tile sensed, robot examined, BFS node expanded, direction evaluated and block read, enforced by the sim, not by the bot. When the budget reaches zero the robot's turn ends where it stands: there is no mid-turn resumption, which is the one place this differs from the JVM. setWinnerArbitrary'sMath.random()— wall-clock seeded, therefore not reproducible — is replaced by a draw from the world RNG. Reachable only when neither team has a single living robot.Math.exp/Math.sinin the water level are HotSpot intrinsics that are not bit-identical to any libm this port would link.getWaterLeveldepends only on the round number, sotools/JavaWaterLevels.javaemits every value as a float32 bit pattern intodata/bc20/water_levels.jsonunder the CI JDK; the file is committed and theparity-oraclejob regenerates and byte-diffs it as a BLOCKING step. The generator also cross-checksMathagainstStrictMathon the whole domain.- A 1500-round cap in place of the map's 10 000, applied through the
engine's own
currentRound >= rounds − 1. Elevation 6 floods at round 1413 and 7 at 1546, so by 1500 every HQ has either been terraformed above the water or has drowned: the game is decided by the cap, not merely stopped. - No indicator dots or lines, no profiler, no crossplay, no
.bc20output. - The
deadlinewall-clock stop is a coworld concept, not an engine one. It is recorded as ONE load-bearing record (plan.abandon_after[g]) and applied by the same proc on record and on playback. - Both chassis are behaviour ports parameterised by the doctrine sheet.
bowl-of-chowderis Bowl of Chowder's ideas rewritten in Nim;examplefuncsplayeris ported statement for statement and may not gain behaviour, because it is one side of the differential oracle. - 18 of the 52 official maps are converted.
CowFarmandDidAMonkeyMakeThisare excluded on purpose: both carry tiles atInteger.MAX_VALUE/2elevation, which is legal but makes the elevation shading meaningless and the timing untypical. - The transaction pool is drained by a stable sort, not by a binary heap.
Transaction.compareTois a total order except for two transactions that agree on cost, on the 32-bit random id and on all seven ints; the sorted extraction is deterministic wherePriorityQueue.pollis unspecified. maptestsmallcarries soup on all 1024 tiles. It is kept anyway, because it is the map the parity oracle and the fast smoke run on; the anomaly is noted here rather than hidden.Infinityhas no tile atMIN_WATER_ELEVATION(its floor is −12) and its HQs start at elevation 0, sotests/test_bc20_maps.nimasserts theMIN_WATER_ELEVATIONguarantee for the other seventeen and recordsInfinity's real floor. The 2020 spec asserts the guarantee; this map does not honour it, and the port follows the file rather than the prose.- The chassis walls before
wall_hq_roundwhen the map demands it. The knob says when to start; the map says when it is too late. Onmaptestsmallthe HQ ring sits at elevation 1 and floods at round 256, so a doctrine that says "wall at 300" has already lost.effectiveWallRoundtakesmin(wall_hq_round, ringFloodRound − 200).wall_hq_round = 0still means never, which is what gives the knob its teeth. - The HQ wall bar is evaluated at the last round the game can reach
(
waterLevel(max(round + 400, maxRounds)) + 2) rather than atround + 400. A moving bar declared the wall closed at round 76 on a map whose ring already sat at elevation 4 and then let the same ring drown at round 932. - The map symmetry is recomputed on every NEUTRAL spawn, not on every
spawn.
TeamControlProviderdelegatesrobotSpawnedby team, and only the cow provider recomputes; cows exist only at map load, so the value is fixed for the match. The port reproduces the delegation, not the prose. - The Fulfillment Center has no
NEED_DRONESbranch. The design note has it build "whenever the roster is under4 + round/300(capped 14) and the pool can pay, and always whenNEED_DRONESis on the chain". Only the first half is implemented: no role in this chassis ever broadcastsNEED_DRONES, so the second branch would guard a signal that never arrives.SigNeedDrones = 5keeps its code point — renumbering the signal table would change the meaning of every message in every recorded match — and is marked reserved inchassis/signals.nim. - The builder-miner's order carries a Refinery, and its net guns stand off
the HQ ring. The design note's order is: Design School →
net_gun_ringNet Guns on the HQ ring → Fulfillment Center → Vaporators → a second Design School after round 600. Whatchassis/miner.nimbuilds is Design School → Refinery → Net Guns → Fulfillment Center → Vaporators → second Design School, with every building at Chebyshev 2 from the own HQ and the Refinery at Chebyshev 4. Both moves are forced by rules the note's order fights:- a walled HQ sits eight elevation steps above the ground outside its
ring and
MAX_DIRT_DIFFERENCEis 3, so once the wall closes a miner can no longer climb to the HQ to deposit. Without a second drop-off the economy stops at exactly the moment the wall succeeds. The Refinery also refines its own 20 a round; - dirt dropped on a building buries it (rule 6.6), and the HQ ring is precisely what the landscapers raise. A net gun on the ring is buried by its own team's wall, so the ring is the one place it may not stand.
- a walled HQ sits eight elevation steps above the ground outside its
ring and
- A non-flying unit that moves into water is DESTROYED, not refused. The
rule as usually stated — "a drone may enter a flooded tile; nothing else
may" — reads like a legality check, and it is not one. In
engine/src/main/battlecode/world/RobotControllerImpl.java:382-401at the pinned commit7618f6b,movecallsassertCanMove, which tests type, adjacency, the map bounds, occupancy,MAX_DIRT_DIFFERENCEand readiness and never mentions flooding; the flood test comes afterwards and callsdisintegrate(), which throwsRobotDeathException(:937-939) and ends the turn.GameWorld.updateRobot:190-191then destroys the robot. So the move is legal, the mover dies, and the tile stays empty.world.canMovereproduces the assert exactly (no flood test) andworld.movereproduces the disintegration; the port destroys the mover at that point rather than at the end of its own turn, which no other body can observe because nothing acts in between. Neither chassis ever plans such a move —pathing.nimexcludes flooded and about-to-flood tiles — so this is the rule for a doctrine that would.
Where the archetypes come from
The four openings — rush, lattice, passive lattice, turtle — are the year's
published metagame, taken from Stone Tao's postmortem
(https://stonet2000.github.io/battlecode/2020/) and from the spec, not from
any bot's source. IvanGeffner/battlecode2020 declares no licence and is
cited here only as the public record of what the winning archetypes were: it is
not vendored, not ported, not compiled and not read into any file in this
repository.