Build a tactic in the arena languages

These three small robots show how a language's programming model shapes its motion and decisions. Each source runs in Classic 2D. Read it, run a match, then change one decision and replay the same seed to see its effect.

Robot Language idea What to watch First experiment
Layered Guard Independent prioritized policies claim individual controls It keeps aiming while boundary steering or a battery reserve takes priority Raise the reserve threshold from 20 to 30; compare firing gaps and battery
Patient Lock Entry actions, held conditions and persistent phases It commits to one orbit side, keeps a brief lost lock and waits for sustained charge before attacking again Change the six-tick lost-contact hold; compare lock retention and time spent searching
Filtered Orbit Words transform values; memory makes a reusable controller Hull steering changes gradually while turret tracking stays responsive Change the slew step from 0.08 to 0.04; compare turn smoothness and tracking lag

Run a real match

Install Node.js 20 or later with npm. Create a project for the example you want to try; no additional compiler is needed for these three languages:

npx @clunkyard/cli new vane layered-guard
cd layered-guard

Replace bot.vane with the complete Layered Guard source below. In bot.json, set supportedModes to ["2d"] and supportedRulesets to ["classic"], matching this example's declarations. Keep the other manifest fields. Then run:

npx @clunkyard/cli match 742 2d classic
npx @clunkyard/cli replay

Open the viewer address printed by the replay command. The match plays against the bundled practice rival and saves replays/trial-742.json in your project. To try Patient Lock or Filtered Orbit, use new cadence patient-lock or new glyph filtered-orbit, replace bot.cadence or bot.glyph with its source below, and make the same manifest edits.

Keep the seed and opponent fixed while comparing an edit, then try several seeds. Watch damage, shots, battery and time spent without a contact alongside the final placing. A smoother turn can also take longer to face danger.

If you already have the full arena source checkout, you can also run the three examples against each other from its root:

node src/cli.js match --mode 2d --ruleset classic --ticks 600 --seed 742 \
  --resources dsl --dsl-ops 1024 \
  --bot @bots/dsl/vane/layered-guard.json \
  --bot @bots/dsl/cadence/patient-lock.json \
  --bot @bots/dsl/glyph/filtered-orbit.json \
  --serve

Open the viewer address printed by the CLI. Remove --serve for a batch run.

The examples concentrate on their controller. For storm escape, sudden-death navigation and 3D force control, continue with the full new-robot starters or the house robots Harrier, Skyhook and Loom.

Vane: combine policies without copying targeting

Layered Guard's boundary rule has priority 400 and writes movement. Its battery reserve rule has priority 300 and writes a slow drive request and fire false. The target rule at priority 100 can still write aim and scan. When all three match, the boundary rule owns movement, the reserve rule vetoes the shot, and the target rule tracks the contact. The patrol rule fills any remaining fields.

A false action claims a field just as a true one does. Omitting fire from the reserve rule would leave the target rule free to fire. Each rule condition is still checked every decision; priorities select controls rather than skip the rest of the program.

Try adding a high-priority rule that writes only scan-arc. Observe how that changes sensing without replacing locomotion or aiming. Use Vane when the tactic reads naturally as several reactions that should coexist.

Cadence: make commitment and patience explicit

Patient Lock requires two consecutive echoes before selecting attack. The transition tick still uses search controls. On the following tick, attack chooses an orbit side once with enter set side ...; hull rotation does not keep changing that choice.

While attacking, each fresh contact updates last-bearing. Six consecutive misses return it to search. Shorter gaps keep the last bearing for aiming and scanning, but contact remains part of the fire condition: a stored bearing does not become a new firing solution.

Low battery selects recover. It stops firing and slows down until the battery stays above 35 for four decisions. The entry threshold is below 18. Those separate thresholds and the held exit prevent repeated state changes around a single cutoff. A timed transition also caps an attack at 100 state ticks.

Try inserting another transition above the timeout. The first ready transition wins. Use Cadence when a decision needs a clear beginning, a duration or a deliberate commitment that survives changing observations.

Glyph: compose a controller from values

Filtered Orbit separates a desired course from the control that follows it:

course steer-towards dup turn! status-value!

