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