# 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 renamed `tankroyale_botapi`) - `jsony >= 1.1.5` ```sh nimble install robocode_tankroyale_botapi ``` The `runBot` task asks Nimble where each of those packages actually lives (`nimble path `) 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 ` instead of a compiler error. ## Run ```sh ./DevControlBot/DevControlBot.sh ``` `DevControlBot.sh` is the **BotLauncher entry point**. It is a thin wrapper: it locates itself, `chdir`s 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). ```sh ./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=value` and `export NAME=value` lines; blank lines and `#` comments ignored; anything that is not a simple `NAME=value` identifier assignment is ignored (a `.env` can never smuggle in a command). - **CRLF tolerant:** `CR` characters are stripped, so a Windows-edited file does not turn the value into `bar\r`. - **Nothing is ever printed.** Only the *names* taken from the file are reported, never a value, and `set -x` is not used. - **Overridable path:** `$DEVCONTROLBOT_ENV_FILE` points 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. ```sh 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](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 ` 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 ` | | `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: ```sh 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](AGENTS.md) for the convention to follow when tests are added.