AGENTS.md is now an inventory of what exists plus the mermaid behaviour diagrams — not a narration of the code. The source and its doc comments are the carrier of truth for how the bot works; a prose retelling in AGENTS.md only goes stale against them. Removed: blocks that duplicated what the source already says, a stale line that contradicted DevControlBot.sh's actual no-argument behaviour, and the prior bug-fix history (which is a record of the past, not guidance for the next change). 384 -> 238 lines. Kept byte-identical: the behaviour diagrams, the rules (git, version/tag, colour convention), the build and run commands, and the physics and coordinates references. Two constraints stay as one-liners, because they are the two ways this bot has actually been broken: command the radar BEFORE go(), and keep all radar logic in modules/radar.nim.
DevControlBot
Control skeleton bot: it boots, participates in the battle, and drives no movement and no gun. The radar is the only driven part.
All radar logic is one module, modules/radar.nim (90 lines including its
comments; two procs, three variables, three constants). run() just calls
commandRadar().
Two commands, one decision. Exactly one enemy alive and a target in hand ->
lock (servo onto the target's bearing). Anything else -> sweep at
MaxRadarTurn = 45 deg/tick, the radar's physical cap, i.e. a full revolution
every 8 turns. The sweep does double duty and needs no separate "melee" mode: it
is both the search when no target is in hand and the right answer for 2+ enemies
(a full revolution re-scans every enemy as fast as the physics allow, and locking
one of several is worthless). The choice is made from the API's
getEnemyCount() (bot.nim:166, from tick.botState.enemyCount, bot.nim:1295;
field at schemas.nim:134) every tick — the server's alive count, so it cannot
drift and it shrinks by itself when an enemy dies. No counter, no seen-id set, no
mode enum, no dispatch table.
There is never a tick without a command. commandRadar() ends in
setRadarTurnRate(...) on every path — the sweep is not a state the lock falls
back into, it is the single command the lock replaces. Switching costs zero
turns, in either direction, by construction.
The lock works because of the 5° overshoot. The commanded rate is the delta
from the radar's current heading to the target's bearing, plus 5° past it
in the direction of the turn, clamped to ±45. So the radar sweeps across the
bearing every tick instead of settling on it — which is what produces a fresh
ScannedBotEvent every tick. (Turning by the bare error, or holding inside a
deadzone, parks the radar on the bearing: the scans stop and the target has to be
re-found by the sweep, which is the delay this design removes. The setRescan()
trick this used to need is unnecessary here — the radar is never idle.) The
bearing is recomputed from the target's remembered absolute position each
tick, so the lock keeps correcting a target, and a radar, that have moved.
Lost-target safety. scanlessTicks counts the ticks since the target was last
scanned (reset by every scan) and FreshScanTurns = 10 is how long a remembered
target stays usable — one sweep revolution plus slack. This can never throw a
good lock away: a working lock re-scans its target every tick, so the counter
never gets past ~1. It only bites on a target unseen for a while, where locking
would slew blindly at a stale point; the sweep re-finds it within one revolution
instead. The target is then dropped and the sweep resumes on the same tick.
onScannedBot is a one-liner — onScan(e.x, e.y) — and issues no command:
go() sends the tick's intent before dispatching the pending events, so
commanding first means the command is part of THIS tick's intent, while
onScannedBot only feeds the next one.
Angles are 0° = east, positive = counter-clockwise = turn LEFT: proven from
the installed API — directionTo is 180 * arctan2(dy, dx) / PI
(utils.nim:146-165, its "0 = North" doc comment contradicts its own code), and
setTurnRadarLeft sets a positive rate while setTurnRadarRight is
setTurnRadarLeft(-degrees) (bot.nim:1062-1070).
Provenance. The design is ModularBot's (Davide Cappellini, Apache-2.0), i.e.
common_libs/radar_lock/radar_lock.nim (doRadar, which is where the overshoot
comes from) and common_libs/radars/melee_scan.nim, picked per tick exactly this
way in ModularBot.nim: let targetMode = if getEnemyCount() == 1: 0 else: 1.
common_libs/ is never edited in place.
Measured (1 round each, RadarSpy observer; scans/turn, idle = turns with no radar command, max gap = longest run of turns without a scan):
| battle | version | scans/turn | idle turns | max scan gap |
|---|---|---|---|---|
| 1v1 Walls | before | 0.513 | 391 / 509 | 33 turns |
| 1v1 Walls | now | 0.960 | 0 | 1 turn |
| vs 2 adversaries | before | 0.256 | 0 | 8 |
| vs 2 adversaries | now | 0.278 | 0 | 8 |
| vs 2 adversaries (3 bots, mixed) | before | 0.462 | 233 | 32 |
| vs 2 adversaries (3 bots, mixed) | now | 0.548–0.644 | 0 | 8 |
The 2-enemy rows are identical by construction (both versions issue the same 45 deg/tick sweep). When the count drops to 1 mid-round the new radar is acquiring again within 6 turns and never parks; the old one took 29 turns and sat at rate 0 for 26 of them.
The radar keeps moving forever: sweeping at full rate until an enemy is
scanned, servoing onto it while it is the only one alive, spinning at the cap as
soon as a second one is alive, and dropping straight back to the lock when one of
them dies. All of it is in DevControlBot/modules/radar.nim.
Body, turret, gun and radar all start plain white, which by team convention
means "not programmed yet". The radar then claims its own colours when it
starts working: initRadar() (DevControlBot/modules/radar.nim) paints the radar
and the scan arc black — black = "programmed and running" — while
body/turret/gun stay white (this bot never moves and never fires). Colors are set
once (startup + radar init), never inside the tick loop.
Layout
DevControlBot_garage/
├── AGENTS.md
├── README.md
└── DevControlBot/
├── DevControlBot.nim # bot type + entry point (isMainModule)
├── DevControlBot.json # bot metadata
├── DevControlBot.nimble # the single `runBot` task
├── DevControlBot.sh # BotLauncher entry point
└── .env # OPTIONAL local config (secrets; never commit)
There is no src/, no tests/, no out/, no config.nim, no nimble.paths
and no vendor directory. DevControlBot.nim is both the bot implementation and
the program entry point.
Dependencies
Only two, both installed globally with Nimble and resolved from the global
package store — nothing vendored, no nimble.paths, no nimble develop:
robocode_tankroyale_botapi >= 1.0.7(the renamedtankroyale_botapi)jsony >= 1.1.5
nimble install robocode_tankroyale_botapi
The runBot task asks Nimble where each of those packages actually lives
(nimble path <pkg>) and passes explicit --path: flags to nim c. This is
required because the compiler's own nimblepath="$home/.nimble/pkgs2/" only
works when $HOME is set — with an empty environment (env -i, Nix build
sandbox, CI) the packages are otherwise not on the search path. If a package is
not installed, the build stops with run: nimble install <pkg> instead of a
compiler error.
Run
./DevControlBot/DevControlBot.sh
DevControlBot.sh is the BotLauncher entry point. It is a thin wrapper: it
locates itself, chdirs to the directory holding the .nimble file, forwards
arguments and signals to nimble runBot, and propagates the real exit code.
All build/run/cleanup logic lives in the runBot task of DevControlBot.nimble,
which creates a throwaway mktemp -d build dir, compiles there, runs, and always
removes the dir via an EXIT/INT/TERM trap. Nothing is left behind — this
bot never creates an out/ directory.
runBot also accepts a metadata path: as the first positional argument. If it
ends in .json it is used instead of the bundled DevControlBot.json; there is
no --json flag (an argument that does not end in .json is forwarded to the bot
as-is).
./DevControlBot/DevControlBot.sh # bundled metadata (default)
./DevControlBot/DevControlBot.sh /path/to/my.json # alt metadata
./DevControlBot/DevControlBot.sh --debug /path/to/my.json # alt metadata, debug build
With no arguments at all the wrapper does not print usage: it performs a
quiet RELEASE build and runs the bot with the bundled DevControlBot.json,
exactly as if that path had been passed. Usage is printed only for an
explicit -h / --help (exit 0, nothing is built).
Configuration comes from the environment — the API's own mechanism
No configuration code exists in DevControlBot.nim, and none is needed. The
bot API reads its own environment variables inside start():
| variable | read at | default |
|---|---|---|
SERVER_URL |
robocode_tankroyale_botapi.nim:424 (documented at :396) |
ws://localhost:7654 |
SERVER_SECRET |
robocode_tankroyale_botapi.nim:425 |
"" |
BOT_NAME, BOT_VERSION, BOT_AUTHORS, … |
bot_info.nim:117-130, used only when no JSON is found |
see that file |
This is the supported mechanism the official BotLauncher uses: it puts those variables in the bot's process environment before starting the binary. So the only thing the build/run wrapper does is make sure the variables are present in the bot's environment for local runs.
Optional .env (a convenience for local runs only)
DevControlBot/.env is optional. If it is absent nothing happens: no error,
no warning beyond one informational line, the run behaves exactly as before and
the API defaults apply. It is not required, not generated, and not committed.
When present it is loaded by the runBot task, not by DevControlBot.sh, so
it applies identically to ./DevControlBot.sh and to a direct nimble runBot
and the wrapper stays a thin wrapper. Properties:
- Format: plain
NAME=valueandexport NAME=valuelines; blank lines and#comments ignored; anything that is not a simpleNAME=valueidentifier assignment is ignored (a.envcan never smuggle in a command). - CRLF tolerant:
CRcharacters are stripped, so a Windows-edited file does not turn the value intobar\r. - Nothing is ever printed. Only the names taken from the file are reported,
never a value, and
set -xis not used. - Overridable path:
$DEVCONTROLBOT_ENV_FILEpoints the loader at a different file (useful for testing).
Precedence: the environment always wins
A variable already present in the environment is never overridden by the
.env file. This matters because the official BotLauncher's values must win:
if a .env sitting in the checkout could overwrite them, this convenience would
silently break a real BotLauncher run.
The .env file is therefore not simply sourced — sourcing it would do the
opposite, because an assignment in a sourced file overwrites an already-exported
variable of the same name. Instead the file is filtered line by line: a
NAME=value line is dropped when NAME is already set in the environment
(tested with [ -n "${NAME+x}" ], i.e. set but possibly empty, not merely
non-empty), and only the still-unset names are then exported with set -a and
sourced. The filtering is what implements the precedence.
SERVER_URL=ws://host:1234 ./DevControlBot.sh # shell wins, .env ignored for SERVER_URL
./DevControlBot.sh # .env supplies SERVER_URL
.env contains secrets, so it must never be committed; see the note in
AGENTS.md about the root .gitignore.
Build modes: release by default, --debug for the classic build
| invocation | build | output |
|---|---|---|
DevControlBot.sh … (default) |
nim c -d:release --hints:off |
quiet: dependency lines, one build ok (release), one running line |
DevControlBot.sh --debug … |
plain nim c |
classic diagnostics: Hint: used config file …, dot-progress line, DEBUG BUILD hint |
--debug is stripped by the wrapper wherever it appears in the arguments (a
repeated --debug is harmless) and is handed to the runBot task via the
exported env var $DEVCONTROLBOT_DEBUG (debug / release) rather than as a
task argument: an env var can never be mistaken for a bot argument, cannot
collide with the $DEVCONTROLBOT_STATUS_FILE exit-code mechanism, and leaves the
task's argument parsing untouched. Every other argument is forwarded verbatim.
In release mode the compiler's stdout/stderr is captured to a log and replayed
only if the build fails, so a failed build is always loud in both modes while
a successful release build stays short (10 lines including the no-server report,
vs 16 with --debug). --help / -h prints usage and exits 0 without
building; no arguments does the opposite — it builds and runs.
The bot needs a running Tank Royale server at SERVER_URL (the API's documented
default is ws://localhost:7654); without one the run fails with
[start] Cannot connect to <SERVER_URL> and exits 1 (expected during local
verification).
Failure reporting and exit codes
Nimble's exec raises a NimScript exception — stack trace, escaped copy of the
whole script — for any non-zero exit, which made "no game server" look like a
catastrophic build crash. The runBot shell therefore always exits 0 and writes
the real code to the status file named by $DEVCONTROLBOT_STATUS_FILE, which
DevControlBot.sh exports and then re-exits with. The real code is preserved,
not masked.
| exit | meaning |
|---|---|
0 |
clean run |
1 |
bot could not connect to the game server at SERVER_URL (expected locally) |
2 |
a dependency is not installed — nimble install <pkg> |
3 |
compilation failed — the compiler's own errors are printed above |
| other | the bot's own exit code (runtime crash, bad metadata) |
Each mode prints its own short line, e.g.
[start] Cannot connect to ws://localhost:4567: Connection refused
[devcontrolbot] bot exited with code 1.
[devcontrolbot] No game server on ws://localhost:4567 (SERVER_URL).
[devcontrolbot] Start the Tank Royale server, or point SERVER_URL at a running one.
No Exception raised during nimble script execution, no stack trace, no source
dump. nimble runBot run directly (no status file) keeps the old behaviour and
still raises.
Interrupt handling is unchanged: SIGINT/SIGTERM are forwarded, the build dir is
removed by the EXIT trap and the wrapper exits 143 (SIGTERM) / 130 (SIGINT).
Gotcha: nimble runBot must be run from DevControlBot/
The .nimble file lives in DevControlBot/, so the task is only visible from
there:
cd DevControlBot && nimble runBot # works
cd DevControlBot_garage && nimble runBot
# Error: Could not find a file with a .nimble extension inside the specified
# directory: .../DevControlBot_garage
DevControlBot.sh handles this for you by cd-ing itself. Note also that
compile/build are reserved Nimble builtin names — hence the runBot name.
Test
There are no tests in this garage. The test task and the tests/ directory
were removed along with the old build layout; see
AGENTS.md for the convention to follow when tests are added.