course leaves one heading. steer-towards converts it into a shortest signed angle, scales and clamps it, then passes it through slew. That word moves the stored steering value towards the desired value by at most 0.08 and leaves the result on the stack. dup lets two consumers use it: the turn setter and numeric spectator status.

Only the hull command passes through this filter. The turret and scanner receive current contacts directly. Memory persists when contacts disappear; the data stack starts empty on the next decision. The comments above words describe their stack effects: ( desired -- filtered ) means one input and one output.

Try calling steer-towards with a boundary-avoidance heading. The same filter can serve several course sources without copying its state update. Use Glyph when reusable transformations and explicit data flow make the controller easier to reason about.

Complete example sources

Layered Guard source

Save this as bot.vane. Repository source.

# Classic 2D workshop: compose independent steering, targeting and battery
# policies. Higher rules claim individual controls, not the whole decision.
bot Layered Guard
modes 2d
rulesets classic

rule boundary priority 400 when x < 70 or x > width - 70 or y < 70 or y > height - 70
  turn clamp(angle(atan2(height / 2 - y, width / 2 - x), heading) / 45, -1, 1)
  drive 0.5
end

# This veto owns fire even when target matches. It leaves aim and scan free.
rule reserve priority 300 when battery < 20
  fire false
  drive 0.2
end

rule target priority 100 when contact
  aim-heading bearing
  scan-heading bearing
  scan-arc 20
  power 1
  fire cooldown == 0 and abs(aim-error) < 3
  turn clamp(angle(bearing + 90, heading) / 45, -1, 1)
end

rule patrol priority 10 when true
  drive 0.5
  turn 0.2
  scan-heading sweep(12)
  scan-arc 40
end

Patient Lock source

Save this as bot.cadence. Repository source.

# Classic 2D workshop: confirm a lock, commit to an orbit side, tolerate
# brief lost echoes and recover battery before starting another attack.
bot Patient Lock
modes 2d
rulesets classic
initial search

state search
  enter set last-bearing heading
  each set last-bearing contact * bearing + (not contact) * last-bearing
  each drive 0.4
  each turn 0.2
  each scan-heading contact * bearing + (not contact) * (scan-heading + 36)
  each scan-arc 40
  when battery < 18 -> recover
  when contact for 2 -> attack
end

state attack
  # Choose a side once, rather than reverse it whenever the hull turns.
  enter set side 1 - 2 * (angle(heading, bearing) < 0)
  each set last-bearing contact * bearing + (not contact) * last-bearing
  each drive 0.45
  each turn clamp(angle(last-bearing + side * 90, heading) / 45, -1, 1)
  each aim-heading last-bearing
  each scan-heading last-bearing
  each scan-arc 20 + 20 * (not contact)
  each power 1
  each fire contact and cooldown == 0 and abs(aim-error) < 3 and battery >= 20
  when battery < 18 -> recover
  when not contact for 6 -> search
  after 100 -> recover
end

state recover
  each drive 0.15
  each turn 0.12
  each scan-heading scan-heading + 36
  each scan-arc 40
  # Different entry/exit thresholds avoid thrashing between states.
  when battery > 35 for 4 -> search
end

Filtered Orbit source

Save this as bot.glyph. Repository source.

# Classic 2D workshop: build a controller from value-transforming words.
# The turn command approaches its target by at most 0.08 each decision;
# that filter keeps its history even when a contact appears or disappears.
bot Filtered Orbit
modes 2d
rulesets classic
memory steering 0

# ( desired -- filtered ): store the new command and leave it for a setter.
: slew
  steering@ - 0.08 neg 0.08 clamp steering@ + dup steering!
;

# ( target-heading -- turn-command )
: steer-towards heading angle 45 / clamp1 slew ;

# ( -- target-heading ): leave a value, rather than write a control here.
: course
  contact if bearing 90 + else heading 18 + then
;

: target
  bearing dup aim! scan-heading!
  20 scan-arc!
  1 power!
  cooldown 0 = aim-error abs 3 < and battery 20 >= and fire!
;

course steer-towards dup turn! status-value!
battery 20 < if 0.2 else 0.45 then drive!
contact if target else heading tick 12 * + scan-heading! 40 scan-arc! then

Download this page as Markdown