The bot now drives its radar every tick. It still never moves and never
fires: body, turret and gun stay white ("not programmed"), while the
radar paints itself black at init to mark itself as working.
- modules/radar.nim replaces the earlier multi-module radar design with a
single module holding the whole of it: target memory, mode selection
and both commands
- two commands, picked from the only fact the bot is sure of (the API's
getEnemyCount()): one enemy plus a target in hand -> LOCK, servo onto
its bearing; anything else -> SWEEP at 45 deg/tick, the radar's physics
cap, a full revolution every 8 ticks
- the lock turns by the delta to the target's bearing plus 5 deg PAST it,
so the radar CROSSES the bearing every tick instead of settling on it.
That crossing is what produces a fresh ScannedBotEvent every tick: a
scan per tick, with no rescan to wait for. Turning by the bare error
parks the radar on the bearing and the scans stop
- commandRadar() is called before go() on every path, so there is no idle
branch to fall into and switching modes costs zero turns
- idea derived from ModularBot's radar (Apache-2.0) via common_libs/;
common_libs/ is not edited in place
- AGENTS.md gains mermaid behaviour diagrams plus pointers to the physics
and coordinate references
- version bumped to 1.1.0 in both DevControlBot.nimble and
DevControlBot.json so the two agree
Compiled against robocode_tankroyale_botapi 1.0.7.
18 KiB
DevControlBot — Garage Agent Guide
⚠️ READ THIS FIRST — THIS GARAGE IS INTENTIONALLY NOT THE ROOT TEMPLATE
The layout and format described in the repo-root
../../AGENTS.mdtemplate do NOT apply toDevControlBot_garage/. The root template documents asrc/<bot>.nim/tests//out/convention for garages. This garage deviates from it on purpose and must stay that way.Do NOT, even to "fix" a perceived inconsistency:
- create
src/,tests/, orout/- add a
config.nims,nimble.paths,--pathflags, or a vendor/ directory- restructure
DevControlBot/to match the root templateBuilds go to a
mktemp -dthrowaway dir that is removed on exit — nothing is ever written here. The rootAGENTS.mdand its folder conventions are for the other garages; here it is context only (issue tracker, Nim conventions), and is NOT a layout contract for this folder. If the root template and this file disagree about layout, this file wins.
⚠️ DO NOT TOUCH
PLAN.md
PLAN.mdis the user's personal working notes file. The user maintains it and edits it exclusively.
- Agents must NEVER modify, rewrite, reformat, move, delete or commit changes to
PLAN.md— unless the user explicitly asks in that conversation.- Reading it is not assumed or expected; do not open it by default.
- It IS tracked in git and is intended to be committed (deliberately added in commit
59fad00b). Do NOT gitignore it and do NOTgit rm --cachedit.
Start here
This file is the working rules for the bot. The human-facing description lives in README.md — read it first.
Garage-specific rules for DevControlBot_garage/. Repo-wide rules live in
../../AGENTS.md (issue tracker, Nim conventions, test
framework) — read it for that context, but note again: its folder-layout
template does not govern this garage.
Conventions
- This bot's code lives only inside
DevControlBot_garage/. Do not edit other garages to make this bot work. - Do not reuse or copy another bot's game logic (movement/scanning/firing, radar
tables, targeting heuristics) without explicit permission from the user. Shared,
bot-agnostic code belongs in
common_libs/, and only with permission. - The radar is the one part currently driven. All of it is
DevControlBot/modules/radar.nim— two procs (onScan,commandRadar), three variables (the target's position, whether we have one, ticks since it was last scanned), three constants. Nothing else; do not split it back into harness/lock/melee modules.commandRadar()issues exactly onesetRadarTurnRateper tick on every path: lock whengetEnemyCount() == 1and we hold a target scanned withinFreshScanTurns, the 45 deg/tick sweep otherwise (that same sweep is both the search and the multi-enemy answer — there is no separate melee mode). No counter, no seen-id set, no mode enum; the server's enemy count drives the choice, so a death switches modes on the spot and no tick is ever left without a command. The lock commands the delta to the target's bearing plusOvershootDegpast it, so the radar sweeps across the bearing every tick and re-scans the target every tick. That overshoot is the whole reason the lock works, and it is whysetRescan()is not needed (the radar is never idle). Never turn by the bare error and never hold at rate 0: that parks the radar on the bearing, the scans stop, and the target is lost until a sweep re-finds it. A lock is only taken on a target seen withinFreshScanTurns; a working lock re-scans every tick, so that threshold can never throw a good lock away, and it prevents a blind slew at a stale position just after the count drops to 1.run()commands the radar before everygo(), so the command is part of the tick's intent;onScannedBotis a one-liner,onScan(e.x, e.y), which remembers the target's absolute position and issues no command. Angles are 0° = east, positive = counter-clockwise = left (see README for the API proof). Never the blockingrescan(). Provenance: ModularBot / common_libs (Davide Cappellini, Apache-2.0); see README.
Build artifacts / binaries
- Rule: never write a binary, log or build output into this garage. The
runBottask builds into amktemp -dthrowaway dir and removes it with anEXIT/INT/TERMtrap, so noout/directory is ever created here. - Rule: never commit binaries or nimble cache dirs (
~/.nimble,nimbledeps/).nimbledeps/only appears withnimble develop; ignore it. - Rule: dependencies come from the global nimble store. Do not vendor a
copy or add
nimble.paths/nimble develop—nimble install robocode_tankroyale_botapiis the only setup step. (The package was renamed fromtankroyale_botapi; the old name is gone.) Exception: therunBottask derives--path:flags at run time fromnimble path <pkg>, because the compiler's bundlednimblepath="$home/.nimble/pkgs2/"only resolves when$HOMEis set. That is resolution, not vendoring; keep it. - Build/run with the folder task, never raw
nim c:
cd DevControlBot_garage/DevControlBot
nimble runBot
- Note: the root
.gitignoreclaims "all builds go to*_garage/out/". That is no longer true for this garage — do not rely on that rule, and do not add anout/directory just to satisfy it. (Root.gitignoreis not this garage's file; leave it alone unless the user asks.)
Running the bot
DevControlBot/DevControlBot.sh is the canonical entry point and works from any
cwd — it resolves SCRIPT_DIR from BASH_SOURCE, cds to the .nimble dir,
forwards args and signals to nimble runBot, and propagates the exit code.
./DevControlBot/DevControlBot.sh # from the garage root: bundled metadata
./DevControlBot/DevControlBot.sh /path.json # alternate metadata (first arg, must end in .json)
./DevControlBot/DevControlBot.sh --debug # classic debug build
./DevControlBot/DevControlBot.sh --help # usage, exits 0 without building
-
Rule: no arguments means RUN, not usage. A bare
./DevControlBot.shperforms a quiet RELEASE build and runs the bot with the bundledDevControlBot.json, exactly as if that path had been passed. Usage is printed only for an explicit-h/--help, which exits 0 without building. (An earlier version printed usage and exited 0 with no arguments — that was the bug, and it is fixed.) -
There is no
--jsonflag: the metadata path is the first positional argument and is used when it ends in.json. Anything else is forwarded to the bot verbatim. -
Build mode: default is release (
nim c -d:release, quiet: no compiler hints, no dot-progress line, just[devcontrolbot] build ok (release)).--debugselects the classic debug build (nim c, full hints/diagnostics). The flag is stripped by the wrapper in any position and reaches therunBottask through the exported env var$DEVCONTROLBOT_DEBUG(debug/release), never as a bot argument. Compiler errors are shown in both modes; a failed release build replays the captured compiler log, so it is never silent. -
With no arguments (or
--help) the wrapper prints usage and exits 0 without building. -
Gotcha:
nimble runBotmust be invoked fromDevControlBot/, the directory containing the.nimble. FromDevControlBot_garage/it fails withCould not find a file with a .nimble extension inside the specified directory. -
compile/buildare reserved Nimble builtin names — hencerunBot. -
Without a Tank Royale server at
SERVER_URLthe run fails with[start] Cannot connect ... Connection refusedand exit code 1. That is expected during local verification, not a bug.
Configuration = environment variables (the API's own mechanism)
- Rule: never add env-reading code to
DevControlBot.nim. The bot API already reads its configuration from the process environment insidestart():SERVER_URLandSERVER_SECRET(robocode_tankroyale_botapi.nim:424-425, documented at:396-397) andBOT_NAME/BOT_VERSION/BOT_AUTHORS/ … inbot_info.nim:117-130(used when no JSON metadata is found). That is the supported mechanism the official BotLauncher uses;DevControlBot.nimcalls plainstart(bot, jsonPath)and needs no change. - The
runBottask makes those variables present for local runs (see the.envsection). That is all it does; it does not interpret them.
Optional .env (local runs only)
DevControlBot/.env holds the local configuration (typically SERVER_URL and
SERVER_SECRET). Rules:
- It is optional. If it is missing the run is unchanged: no error, no non-zero exit, just one informational line and the API defaults.
- Loaded in the
runBottask's shell, not inDevControlBot.sh, so it works for both./DevControlBot.shand a directnimble runBotand the wrapper stays a thin wrapper.$DEVCONTROLBOT_ENV_FILEoverrides the path. - Precedence — CRITICAL: the environment always wins. A variable already
present in the environment is never overridden by
.env, so the official BotLauncher's values keep winning. It is achieved by filtering: aNAME=valueline is dropped whenNAMEis already set ([ -n "${NAME+x}" ], set-but-maybe-empty), and only the still-unset names areset -a-exported and sourced. Do not "simplify" this into a plainset -a; . .env— sourcing does the opposite and would let the file beat the launcher. - Accepts
FOO=barandexport FOO=bar, stripsCR(CRLF), skips blanks,#comments and any line that is not a plainNAME=valueidentifier. - Never print values and never use
set -x: report variable names only. - Never commit it — it holds secrets. The root
.gitignorecoversModularBot_garage/.envonly and does not ignore this one; the rule to add (ask the user before touching the root file) isDevControlBot_garage/DevControlBot/.env.
Failure reporting / exit codes
- Rule: an expected failure must never surface as a Nimble/NimScript
exception.
execraises on any non-zero exit, printing a stack trace and an escaped copy ofrunScript. So the shell always exits 0 and writes the real code to$DEVCONTROLBOT_STATUS_FILE;DevControlBot.shexports that path and re-exits with the code. Never mask it to 0, never let the shell's code be swallowed. - Exit codes (keep them distinct, they are what BotLauncher branches on):
0clean,1no game server atSERVER_URL(expected),2dependency not installed,3compile failure, anything else = the bot's own code. Each prints its own short, actionable line; compile failures keep the raw compiler errors. - Running
nimble runBotdirectly (no status file) intentionally falls back to the legacy behaviour: the shell exits with the real code and Nimble raises.
Test organization
- There are currently no tests in this garage. No
tests/directory, notests/config.nims, and the.nimblehas exactly one task (runBot) — the oldtest,compileBotandsetupVendortasks are gone.nimble testdoes not exist; do not invoke it and do not document it. - Convention when tests are added (repo-wide, see
../../AGENTS.mdandcommon_libs/test_framework/README.md):-
create
DevControlBot_garage/tests/withtests/config.nimscontaining--path:"../../common_libs"(depth must match the garage location), onetest<concern>.nimper concern (e.g.test_basic_battle.nim); -
add a
testtask toDevControlBot.nimblerunningnim c -r --path:../common_libs tests/test_basic_battle.nim; -
guard first, import second — tests must pass with no Java present:
import std/os if not existsEnv("TR_SERVER_JAR") or not existsEnv("TR_BATTLE_RUNNER"): echo "Skipping: TR_SERVER_JAR / TR_BATTLE_RUNNER not set" quit(0) import test_framework/test_framework -
shared adversaries:
common_libs/test_framework/adversaries/SittingDuck(passive) andOscillatorBot(fights back); standard call isrunBattle(@[myBotDir, adversaryDir], rounds = 10). -
env vars:
TR_SERVER_JAR,TR_BATTLE_RUNNER. Failures surface asOSError(bot compile failed),IOError(runner non-zero),TimeoutError(server 15s / compile 30s per bot / battle runner).
-
Onboarding — full sequence
# 1. dependency (once; already global if nimble install says "already installed")
nimble install robocode_tankroyale_botapi
# 2. run the bot (compiles to a temp dir, runs, cleans up; nothing left behind)
./DevControlBot/DevControlBot.sh
# 2b. same thing via the task — must be run from DevControlBot/
cd DevControlBot && nimble runBot
# 3. tests — none exist yet; see "Test organization" above
Checklist after touching build config:
cd DevControlBot && nimble runBotcompiles (fails only on "Cannot connect" without a server) and leaves the garage tree unchanged (ls DevControlBot_garagestill shows onlyAGENTS.md,README.md,DevControlBot/).
Layout of this folder
DevControlBot_garage/
├── AGENTS.md # this file
├── README.md # human-facing description
└── DevControlBot/
├── DevControlBot.nim # bot type + entry point (isMainModule)
├── DevControlBot.json # bot metadata
├── DevControlBot.nimble # single task: runBot
├── DevControlBot.sh # BotLauncher entry point (thin wrapper)
└── .env # OPTIONAL local config; secrets, never commit
Coordinates & angles (source: robocode.dev/articles/coordinates-and-angles.html)
Source: https://robocode.dev/articles/coordinates-and-angles.html (Tank Royale docs)
- Cartesian coordinate system; (0, 0) is the bottom-left corner of the arena.
- Y up is implied by the origin being bottom-left (page never states it explicitly); X east is confirmed via the angle rules below.
- Angles follow classic trig (unlike original Robocode, whose 0/360 was north and 90 east).
- 0°/360° = east; 90° = north; 180° = west; 270° = south.
- Positive angles go counterclockwise; turning right decreases the angle (clockwise).
- Full turn = 360°.
- NOT stated on this page: distance formula, bearing/angle-between-two-points formula, angle normalization/wrapping, worked example numbers.
Behaviour diagrams
CURRENT STATE, not a design sketch. These describe the code as it is today.
Diagram 1 — one tick. Owned by DevControlBot/DevControlBot.nim (run, lines
34-40) and DevControlBot/modules/radar.nim (onScan, commandRadar) — update
this diagram when either file changes. The ordering is the whole point: the
command is issued BEFORE go() so it travels with this tick's intent, and
events arrive AFTER, so a scan can only ever steer the NEXT tick.
sequenceDiagram
participant Run as run loop
participant Radar as radar.nim
participant Bot as bot + API
participant Srv as server
Run->>Radar: commandRadar<br/># one command, every tick, no idle branch
Radar-->>Bot: setRadarTurnRate<br/>LOCK turn + 5deg overshoot,<br/>or +45deg sweep
Run->>Bot: go<br/># intent sent here, FIRST
Bot->>Srv: tick intent<br/>radar turn only,<br/>no move, no fire
Srv-->>Bot: tick result<br/>+ pending ScannedBotEvent
Bot->>Radar: onScannedBot -> onScan x, y<br/># AFTER go: remembers only
Radar-->>Radar: target position stored,<br/>tick counter reset
Note over Run,Radar: that remembered target is used<br/>by the NEXT commandRadar
Diagram 2 — radar behaviour. Owned by DevControlBot/modules/radar.nim
(commandRadar, lines 70-81) — update this diagram when that file changes.
SWEEP is not a separate lifecycle: it is the same single command the lock
replaces, so switching costs no ticks.
stateDiagram-v2
[*] --> SWEEP
state "SWEEP<br/>360 sweep at 45 deg/tick,<br/>full revolution every 8 ticks" as SWEEP
state "LOCKED<br/>servo onto target bearing<br/>PLUS 5 deg overshoot" as LOCKED
SWEEP --> LOCKED: "getEnemyCount is 1<br/>AND target seen within<br/>FreshScanTurns 1"
LOCKED --> SWEEP: "enemy count is not 1,<br/>or target stale beyond 10 ticks,<br/>or lost"
LOCKED --> LOCKED: "scan every tick<br/>because the radar crosses<br/>the bearing"
The 5 deg overshoot is the reason the lock works: the radar sweeps ACROSS the
bearing instead of parking on it, so a fresh ScannedBotEvent arrives every
tick. Turning by the bare error, or holding at rate 0 inside a deadzone, stops
the scans and loses the target until a sweep re-finds it. Never replace the
overshoot with a deadzone hold.
Physics (source: robocode.dev/articles/physics.html)
Source: https://robocode.dev/articles/physics.html (Tank Royale docs)
- Turns/rounds 1-based; each round restarts at turn 1. Distance units: floating-point double.
- Acceleration: +1 unit/turn; deceleration: 2 units/turn (braking 2x faster than accelerating).
- Speed: v = a * t; max speed 8 units/turn. Velocity direction == bot heading.
- Distance: d = v * t.
- Rotation in degrees. Base max turn rate: 10 - 3/4*|v| deg/turn (10 deg/turn standing still; 4 deg/turn at max speed 8).
- Gun rotation max 20 deg/turn (added to bot's current rate).
- Radar rotation max 45 deg/turn (added to gun's current rate).
- Firepower: min 0.1, max 3; energy cost subtracted from bot energy.
- Bullet damage: 4 * firepower, plus 2 * (firepower - 1) if firepower > 1.
- Bullet speed: 20 - 3 * firepower units/turn (19.7 at fp 0.1; 11 at fp 3); constant per shot.
- Gun heat gain: 1 + firepower/5. Cannot fire while heat > 0. Gun starts at heat 3 each round.
- Energy gain on bullet hit: 3 * firepower.
- Collision with bot/wall stops the bot, except when moving away from the bot that hit it.
- Bot-vs-bot collision: 0.6 damage to each.
- Ramming (moving forward into another bot): both take damage; rammer gets ramming kill bonus. Wall damage: |v|/2 - 1, clamped at 0 if negative.
- NOT stated on this page: coordinate system/axes/units, Y up or down, angle origin & clockwise/CCW sense, distance/bearing formulas, angle normalization/wrapping, max energy, per-turn energy regain from inactivity.