diff --git a/DevControlBot_garage/AGENTS.md b/DevControlBot_garage/AGENTS.md
index c00f292..4ce1179 100644
--- a/DevControlBot_garage/AGENTS.md
+++ b/DevControlBot_garage/AGENTS.md
@@ -47,9 +47,33 @@ template does not govern this garage.**
- 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.
-- Body, gun and radar are `WHITE` (team convention: that part is not programmed
- yet). Colors are set once at initialization, never in the tick loop. Keep it
- that way: the tick loop must do nothing but `go()` until real logic is written.
+- 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 one `setRadarTurnRate` per tick on every path:
+ lock when `getEnemyCount() == 1` and we hold a target scanned within
+ `FreshScanTurns`, 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 **plus `OvershootDeg` past
+ 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 why
+ `setRescan()` 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 within `FreshScanTurns`; 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 every `go()`, so the command is part of the
+ tick's intent; `onScannedBot` is 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 blocking `rescan()`.
+ Provenance: ModularBot / common_libs (Davide Cappellini, Apache-2.0); see README.
## Build artifacts / binaries
@@ -237,4 +261,84 @@ DevControlBot_garage/
├── DevControlBot.nimble # single task: runBot
├── DevControlBot.sh # BotLauncher entry point (thin wrapper)
└── .env # OPTIONAL local config; secrets, never commit
-```
\ No newline at end of file
+```
+
+## 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.
+
+```mermaid
+sequenceDiagram
+ participant Run as run loop
+ participant Radar as radar.nim
+ participant Bot as bot + API
+ participant Srv as server
+ Run->>Radar: commandRadar
# one command, every tick, no idle branch
+ Radar-->>Bot: setRadarTurnRate
LOCK turn + 5deg overshoot,
or +45deg sweep
+ Run->>Bot: go
# intent sent here, FIRST
+ Bot->>Srv: tick intent
radar turn only,
no move, no fire
+ Srv-->>Bot: tick result
+ pending ScannedBotEvent
+ Bot->>Radar: onScannedBot -> onScan x, y
# AFTER go: remembers only
+ Radar-->>Radar: target position stored,
tick counter reset
+ Note over Run,Radar: that remembered target is used
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.
+
+```mermaid
+stateDiagram-v2
+ [*] --> SWEEP
+ state "SWEEP
360 sweep at 45 deg/tick,
full revolution every 8 ticks" as SWEEP
+ state "LOCKED
servo onto target bearing
PLUS 5 deg overshoot" as LOCKED
+ SWEEP --> LOCKED: "getEnemyCount is 1
AND target seen within
FreshScanTurns 1"
+ LOCKED --> SWEEP: "enemy count is not 1,
or target stale beyond 10 ticks,
or lost"
+ LOCKED --> LOCKED: "scan every tick
because the radar crosses
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.
diff --git a/DevControlBot_garage/DevControlBot/DevControlBot.json b/DevControlBot_garage/DevControlBot/DevControlBot.json
index 4428e73..92bfc17 100644
--- a/DevControlBot_garage/DevControlBot/DevControlBot.json
+++ b/DevControlBot_garage/DevControlBot/DevControlBot.json
@@ -1,6 +1,6 @@
{
"name": "DevControlBot",
- "version": "0.1.0",
+ "version": "1.1.0",
"authors": ["Davide Cappellini"],
"description": "Control skeleton bot — does nothing, bright colors",
"gameTypes": ["classic", "1v1"],
diff --git a/DevControlBot_garage/DevControlBot/DevControlBot.nim b/DevControlBot_garage/DevControlBot/DevControlBot.nim
index 9116a3c..15afe92 100644
--- a/DevControlBot_garage/DevControlBot/DevControlBot.nim
+++ b/DevControlBot_garage/DevControlBot/DevControlBot.nim
@@ -1,14 +1,24 @@
-## DevControlBot — control skeleton: boots, stands still, does nothing.
+## DevControlBot — control skeleton: boots, stands still, and drives nothing but
+## the radar. It never moves and it never fires.
##
-## Team convention: white means "not programmed yet", so body, gun and radar are
-## all plain white. Colors are applied once at initialization, never per tick.
+## Team convention: white means "not programmed yet". At startup body, turret, gun
+## and radar are all plain WHITE. The radar then claims its OWN colours when it
+## starts working: initRadar() paints the radar and the scan arc BLACK, leaving
+## body/turret/gun white (this bot never moves and never fires, so they stay
+## "not programmed"). Colors are applied once, never per tick.
##
## This module is both the bot type and the program entry point (see isMainModule
-## below); there is no separate top-level DevControlBot.nim.
+## below). All radar logic lives in modules/radar.nim; the bot keeps no state.
+##
+## The run loop drives the radar: commandRadar() is called BEFORE go() every
+## tick, so the command is part of the tick's intent and is sent by THIS go().
+## The order matters — go() sends the intent first and dispatches the pending
+## events last, so onScannedBot only feeds the NEXT tick.
##
## API: robocode_tankroyale_botapi 1.0.7 (the renamed tankroyale_botapi package).
-import std/os
+import std/[os]
import robocode_tankroyale_botapi
+import modules/radar
type DevControlBot* = ref object of Bot
@@ -19,14 +29,24 @@ proc newDevControlBot*(): DevControlBot =
setGunColor(WHITE)
setRadarColor(WHITE)
+method onScannedBot*(bot: DevControlBot, e: ScannedBotEvent) =
+ onScan(e.x, e.y) # remembered; commandRadar() re-aims every tick
+ # (e.scannedBotId, schemas.nim:305, is the
+ # scanned bot's identity; unused here)
+
method run*(bot: DevControlBot) =
+ initRadar() # once: radar + scan turn BLACK = programmed
while isRunning():
+ # One radar command per tick, unconditionally, BEFORE go() — so the command
+ # is part of this tick's intent and no tick is ever left without one.
+ commandRadar()
go()
when isMainModule:
# argv[1] may override the metadata file (used by DevControlBot/DevControlBot.sh);
# otherwise the JSON next to this source, resolved at compile time so any cwd works.
- let jsonPath = if paramCount() >= 1: paramStr(1)
- else: currentSourcePath().parentDir / "DevControlBot.json"
+ let jsonPath =
+ if paramCount() >= 1: paramStr(1)
+ else: currentSourcePath().parentDir / "DevControlBot.json"
var bot = newDevControlBot()
start(bot, jsonPath)
diff --git a/DevControlBot_garage/DevControlBot/DevControlBot.nimble b/DevControlBot_garage/DevControlBot/DevControlBot.nimble
index 4503565..d987543 100644
--- a/DevControlBot_garage/DevControlBot/DevControlBot.nimble
+++ b/DevControlBot_garage/DevControlBot/DevControlBot.nimble
@@ -1,5 +1,5 @@
# Package
-version = "0.1.0"
+version = "1.1.0"
author = "Davide Cappellini"
description = "DevControlBot — control-skeleton bot that does nothing but show up bright"
license = "MIT"
diff --git a/DevControlBot_garage/DevControlBot/modules/radar.nim b/DevControlBot_garage/DevControlBot/modules/radar.nim
new file mode 100644
index 0000000..3d75aa0
--- /dev/null
+++ b/DevControlBot_garage/DevControlBot/modules/radar.nim
@@ -0,0 +1,92 @@
+## The radar — one module, the whole of it. No movement, no gun.
+##
+## Every tick `commandRadar()` issues EXACTLY ONE radar command, before `go()`,
+## on every path. There is no "idle" branch: the 360 sweep is not a mode we
+## fall back INTO, it is the command the other branch replaces. That is why
+## switching costs zero turns.
+##
+## TWO commands, chosen by the only fact the bot knows for sure:
+##
+## exactly one enemy alive + a target in hand -> LOCK (servo onto its bearing)
+## anything else -> SWEEP (45 deg/tick, the cap,
+## a full revolution every 8 ticks)
+##
+## The sweep does double duty and needs no separate "melee" mode: it is both the
+## search for a target we do not have yet and the right answer for 2+ enemies
+## (a full revolution re-scans every enemy 8x sooner than any narrowed sweep,
+## and locking one of several is worthless).
+##
+## Derived from ModularBot's radar (Davide Cappellini, Apache-2.0), i.e. from
+## common_libs/radar_lock/radar_lock.nim (`doRadar`) and common_libs/radars/
+## melee_scan.nim, which ModularBot picks between per tick in exactly this way
+## (`let targetMode = if getEnemyCount() == 1: 0 else: 1` — ModularBot.nim, the
+## "Auto-switch radar based on the SERVER's live enemy count" block). The one
+## idea taken from it is the OVERSHOOT, and it is the whole reason the lock
+## works: the rate is 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 — which
+## is exactly what produces a fresh ScannedBotEvent every tick. Turning by the
+## bare error (or holding inside a deadzone) parks the radar on the bearing and
+## the scans stop; the target then has to be re-found by the sweep, which is the
+## delay this replaces. common_libs/ is never edited in place.
+##
+## ANGLES: 0° = east, positive = counter-clockwise, so positive = a LEFT turn
+## (proof in ../README.md; the API's own doc comment claiming "0 = North" for
+## `directionTo` contradicts its code and is wrong).
+
+import std/math
+import robocode_tankroyale_botapi
+
+const
+ MaxRadarTurn* = 45.0 # deg/tick, the radar's hard cap (the sweep)
+ OvershootDeg* = 5.0 # how far past the target's bearing the lock aims;
+ # guarantees the bearing is swept every tick
+ FreshScanTurns* = 1 # how many ticks a remembered target stays usable.
+ # The 5 deg overshoot makes the lock CROSS the
+ # target's bearing every tick, so a fresh
+ # ScannedBotEvent arrives every tick and this
+ # counter never gets past 1. Hence one tick without a
+ # scan means the target has genuinely left the radar
+ # cone: there is nothing left to wait for, sweeping
+ # re-finds it faster than a stale lock could.
+
+# The target in hand: the position of the last bot we scanned. Absolute
+# position, not a remembered bearing, so the delta is recomputed against the
+# radar's CURRENT heading every tick and the lock keeps correcting a target
+# that (and a radar that) has moved since the scan.
+var
+ targetX, targetY: float
+ targetSeen: bool
+ scanlessTicks: int
+
+proc onScan*(x, y: float) =
+ ## Remember a scanned bot as the target. Issues no command: `onScannedBot`
+ ## runs AFTER go() has already sent this tick's intent.
+ targetX = x
+ targetY = y
+ targetSeen = true
+ scanlessTicks = 0
+
+proc initRadar*() =
+ ## Called ONCE, before the first tick: the radar is now programmed and working,
+ ## so it claims its own colours — radar and scan arc go BLACK.
+ ## COLOUR CONVENTION: white = "not programmed yet", black = "programmed and
+ ## running". The bot starts all WHITE (DevControlBot.nim); the radar paints
+ ## ITSELF black the moment it takes control, and body/turret/gun stay white
+ ## forever (they are still unprogrammed — this bot never moves, never fires).
+ ## Set once here, never per tick: the colour never changes, so re-sending it
+ ## every tick would only cost intent bandwidth.
+ setRadarColor(BLACK)
+ setScanColor(BLACK)
+
+proc commandRadar*() =
+ ## Command the radar for THIS tick. Called from run() before go().
+ if targetSeen: inc scanlessTicks
+ if getEnemyCount() == 1 and targetSeen and scanlessTicks <= FreshScanTurns:
+ var turn = normalizeRelativeAngle(
+ directionTo(getX(), getY(), targetX, targetY) - getRadarDirection())
+ if turn < 0.0: turn -= OvershootDeg
+ else: turn += OvershootDeg
+ setRadarTurnRate(turn.clamp(-MaxRadarTurn, MaxRadarTurn))
+ return
+ targetSeen = false # nothing usable to lock on: sweep
+ setRadarTurnRate(MaxRadarTurn)
diff --git a/DevControlBot_garage/README.md b/DevControlBot_garage/README.md
index be12f74..08f9c3a 100644
--- a/DevControlBot_garage/README.md
+++ b/DevControlBot_garage/README.md
@@ -1,11 +1,93 @@
# DevControlBot
-Control skeleton bot: it boots, participates in the battle, and does **nothing**
-per tick except `go()` — no movement logic, no scanning, no firing.
+Control skeleton bot: it boots, participates in the battle, and drives **no
+movement and no gun**. The radar is the only driven part.
-Body, gun and radar are plain **white**, which by team convention means "this part
-is not programmed yet". Colors are set **once at initialization** (`newDevControlBot`),
-never inside the tick loop.
+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