feat(ModularBot): pluggable bot with 4 guns, phantom meteor movement, radar harness

- Gun harness: virtual bullet tracker, rolling fitness, auto-selector
- Guns: head-on, linear (extrapolation), circular (integrated formula), tsetlin machine (learning)
- Movement: phantom meteor gravity engine (danger histograms, phantom bullets, fire detection)
- Radar: harness + radar_lock adapter
- Color-coded modules: turret/bullet color per gun, body per movement, scan per radar
- Beats Target, SpinBot, Crazy, TrackFire in 10-round battles
This commit is contained in:
2026-09-20 00:37:10 +02:00
parent c9191b7afb
commit 254c7dc997
39 changed files with 2630 additions and 30 deletions
+1 -1
View File
@@ -4,7 +4,7 @@ author = "Davide Cappellini"
description = "Binary Neural Network Bot — learns aiming with pure binary operations"
license = "MIT"
srcDir = "src"
bin = @["BNNBot"]
bin = @["BNNBot", "WiSARDBot", "TsetlinBot"]
# Dependencies
requires "nim >= 2.0.0"
+92
View File
@@ -0,0 +1,92 @@
# BNNBot Research Brief
## Problem
Predict enemy future position in Robocode Tank Royale to aim bullets accurately. The prediction must happen online (during battle), without pre-training.
## Hard Constraints
- NO supervised learning (no labeled input→output training pairs)
- NO gradient descent (no derivatives, no surrogate gradients, no STE)
- Online learning only — must learn and improve during a single battle
- Computational budget: ~1ms per tick
- Binary-friendly (690-bit input encoding already exists)
## Allowed
- Backpropagation of SIGNALS (non-gradient information flowing backward through layers)
- Reinforcement learning (reward signal available from wave hit system)
- Self-supervised learning
- Unsupervised learning
- Network structure modification during runtime
## Current Architecture
### Input Engineering (binary_encoding.nim)
- 690-bit binary vector: 10 frames × 69 bits
- Per frame: bearing sin/cos (16b), distance (7b), velocity (5b), heading sin/cos (16b), enemy X/Y position (14b), enemy energy (11b)
- Gray-coded for Hamming distance smoothness
- Temporal window: 10 most recent radar scans
### Feedback System (wave system in BNNBot.nim)
- Every tick: 10 circular waves spawned at bot position
- Powers: 0.1 to 3.0 (10 levels), speeds: 19.7 to 11.0 px/tick
- When wave radius reaches enemy: records enemy state as 69-bit frame
- Provides ground truth: "if you fired at power X, the enemy would be HERE when the bullet arrives"
- ~95.6% hit rate in testing
### Current Predictor (predictor.nim)
- Linear extrapolation: predicted = current_pos + velocity * ticks_to_arrival
- Hebbian residual table: 8 heading sectors × 3 distance bands = 24 cells
- Each cell stores (correction_x, correction_y), updated online with lr=0.2
- Backtest results: 14-21% MAE reduction over pure linear extrapolation
- Converges within one battle (MAE 14.85 → 2.62, first 50 vs last 50 rows)
## Key Findings
### Data Analysis (analysis/report.txt)
- Enemy movement is 97.8% constant-velocity straight lines
- Acceleration is negligible (std 0.25-0.49 px/tick²)
- Heading is very stable across 10-frame windows
- Linear extrapolation MAE: 15-27px (1.5-2.7% of arena)
- Distance to enemy is the main error driver
- Scalar velocity alone is weak predictor (r=0.15); directional velocity from frame deltas is strong
### Backtest Results (analysis/backtest_report.txt)
- P1 (linear): MAE 8.1-18.7 encoded units
- P2 (weighted 4-frame): ~8% improvement, trivial cost
- P3 (linear + Hebbian residual): 14-21% improvement, converges fast
- Most residual table cells stay empty — only ~10/24 activate
### Encoding Insights
- sin/cos angle encoding avoids wraparound discontinuity — worth the extra bits
- Enemy X/Y position partially redundant with bearing+distance (encodes absolute position)
- Wall distance → XY% compression saved 140 bits losslessly
- Self-state removed (not needed for aiming)
## What We've Tried
1. ✅ Input engineering with Gray coding and temporal window — works well
2. ✅ Linear extrapolation — strong baseline, 15-27px error
3. ✅ Hebbian residual table — learns online, 14-21% improvement
4. ❌ Pure XOR layer stacking — collapses (associative, no non-linearity)
5. ❌ XOR + AND layers — AND with fixed mask is still linear over GF(2)
6. ✅ XOR + popcount + threshold = valid binary neuron (non-linear)
## Open Questions
1. Can we go deeper than the current shallow predictor while respecting the constraints?
2. What non-gradient learning rules can train multi-layer binary networks?
3. Can the temporal structure (10 frames) be exploited by the network architecture?
4. Is there a way to do credit assignment through depth without gradients?
5. Can the wave hit system provide richer learning signal than just miss distance?
## Architecture Philosophy
- Input engineering IS the feature hierarchy (handcrafted, domain-informed)
- Current approach is essentially reservoir computing: rich fixed features → simple learnable readout
- Question: can we do better with a learnable feature extractor, or is the handcrafted one already near-optimal?
## Files
- `src/BNNBot.nim` — main bot, wave system, integration
- `src/binary_encoding.nim` — 690-bit input encoding
- `src/predictor.nim` — linear extrapolation + Hebbian residual table
- `analysis/correlations.py` — data analysis script
- `analysis/backtest.py` — predictor comparison script
- `analysis/report.txt` — correlation analysis results
- `analysis/backtest_report.txt` — predictor backtest results
- `data/` — CSV battle logs (enabled via BNNBOT_CSV=1)
+76 -4
View File
@@ -1,13 +1,16 @@
# BNNBot — Hebbian weight matrix with virtual bullet learning.
# 870-bit input → 7-bit aim angle output via forward pass.
# Learns from virtual bullets (no real firing) via three-factor Hebbian rule.
# CSV data collection: BNNBOT_CSV=1 writes per-round CSV to data/battle_{round}.csv
import std/[math, os, strutils, random]
import std/[math, os, strformat, strutils, random]
import robocode_tankroyale_botapi
import radar_lock/radar_lock as radar_lock
import binary_encoding
import hebbian
let csvEnabled = getEnv("BNNBOT_CSV", "0") == "1"
const botJsonPath = currentSourcePath().parentDir / "BNNBot.json"
const
@@ -17,7 +20,18 @@ const
EPSILON_MIN = 0.05
EPSILON_DECAY = 0.9995
# CSV: 9 raw decimal fields per frame (matches analysis/backtest.py column names)
# bearing_sin(0-199), bearing_cos(0-199), distance(0-99), velocity(0-15),
# heading_sin(0-199), heading_cos(0-199), enemy_x(px), enemy_y(px), enemy_energy(float)
type
FrameRaw = object
bSin, bCos: int # 0-199
dist: int # 0-99
vel: int # 0-15
hSin, hCos: int # 0-199
ex, ey: float # absolute pixel coords
energy: float
BNNBot = ref object of Bot
hasContact: bool
enemyBearing: float
@@ -31,13 +45,52 @@ type
prevVec: BinaryVector
hasPrev: bool
frameBuffer: array[WINDOW_SIZE, array[FRAME_BITS, uint8]]
frameRawBuf: array[WINDOW_SIZE, FrameRaw] # decimal mirror of frameBuffer
bufferCount: int
net: HebbianNet
bullets: array[BULLET_SLOTS, VirtualBullet]
bulletHead: int # ring-buffer write index
bulletHead: int
virtualHits: int
virtualMiss: int
epsilon: float
# CSV state
csvFile: File
csvOpen: bool
roundNum: int
# ── CSV helpers ──────────────────────────────────────────────────────────────
proc csvPath(roundNum: int): string =
getAppDir() / "data" / fmt"battle_{roundNum}.csv"
proc buildHeader(): string =
result = "tick"
for fr in 0..<WINDOW_SIZE:
for fn in ["bearing_sin", "bearing_cos", "distance", "velocity",
"heading_sin", "heading_cos", "enemy_x", "enemy_y", "enemy_energy"]:
result &= fmt",f{fr}_{fn}"
proc openCsv(bot: BNNBot) =
if not csvEnabled: return
createDir(csvPath(bot.roundNum).parentDir)
bot.csvFile = open(csvPath(bot.roundNum), fmWrite)
bot.csvOpen = true
bot.csvFile.writeLine(buildHeader())
proc closeCsv(bot: BNNBot) =
if bot.csvOpen:
bot.csvFile.close()
bot.csvOpen = false
proc writeRow(bot: BNNBot) =
if not bot.csvOpen: return
var line = $bot.tick
for i in 0..<WINDOW_SIZE:
let f = bot.frameRawBuf[i]
line &= fmt",{f.bSin},{f.bCos},{f.dist},{f.vel},{f.hSin},{f.hCos},{f.ex:.2f},{f.ey:.2f},{f.energy:.2f}"
bot.csvFile.writeLine(line)
# ── Bot methods ──────────────────────────────────────────────────────────────
method onScannedBot*(bot: BNNBot, e: ScannedBotEvent) =
let bx = getX(); let by = getY()
@@ -66,10 +119,23 @@ method onScannedBot*(bot: BNNBot, e: ScannedBotEvent) =
)
let encoded = encodeFrame(frame)
# Shift window — index 0 = newest
for i in countdown(WINDOW_SIZE - 1, 1):
bot.frameBuffer[i] = bot.frameBuffer[i - 1]
bot.frameRawBuf[i] = bot.frameRawBuf[i - 1]
bot.frameBuffer[0] = encoded
# Build raw decimal record for this frame
let bSin = int((sin(degToRad(bot.enemyBearing)) + 1.0) / 2.0 * 199.0)
let bCos = int((cos(degToRad(bot.enemyBearing)) + 1.0) / 2.0 * 199.0)
let dist = int(clamp(bot.distance / 1414.0 * 99.0, 0.0, 99.0))
let vel = int(clamp(bot.velocity + 8.0, 0.0, 16.0))
let hSin = int((sin(degToRad(bot.heading)) + 1.0) / 2.0 * 199.0)
let hCos = int((cos(degToRad(bot.heading)) + 1.0) / 2.0 * 199.0)
bot.frameRawBuf[0] = FrameRaw(bSin: bSin, bCos: bCos, dist: dist, vel: vel,
hSin: hSin, hCos: hCos,
ex: e.x, ey: e.y, energy: e.energy)
if bot.bufferCount < WINDOW_SIZE:
inc bot.bufferCount
if bot.bufferCount < WINDOW_SIZE:
@@ -99,11 +165,9 @@ method onScannedBot*(bot: BNNBot, e: ScannedBotEvent) =
let missDistance = hypot(bulletX - bot.lastEnemyX, bulletY - bot.lastEnemyY)
# Shaped reward: +1.0 for perfect hit, decays toward -1.0 as miss distance grows
# Using exponential decay: reward = 2.0 * exp(-missDistance / 36.0) - 1.0
let reward = 2.0 * exp(-missDistance / 36.0) - 1.0
bot.net.learn(b.trace, reward)
# Track hit/miss for display: hit if within 36px, miss otherwise
if missDistance < 36.0:
inc bot.virtualHits
else:
@@ -134,6 +198,9 @@ method onScannedBot*(bot: BNNBot, e: ScannedBotEvent) =
active: true,
)
# ── CSV row ───────────────────────────────────────────────────────────
bot.writeRow()
# ── stats & echo ─────────────────────────────────────────────────────
let hamming = if bot.hasPrev: hammingDistance(bot.prevVec, vec) else: 0
let similarity = if bot.hasPrev: TOTAL_BITS - hamming else: 0
@@ -159,8 +226,13 @@ method onRoundStarted*(bot: BNNBot, e: RoundStartedEvent) =
bot.tick = 0
bot.hasPrev = false
bot.bufferCount = 0
inc bot.roundNum
bot.openCsv()
# net and epsilon persist across rounds (learning carries over)
method onRoundEnded*(bot: BNNBot, e: RoundEndedEventForBot) =
bot.closeCsv()
method onGameStarted*(bot: BNNBot, e: GameStartedEventForBot) =
discard
+11
View File
@@ -0,0 +1,11 @@
{
"name": "TsetlinBot",
"version": "0.1.0",
"authors": ["Davide Cappellini"],
"description": "Regression Tsetlin Machine bot — learns aiming with virtual bullet feedback",
"homepage": "",
"countryCodes": ["IT"],
"gameTypes": ["classic", "1v1"],
"platform": "Nim",
"programmingLang": "Nim"
}
+280
View File
@@ -0,0 +1,280 @@
# TsetlinBot — Regression Tsetlin Machine for aiming.
# Identical to BNNBot except uses tsetlin_predictor instead of hebbian.
import std/[math, os, strutils, random]
import robocode_tankroyale_botapi
import radar_lock/radar_lock as radar_lock
import binary_encoding
import tsetlin_predictor
const botJsonPath = currentSourcePath().parentDir / "TsetlinBot.json"
const
BULLET_SLOTS = 50
EPSILON_START = 0.2
EPSILON_MIN = 0.05
EPSILON_DECAY = 0.9995
# Power levels probed each tick to pick best fire power
POWER_LEVELS = [0.1'f64, 0.4, 0.7, 1.0, 1.3, 1.6, 1.9, 2.2, 2.5, 3.0]
N_POWER = POWER_LEVELS.len
HIT_THRESHOLD = 0.40 # min hit-rate to qualify a power level
MIN_SAMPLES = 5 # samples before trusting a power level
DEFAULT_POWER = 1.0 # cold-start fallback
type
ProbeSlot = object # lightweight wave probe — no learning, just geometry
active: bool
powerIdx: int
fireX: float
fireY: float
aimAngleDeg: float
fireDist: float
bulletSpeed: float
age: int
type
TsetlinBot = ref object of Bot
hasContact: bool
enemyBearing: float
lastEnemyX: float
lastEnemyY: float
hasLastPos: bool
velocity: float
heading: float
distance: float
tick: int
prevVec: BinaryVector
hasPrev: bool
frameBuffer: array[WINDOW_SIZE, array[FRAME_BITS, uint8]]
bufferCount: int
net: TsetlinNet
bullets: array[BULLET_SLOTS, VirtualBullet]
bulletHead: int
virtualHits: int
virtualMiss: int
epsilon: float
aimAngle: float # absolute gun aim angle, updated each scan
firePower: float # dynamically selected fire power
powerStats: array[N_POWER, tuple[hits, total: int]]
probes: array[N_POWER, ProbeSlot] # one probe per power level per tick
method onScannedBot*(bot: TsetlinBot, e: ScannedBotEvent) =
let bx = getX(); let by = getY()
bot.enemyBearing = directionTo(bx, by, e.x, e.y)
bot.distance = distanceTo(bx, by, e.x, e.y)
bot.heading = e.direction
bot.velocity = e.speed
bot.lastEnemyX = e.x
bot.lastEnemyY = e.y
bot.hasLastPos = true
bot.hasContact = true
let arenaW = getArenaWidth().float
let arenaH = getArenaHeight().float
let frame = EnemyScanFrame(
bearing: bot.enemyBearing,
distance: bot.distance,
velocity: bot.velocity,
heading: bot.heading,
enemyWallN: arenaH - e.y,
enemyWallS: e.y,
enemyWallE: arenaW - e.x,
enemyWallW: e.x,
enemyEnergy: e.energy,
)
let encoded = encodeFrame(frame)
for i in countdown(WINDOW_SIZE - 1, 1):
bot.frameBuffer[i] = bot.frameBuffer[i - 1]
bot.frameBuffer[0] = encoded
if bot.bufferCount < WINDOW_SIZE:
inc bot.bufferCount
if bot.bufferCount < WINDOW_SIZE:
return
let selfState = SelfState(
myWallN: arenaH - by,
myWallS: by,
myWallE: arenaW - bx,
myWallW: bx,
myEnergy: getEnergy(),
canFire: getGunHeat() <= 0.0,
)
let selfEncoded = encodeSelf(selfState)
let vec = encodeFullVector(bot.frameBuffer, selfEncoded)
# ── pick best fire power ──────────────────────────────────────────────
bot.firePower = DEFAULT_POWER
var bestPow = -1.0
for pi in 0..<N_POWER:
let s = bot.powerStats[pi]
if s.total >= MIN_SAMPLES:
let rate = s.hits.float / s.total.float
if rate >= HIT_THRESHOLD and POWER_LEVELS[pi] > bestPow:
bestPow = POWER_LEVELS[pi]
bot.firePower = POWER_LEVELS[pi]
let selectedSpeed = 20.0 - 3.0 * bot.firePower
# ── Bug 4 fix: forward pass returns (cx, cy) pixel corrections ────────
var cache: ClauseCache
let (cx, cy) = bot.net.forwardWithCache(vec, cache)
# epsilon-greedy exploration: perturb the correction
var corrX = cx
var corrY = cy
if rand(1.0) < bot.epsilon:
corrX += rand(20.0) - 10.0
corrY += rand(20.0) - 10.0
bot.epsilon = max(EPSILON_MIN, bot.epsilon * EPSILON_DECAY)
# Convert (cx, cy) correction to aim angle.
# Linear extrapolation first, then TM residual correction on top.
let bx2 = getX(); let by2 = getY()
var aimAngle = bot.enemyBearing
if bot.hasLastPos:
let ticksToArrive = bot.distance / selectedSpeed
let extrapolX = cos(degToRad(bot.heading)) * bot.velocity * ticksToArrive
let extrapolY = sin(degToRad(bot.heading)) * bot.velocity * ticksToArrive
let targetX = bot.lastEnemyX + extrapolX + corrX
let targetY = bot.lastEnemyY + extrapolY + corrY
aimAngle = directionTo(bx2, by2, targetX, targetY)
var aimOffset = aimAngle - bot.enemyBearing
while aimOffset > 180.0: aimOffset -= 360.0
while aimOffset < -180.0: aimOffset += 360.0
bot.aimAngle = bot.enemyBearing + aimOffset
# ── store virtual bullet (with clause cache for eligibility) ──────────
let slot = bot.bulletHead mod BULLET_SLOTS
bot.bulletHead = slot + 1
bot.bullets[slot] = VirtualBullet(
trace: EligibilityTrace(input: vec, clauseOut: cache, age: 0, alive: true),
fireX: bx2,
fireY: by2,
aimAngleDeg: bot.enemyBearing + aimOffset,
fireDist: bot.distance,
bulletSpeed: selectedSpeed,
active: true,
)
# ── spawn one probe per power level ──────────────────────────────────
for pi in 0..<N_POWER:
let spd = 20.0 - 3.0 * POWER_LEVELS[pi]
let probeTicks = bot.distance / spd
let probeExtraX = cos(degToRad(bot.heading)) * bot.velocity * probeTicks
let probeExtraY = sin(degToRad(bot.heading)) * bot.velocity * probeTicks
let probeTargetX = bot.lastEnemyX + probeExtraX + corrX
let probeTargetY = bot.lastEnemyY + probeExtraY + corrY
let probeAimAngle = directionTo(bx2, by2, probeTargetX, probeTargetY)
bot.probes[pi] = ProbeSlot(
active: true,
powerIdx: pi,
fireX: bx2,
fireY: by2,
aimAngleDeg: probeAimAngle,
fireDist: bot.distance,
bulletSpeed: spd,
age: 0,
)
# ── stats & echo ─────────────────────────────────────────────────────
let hamming = if bot.hasPrev: hammingDistance(bot.prevVec, vec) else: 0
let similarity = if bot.hasPrev: TOTAL_BITS - hamming else: 0
let overlap = if bot.hasPrev: popcount(bitwiseAnd(bot.prevVec, vec)) else: 0
let totalVirtual = bot.virtualHits + bot.virtualMiss
let hitPct = if totalVirtual > 0: bot.virtualHits.float / totalVirtual.float * 100.0 else: 0.0
echo align($bot.tick, 4), " ",
hamming, " ", similarity, " ", overlap, " ",
formatFloat(aimOffset, ffDecimal, 2), " ",
bot.virtualHits, " ", bot.virtualMiss, " ",
formatFloat(hitPct, ffDecimal, 1), "% ",
"pwr=", formatFloat(bot.firePower, ffDecimal, 1)
bot.prevVec = vec
bot.hasPrev = true
method onRoundStarted*(bot: TsetlinBot, e: RoundStartedEvent) =
setAdjustGunForBodyTurn(true)
setAdjustRadarForBodyTurn(true)
setAdjustRadarForGunTurn(true)
radar_lock.init()
bot.hasContact = false
bot.hasLastPos = false
bot.tick = 0
bot.hasPrev = false
bot.bufferCount = 0
for i in 0..<BULLET_SLOTS:
bot.bullets[i].active = false
for i in 0..<N_POWER:
bot.probes[i].active = false
bot.bulletHead = 0
# net and epsilon persist across rounds
method onGameStarted*(bot: TsetlinBot, e: GameStartedEventForBot) =
discard
method run*(bot: TsetlinBot) =
while isRunning():
inc bot.tick
setTargetSpeed(0.0)
setTurnRate(0.0)
if not bot.hasContact:
setRadarTurnRate(45.0)
go()
continue
setRadarTurnRate(radar_lock.doRadar(getRadarDirection(), bot.enemyBearing))
# ── age & settle virtual bullets (game ticks) ─────────────────────
if bot.hasLastPos:
for idx in 0..<BULLET_SLOTS:
var b = addr bot.bullets[idx]
if not b.active: continue
inc b.trace.age
let bulletDist = b.bulletSpeed * float(b.trace.age)
if bulletDist >= b.fireDist or b.trace.age >= TRACE_MAX_AGE:
let bulletX = b.fireX + cos(degToRad(b.aimAngleDeg)) * bulletDist
let bulletY = b.fireY + sin(degToRad(b.aimAngleDeg)) * bulletDist
let residualX = bot.lastEnemyX - bulletX
let residualY = bot.lastEnemyY - bulletY
let missDistance = hypot(residualX, residualY)
bot.net.learn(b.trace, residualX, residualY)
if missDistance < 36.0: inc bot.virtualHits
else: inc bot.virtualMiss
b.active = false
for pi in 0..<N_POWER:
var p = addr bot.probes[pi]
if not p.active: continue
inc p.age
let pd = p.bulletSpeed * float(p.age)
if pd >= p.fireDist or p.age >= TRACE_MAX_AGE:
let px = p.fireX + cos(degToRad(p.aimAngleDeg)) * pd
let py = p.fireY + sin(degToRad(p.aimAngleDeg)) * pd
let md = hypot(px - bot.lastEnemyX, py - bot.lastEnemyY)
inc bot.powerStats[pi].total
if md < 36.0: inc bot.powerStats[pi].hits
p.active = false
# Aim gun and fire when ready
let gunDir = getGunDirection()
let gunDelta = normalizeRelativeAngle(bot.aimAngle - gunDir)
setGunTurnRate(gunDelta.clamp(-20.0, 20.0))
if getGunHeat() <= 0.0 and abs(gunDelta) < 2.0:
discard setFire(bot.firePower)
go()
when isMainModule:
randomize()
var bot = TsetlinBot(
net: initTsetlinNet(),
epsilon: EPSILON_START,
firePower: DEFAULT_POWER,
)
start(bot, botJsonPath)
+11
View File
@@ -0,0 +1,11 @@
{
"name": "WiSARDBot",
"version": "0.1.0",
"authors": ["Davide Cappellini"],
"description": "WiSARD (K=14) regression predictor bot — 63 neurons × 16384-entry LUTs over 870-bit binary state",
"homepage": "",
"countryCodes": ["IT"],
"gameTypes": ["classic", "1v1"],
"platform": "Nim",
"programmingLang": "Nim"
}
+288
View File
@@ -0,0 +1,288 @@
# WiSARDBot — WiSARD regression predictor for aiming.
# Identical to TsetlinBot except uses wisard_predictor instead of tsetlin_predictor.
import std/[math, os, strutils, random]
import robocode_tankroyale_botapi
import radar_lock/radar_lock as radar_lock
import binary_encoding
import wisard_predictor
const botJsonPath = currentSourcePath().parentDir / "WiSARDBot.json"
const
BULLET_SLOTS = 50
EPSILON_START = 0.2
EPSILON_MIN = 0.05
EPSILON_DECAY = 0.9995
# Power levels probed each tick to pick best fire power
POWER_LEVELS = [0.1'f64, 0.4, 0.7, 1.0, 1.3, 1.6, 1.9, 2.2, 2.5, 3.0]
N_POWER = POWER_LEVELS.len
HIT_THRESHOLD = 0.40 # min hit-rate to qualify a power level
MIN_SAMPLES = 5 # samples before trusting a power level
DEFAULT_POWER = 1.0 # cold-start fallback
type
ProbeSlot = object # lightweight wave probe — no learning, just geometry
active: bool
powerIdx: int
fireX: float
fireY: float
aimAngleDeg: float
fireDist: float
bulletSpeed: float
age: int
type
WiSARDBot = ref object of Bot
hasContact: bool
enemyBearing: float
lastEnemyX: float
lastEnemyY: float
hasLastPos: bool
velocity: float
heading: float
distance: float
tick: int
prevVec: BinaryVector
hasPrev: bool
frameBuffer: array[WINDOW_SIZE, array[FRAME_BITS, uint8]]
bufferCount: int
net: WiSARDNet
bullets: array[BULLET_SLOTS, VirtualBullet]
bulletHead: int
virtualHits: int
virtualMiss: int
epsilon: float
aimAngle: float # absolute gun aim angle, updated each scan
firePower: float # dynamically selected fire power
powerStats: array[N_POWER, tuple[hits, total: int]]
probes: array[N_POWER, ProbeSlot] # one probe per power level per tick
method onScannedBot*(bot: WiSARDBot, e: ScannedBotEvent) =
let bx = getX(); let by = getY()
bot.enemyBearing = directionTo(bx, by, e.x, e.y)
bot.distance = distanceTo(bx, by, e.x, e.y)
bot.heading = e.direction
bot.velocity = e.speed
bot.lastEnemyX = e.x
bot.lastEnemyY = e.y
bot.hasLastPos = true
bot.hasContact = true
let arenaW = getArenaWidth().float
let arenaH = getArenaHeight().float
# lastEnemyX/Y already stored above — available for wave resolution in run()
let frame = EnemyScanFrame(
bearing: bot.enemyBearing,
distance: bot.distance,
velocity: bot.velocity,
heading: bot.heading,
enemyWallN: arenaH - e.y,
enemyWallS: e.y,
enemyWallE: arenaW - e.x,
enemyWallW: e.x,
enemyEnergy: e.energy,
)
let encoded = encodeFrame(frame)
for i in countdown(WINDOW_SIZE - 1, 1):
bot.frameBuffer[i] = bot.frameBuffer[i - 1]
bot.frameBuffer[0] = encoded
if bot.bufferCount < WINDOW_SIZE:
inc bot.bufferCount
if bot.bufferCount < WINDOW_SIZE:
return
let selfState = SelfState(
myWallN: arenaH - by,
myWallS: by,
myWallE: arenaW - bx,
myWallW: bx,
myEnergy: getEnergy(),
canFire: getGunHeat() <= 0.0,
)
let selfEncoded = encodeSelf(selfState)
let vec = encodeFullVector(bot.frameBuffer, selfEncoded)
# ── pick best fire power ──────────────────────────────────────────────
bot.firePower = DEFAULT_POWER
var bestPow = -1.0
for pi in 0..<N_POWER:
let s = bot.powerStats[pi]
if s.total >= MIN_SAMPLES:
let rate = s.hits.float / s.total.float
if rate >= HIT_THRESHOLD and POWER_LEVELS[pi] > bestPow:
bestPow = POWER_LEVELS[pi]
bot.firePower = POWER_LEVELS[pi]
let selectedSpeed = 20.0 - 3.0 * bot.firePower
# ── WiSARD forward pass: compute addresses then predict correction ────
let addrs = bot.net.computeAddresses(vec)
let (cx, cy) = bot.net.predictCorrection(addrs)
# Convert (cx, cy) correction to aim angle offset.
# Linear extrapolation first; cx/cy are residual corrections on top.
let bx2 = getX(); let by2 = getY()
var aimAngle = bot.enemyBearing
if bot.hasLastPos:
let ticksToArrive = bot.distance / selectedSpeed
let extrapolX = cos(degToRad(bot.heading)) * bot.velocity * ticksToArrive
let extrapolY = sin(degToRad(bot.heading)) * bot.velocity * ticksToArrive
let targetX = bot.lastEnemyX + extrapolX + cx
let targetY = bot.lastEnemyY + extrapolY + cy
echo "AIM: enemyXY=(" & formatFloat(bot.lastEnemyX, ffDecimal, 1) & "," & formatFloat(bot.lastEnemyY, ffDecimal, 1) &
") heading=" & formatFloat(bot.heading, ffDecimal, 1) &
" vel=" & formatFloat(bot.velocity, ffDecimal, 1) &
" ticks=" & formatFloat(ticksToArrive, ffDecimal, 1) &
" extrapol=(" & formatFloat(extrapolX, ffDecimal, 1) & "," & formatFloat(extrapolY, ffDecimal, 1) &
") corr=(" & formatFloat(cx, ffDecimal, 1) & "," & formatFloat(cy, ffDecimal, 1) &
") target=(" & formatFloat(targetX, ffDecimal, 1) & "," & formatFloat(targetY, ffDecimal, 1) &
") aimAng=" & formatFloat(aimAngle, ffDecimal, 1) &
" bearing=" & formatFloat(bot.enemyBearing, ffDecimal, 1)
aimAngle = directionTo(bx2, by2, targetX, targetY)
var aimOffset = aimAngle - bot.enemyBearing
# Normalise to [-180, 180]
while aimOffset > 180.0: aimOffset -= 360.0
while aimOffset < -180.0: aimOffset += 360.0
# epsilon-greedy exploration: perturb the correction
if rand(1.0) < bot.epsilon:
aimOffset += rand(10.0) - 5.0
bot.epsilon = max(EPSILON_MIN, bot.epsilon * EPSILON_DECAY)
bot.aimAngle = bot.enemyBearing + aimOffset
# ── store virtual bullet (with addresses for eligibility) ────────────
let slot = bot.bulletHead mod BULLET_SLOTS
bot.bulletHead = slot + 1
bot.bullets[slot] = VirtualBullet(
trace: WaveTrace(addrs: addrs, valid: true, age: 0),
fireX: bx2,
fireY: by2,
aimAngleDeg: bot.enemyBearing + aimOffset,
fireDist: bot.distance,
bulletSpeed: selectedSpeed,
active: true,
)
# ── spawn one probe per power level (each uses its own extrapolation) ─
for pi in 0..<N_POWER:
let probeSpeed = 20.0 - 3.0 * POWER_LEVELS[pi]
let probeTicks = bot.distance / probeSpeed
let probeExtrapolX = cos(degToRad(bot.heading)) * bot.velocity * probeTicks
let probeExtrapolY = sin(degToRad(bot.heading)) * bot.velocity * probeTicks
let probeTargetX = bot.lastEnemyX + probeExtrapolX + cx
let probeTargetY = bot.lastEnemyY + probeExtrapolY + cy
let probeAimAngle = directionTo(bx2, by2, probeTargetX, probeTargetY)
bot.probes[pi] = ProbeSlot(
active: true,
powerIdx: pi,
fireX: bx2,
fireY: by2,
aimAngleDeg: probeAimAngle,
fireDist: bot.distance,
bulletSpeed: probeSpeed,
age: 0,
)
# ── stats & echo ─────────────────────────────────────────────────────
let hamming = if bot.hasPrev: hammingDistance(bot.prevVec, vec) else: 0
let similarity = if bot.hasPrev: TOTAL_BITS - hamming else: 0
let overlap = if bot.hasPrev: popcount(bitwiseAnd(bot.prevVec, vec)) else: 0
let totalVirtual = bot.virtualHits + bot.virtualMiss
let hitPct = if totalVirtual > 0: bot.virtualHits.float / totalVirtual.float * 100.0 else: 0.0
echo align($bot.tick, 4), " ",
hamming, " ", similarity, " ", overlap, " ",
formatFloat(aimOffset, ffDecimal, 2), " ",
bot.virtualHits, " ", bot.virtualMiss, " ",
formatFloat(hitPct, ffDecimal, 1), "% ",
"pwr=", formatFloat(bot.firePower, ffDecimal, 1)
bot.prevVec = vec
bot.hasPrev = true
method onRoundStarted*(bot: WiSARDBot, e: RoundStartedEvent) =
setAdjustGunForBodyTurn(true)
setAdjustRadarForBodyTurn(true)
setAdjustRadarForGunTurn(true)
radar_lock.init()
bot.hasContact = false
bot.hasLastPos = false
bot.tick = 0
bot.hasPrev = false
bot.bufferCount = 0
for i in 0..<BULLET_SLOTS:
bot.bullets[i].active = false
for i in 0..<N_POWER:
bot.probes[i].active = false
bot.bulletHead = 0
# net and epsilon persist across rounds
method onGameStarted*(bot: WiSARDBot, e: GameStartedEventForBot) =
discard
method run*(bot: WiSARDBot) =
while isRunning():
inc bot.tick
setTargetSpeed(0.0)
setTurnRate(0.0)
# ── age & settle virtual bullets every game tick ──────────────────
if bot.hasLastPos:
for idx in 0..<BULLET_SLOTS:
var b = addr bot.bullets[idx]
if not b.active: continue
inc b.trace.age
let bulletDist = b.bulletSpeed * float(b.trace.age)
if bulletDist >= b.fireDist or b.trace.age >= TRACE_MAX_AGE:
let bulletX = b.fireX + cos(degToRad(b.aimAngleDeg)) * bulletDist
let bulletY = b.fireY + sin(degToRad(b.aimAngleDeg)) * bulletDist
let missDistance = hypot(bulletX - bot.lastEnemyX, bulletY - bot.lastEnemyY)
let resX = bot.lastEnemyX - bulletX
let resY = bot.lastEnemyY - bulletY
bot.net.learnCorrection(b.trace.addrs, resX, resY)
if missDistance < 36.0: inc bot.virtualHits
else: inc bot.virtualMiss
b.active = false
for pi in 0..<N_POWER:
var p = addr bot.probes[pi]
if not p.active: continue
inc p.age
let pd = p.bulletSpeed * float(p.age)
if pd >= p.fireDist or p.age >= TRACE_MAX_AGE:
let px = p.fireX + cos(degToRad(p.aimAngleDeg)) * pd
let py = p.fireY + sin(degToRad(p.aimAngleDeg)) * pd
let md = hypot(px - bot.lastEnemyX, py - bot.lastEnemyY)
inc bot.powerStats[pi].total
if md < 36.0: inc bot.powerStats[pi].hits
p.active = false
if not bot.hasContact:
setRadarTurnRate(45.0)
go()
continue
setRadarTurnRate(radar_lock.doRadar(getRadarDirection(), bot.enemyBearing))
# Aim gun and fire when ready
let gunDir = getGunDirection()
let gunDelta = normalizeRelativeAngle(bot.aimAngle - gunDir)
setGunTurnRate(gunDelta.clamp(-20.0, 20.0))
if getGunHeat() <= 0.0 and abs(gunDelta) < 2.0:
discard setFire(bot.firePower)
go()
when isMainModule:
randomize()
var bot = WiSARDBot(
net: initWiSARD(),
epsilon: EPSILON_START,
firePower: DEFAULT_POWER,
)
start(bot, botJsonPath)
+31 -1
View File
@@ -28,7 +28,10 @@ type
myEnergy*: float # 11 bits
canFire*: bool # 1 bit
const MAX_DISTANCE = 1414.0 # diagonal of 1000x1000 arena
const
MAX_DISTANCE = 1414.0 # diagonal of 1000x1000 arena
NUM_FRAME_FIELDS* = 9 # for CSV output: bearing_sin, bearing_cos, distance, velocity, heading_sin, heading_cos, enemy_x, enemy_y, energy
FIELD_NAMES* = ["bearing_sin", "bearing_cos", "distance", "velocity", "heading_sin", "heading_cos", "wallN", "wallS", "energy"]
proc toGray(value: int): int =
value xor (value shr 1)
@@ -137,6 +140,33 @@ proc bitwiseAnd*(a, b: BinaryVector): BinaryVector =
for i in 0..<TOTAL_BITS:
result[i] = a[i] and b[i]
proc formatVectorBinary*(vec: BinaryVector): string =
formatBinary(vec)
proc formatVectorDecimal*(vec: BinaryVector): string =
# Placeholder: output popcount or some summary per frame
result = ""
for i in 0..<WINDOW_SIZE:
var cnt = 0
for j in 0..<FRAME_BITS:
cnt += int(vec[i * FRAME_BITS + j])
if i > 0: result &= ","
result &= $cnt
proc formatFrameBinary*(frame: array[FRAME_BITS, uint8]): string =
for b in frame:
result &= (if b == 1: "1" else: "0")
proc formatFrameDecimal*(frame: array[FRAME_BITS, uint8]): string =
# Nine decimal fields from the frame bits
# bearing_sin (0-199), bearing_cos (0-199), distance (0-99), velocity (0-15), heading_sin (0-199), heading_cos (0-199), wall (0-99), wall (0-99), energy (0-1500/10)
result = "199,199,99,15,199,199,99,99,150" # placeholder max values
proc decodeFrameFields*(frame: array[FRAME_BITS, uint8]): array[9, float] =
# Decode frame to 9 decimal field values (enemy X, Y for field indices 6, 7)
# This is a placeholder—actual decoding would reverse the toBits() encoding
result = [0.0, 0.0, 0.0, 0.0, 0.0, 0.0, 50.0, 50.0, 100.0]
# Output encoding -------------------------------------------------------
const
+50
View File
@@ -0,0 +1,50 @@
import std/math
import binary_encoding
const
N_SECTORS = 8
N_BANDS = 3
LEARNING_RATE = 0.2
BAND_THR_LO = 33.0
BAND_THR_HI = 66.0
type
ResidualTable* = object
corrections*: array[N_SECTORS * N_BANDS, tuple[cx, cy: float]]
proc initPredictor*(): ResidualTable = discard # zero-init is correct
proc getSector*(headingSin, headingCos: int): int =
let s = headingSin.float / 199.0 * 2.0 - 1.0
let c = headingCos.float / 199.0 * 2.0 - 1.0
var deg = radToDeg(arctan2(s, c))
if deg < 0.0: deg += 360.0
int(deg / 45.0) mod N_SECTORS
proc getBand*(distance: int): int =
if distance.float < BAND_THR_LO: 0
elif distance.float < BAND_THR_HI: 1
else: 2
proc predict*(table: ResidualTable,
f0, f1: array[NUM_FRAME_FIELDS, int],
power: float): tuple[predX, predY: float] =
# field indices: 2=distance, 4=heading_sin, 5=heading_cos, 6=enemy_x, 7=enemy_y
let bulletSpd = 20.0 - 3.0 * power
let distancePx = f0[2].float / 99.0 * 1414.0
let ticks = distancePx / bulletSpd
let vx = float(f0[6] - f1[6])
let vy = float(f0[7] - f1[7])
let sector = getSector(f0[4], f0[5])
let band = getBand(f0[2])
let corr = table.corrections[sector * N_BANDS + band]
result.predX = f0[6].float + vx * ticks + corr.cx
result.predY = f0[7].float + vy * ticks + corr.cy
proc sectorBand*(f0: array[NUM_FRAME_FIELDS, int]): tuple[sector, band: int] =
(getSector(f0[4], f0[5]), getBand(f0[2]))
proc learn*(table: var ResidualTable, sector, band: int,
residualX, residualY: float) =
table.corrections[sector * N_BANDS + band].cx += LEARNING_RATE * residualX
table.corrections[sector * N_BANDS + band].cy += LEARNING_RATE * residualY
+178
View File
@@ -0,0 +1,178 @@
# Regression Tsetlin Machine for aiming correction.
# Input: 870 bits → 1740 literals (bit + complement)
# N_OUT=2 outputs: cx correction, cy correction (pixel offsets).
# 64 clauses per output (32 positive polarity, 32 negative).
# Clause output = AND of all included literals.
# Regression: vote ∈ [-N_CLAUSES/2, N_CLAUSES/2] → scaled to [-RESID_MAX, RESID_MAX].
# Online learning via Type I / Ib / Type II stochastic feedback.
import binary_encoding
import std/[math, random]
const
N_IN* = TOTAL_BITS # 870
N_OUT* = 2 # cx, cy pixel corrections
N_LITERALS = N_IN * 2 # 1740 (bit + complement)
N_CLAUSES = 64 # per output; first 32 = pos polarity
HALF_CLAUSES = N_CLAUSES div 2
N_STATES = 15 # automaton range [-N_STATES..N_STATES] (int8 ok)
T* = float(HALF_CLAUSES) # = 32.0; vote clamped to [-T, T]
S = 4.0 # specificity (higher = sparser clauses)
RESID_MAX* = 80.0 # residual correction range (on top of linear extrapolation)
# ponytail: N_STATES=15 fits int8, keeps array small; raise if underfitting
TRACE_MAX_AGE* = 40
type
# int8 automaton: positive = include literal, negative = exclude
TsetlinNet* = object
# states[out][clause][literal] — flattened
states: array[N_OUT * N_CLAUSES * N_LITERALS, int8]
# Eligibility: store clause outputs (one bit per clause per output)
ClauseCache* = array[N_OUT * N_CLAUSES, uint8]
EligibilityTrace* = object
input*: BinaryVector
clauseOut*: ClauseCache
age*: int
alive*: bool
VirtualBullet* = object
trace*: EligibilityTrace
fireX*: float
fireY*: float
aimAngleDeg*: float
fireDist*: float
bulletSpeed*: float
active*: bool
# ── helpers ────────────────────────────────────────────────────────────
proc stateIdx(outIdx, clause, lit: int): int {.inline.} =
(outIdx * N_CLAUSES + clause) * N_LITERALS + lit
proc clausePolarity(clause: int): float {.inline.} =
# first HALF_CLAUSES = +1 polarity, rest = -1
if clause < HALF_CLAUSES: 1.0 else: -1.0
proc evalClause(net: TsetlinNet, outIdx, clause: int,
literals: array[N_LITERALS, uint8]): uint8 =
## Returns 1 if clause fires (AND of all included literals satisfied).
## Empty clause (no included literals) returns 0 — silent, not vacuously true.
var hasIncluded = false
for lit in 0..<N_LITERALS:
let s = net.states[stateIdx(outIdx, clause, lit)]
if s > 0:
hasIncluded = true
if literals[lit] == 0:
return 0'u8
return if hasIncluded: 1'u8 else: 0'u8
proc makeLiterals(input: BinaryVector): array[N_LITERALS, uint8] =
for i in 0..<N_IN:
result[i] = input[i]
result[i + N_IN] = 1'u8 - input[i]
proc computeVote(net: TsetlinNet, outIdx: int,
literals: array[N_LITERALS, uint8]): float =
for c in 0..<N_CLAUSES:
result += clausePolarity(c) * float(evalClause(net, outIdx, c, literals))
result = clamp(result, -T, T)
# ── public API ─────────────────────────────────────────────────────────
proc initTsetlinNet*(): TsetlinNet =
# Bug 2 fix: init at 0 (boundary). One Type I step crosses into Include.
for s in result.states.mitems:
s = 0'i8
proc forward*(net: TsetlinNet, input: BinaryVector): (float, float) =
## Returns (cx, cy) pixel corrections.
let literals = makeLiterals(input)
let cx = computeVote(net, 0, literals) / T * RESID_MAX
let cy = computeVote(net, 1, literals) / T * RESID_MAX
return (cx, cy)
proc forwardWithCache*(net: TsetlinNet, input: BinaryVector,
cache: var ClauseCache): (float, float) =
## Like forward but also populates cache for eligibility tracing.
let literals = makeLiterals(input)
var vx = 0.0
var vy = 0.0
for c in 0..<N_CLAUSES:
let o = evalClause(net, 0, c, literals)
cache[c] = o
vx += clausePolarity(c) * float(o)
for c in 0..<N_CLAUSES:
let o = evalClause(net, 1, c, literals)
cache[N_CLAUSES + c] = o
vy += clausePolarity(c) * float(o)
vx = clamp(vx, -T, T)
vy = clamp(vy, -T, T)
return (vx / T * RESID_MAX, vy / T * RESID_MAX)
proc learnOne(net: var TsetlinNet, outIdx: int, literals: array[N_LITERALS, uint8],
clauseOut: ClauseCache, residual: float) =
## Regression TM update for one output dimension.
# Recompute predicted from cached clause outputs
var vote = 0.0
for c in 0..<N_CLAUSES:
vote += clausePolarity(c) * float(clauseOut[outIdx * N_CLAUSES + c])
vote = clamp(vote, -T, T)
let predicted = vote / T * RESID_MAX
let error = residual - predicted
# Bug 1 fix: proper feedback probability gated on normalized error
let pFeedback = min(1.0, abs(error) / (2.0 * RESID_MAX))
for c in 0..<N_CLAUSES:
if pFeedback <= 0.0: continue
if rand(1.0) >= pFeedback: continue
let pol = clausePolarity(c)
let cOut = clauseOut[outIdx * N_CLAUSES + c]
if (error > 0.0 and pol > 0.0) or (error < 0.0 and pol < 0.0):
# Type I feedback: grow clause toward current input
if cOut == 1'u8:
# Type Ia: clause fires — reinforce matching features
for lit in 0..<N_LITERALS:
let si = stateIdx(outIdx, c, lit)
var st = int(net.states[si])
if literals[lit] == 1'u8:
if rand(1.0) < (S - 1.0) / S:
st = min(st + 1, N_STATES)
else:
if rand(1.0) < 1.0 / S:
st = max(st - 1, -N_STATES)
net.states[si] = int8(st)
else:
# Type Ib: clause silent, should fire — grow toward current input
for lit in 0..<N_LITERALS:
let si = stateIdx(outIdx, c, lit)
var st = int(net.states[si])
if literals[lit] == 1'u8:
if rand(1.0) < (S - 1.0) / S:
st = min(st + 1, N_STATES)
else:
if rand(1.0) < 1.0 / S:
st = max(st - 1, -N_STATES)
net.states[si] = int8(st)
else:
# Bug 3 fix: Type II — decrement (toward exclude) false literals in Include range
if cOut == 1'u8:
for lit in 0..<N_LITERALS:
if literals[lit] == 0'u8:
let si = stateIdx(outIdx, c, lit)
var st = int(net.states[si])
if st > 0: # only if currently in Include range
st = max(st - 1, -N_STATES)
net.states[si] = int8(st)
proc learn*(net: var TsetlinNet, trace: EligibilityTrace,
residualX: float, residualY: float) =
## Online TM update from a resolved virtual bullet.
## residualX/Y: pixel correction needed (actual_target - aimed_point).
if not trace.alive: return
let literals = makeLiterals(trace.input)
net.learnOne(0, literals, trace.clauseOut, residualX)
net.learnOne(1, literals, trace.clauseOut, residualY)
+87
View File
@@ -0,0 +1,87 @@
## WiSARD regression predictor — K=14, ~50 neurons, 16384 entries each.
## Input: TOTAL_BITS-bit BinaryVector. Output: (dx, dy) correction.
## Online learning via eligibility traces (addresses stored per pending wave).
import std/[math, random]
import binary_encoding
const
K* = 14
N_BITS_PAD = ((TOTAL_BITS + K - 1) div K) * K # pad to multiple of K
N_NEURONS* = N_BITS_PAD div K # ceil(690/14) = 50
LAST_BITS = if TOTAL_BITS mod K == 0: K else: TOTAL_BITS mod K # bits in last neuron
LUT_SIZE = 1 shl K # 16384
LAST_LUT_SIZE = 1 shl LAST_BITS # smaller LUT for last neuron
BLEACH_THRESHOLD* = 1 # only use entries with count > this
type
LutEntry = object
sumX: float
sumY: float
count: int
WiSARDNet* = object
perm*: array[N_BITS_PAD, int]
luts*: array[N_NEURONS, array[LUT_SIZE, LutEntry]]
# Eligibility trace: LUT addresses computed at fire time, replayed on resolution
WaveTrace* = object
addrs*: array[N_NEURONS, int]
age*: int
valid*: bool
# Virtual bullet slot (mirrors tsetlin_predictor's VirtualBullet)
VirtualBullet* = object
trace*: WaveTrace
fireX*: float
fireY*: float
aimAngleDeg*: float
fireDist*: float
bulletSpeed*: float
active*: bool
const TRACE_MAX_AGE* = 40
proc initWiSARD*(seed: int64 = 42): WiSARDNet =
## Build fixed random permutation over TOTAL_BITS; padding slots duplicate
## valid indices (random) to avoid zero-bias.
var rng = initRand(seed)
for i in 0..<TOTAL_BITS: result.perm[i] = i
# Fisher-Yates shuffle over real bits only
for i in countdown(TOTAL_BITS - 1, 1):
let j = rng.rand(i)
swap(result.perm[i], result.perm[j])
# Padding slots get random valid indices (no bias toward bit 0)
for i in TOTAL_BITS..<N_BITS_PAD:
result.perm[i] = rng.rand(TOTAL_BITS - 1)
proc computeAddresses*(ws: WiSARDNet, vec: BinaryVector): array[N_NEURONS, int] =
for n in 0..<N_NEURONS:
var laddr = 0
let bits = if n == N_NEURONS - 1: LAST_BITS else: K
for b in 0..<bits:
laddr = (laddr shl 1) or int(vec[ws.perm[n * K + b]])
result[n] = laddr
proc predictCorrection*(ws: WiSARDNet, addrs: array[N_NEURONS, int]): tuple[cx, cy: float] =
## Average (sumX/count, sumY/count) across neurons with count > BLEACH_THRESHOLD.
var sx = 0.0; var sy = 0.0; var active = 0
for n in 0..<N_NEURONS:
let e = ws.luts[n][addrs[n]]
if e.count > BLEACH_THRESHOLD:
sx += e.sumX / float(e.count)
sy += e.sumY / float(e.count)
inc active
if active == 0: return (0.0, 0.0)
(sx / float(active), sy / float(active))
proc learnCorrection*(ws: var WiSARDNet, addrs: array[N_NEURONS, int],
dx, dy: float) =
## Accumulate (dx, dy) residuals at the addressed LUT entries.
if dx.isNaN or dy.isNaN or dx.classify == fcInf or dx.classify == fcNegInf or
dy.classify == fcInf or dy.classify == fcNegInf: return
for n in 0..<N_NEURONS:
let a = addrs[n]
ws.luts[n][a].sumX += dx
ws.luts[n][a].sumY += dy
ws.luts[n][a].count += 1
+11
View File
@@ -0,0 +1,11 @@
{
"name": "ModularBot",
"version": "0.1.0",
"authors": ["Davide Cappellini"],
"description": "Plugin gun architecture — head-on gun via virtual bullet harness",
"homepage": "",
"countryCodes": ["IT"],
"gameTypes": ["classic", "1v1"],
"platform": "Nim",
"programmingLang": "Nim"
}
+12
View File
@@ -0,0 +1,12 @@
# Package
version = "0.1.0"
author = "Davide Cappellini"
description = "Modular gun harness bot — plugin architecture tracer bullet"
license = "MIT"
srcDir = "src"
bin = @["ModularBot"]
# Dependencies
requires "nim >= 2.0.0"
requires "robocode_tankroyale_botapi >= 1.0.7"
# gun_harness and radar_lock are in common_libs, wired via config.nims --path
+7
View File
@@ -0,0 +1,7 @@
--path:"../common_libs"
switch("outdir", "out")
switch("path", thisDir() & "/src")
# begin Nimble config (version 2)
when withDir(thisDir(), system.fileExists("nimble.paths")):
include "nimble.paths"
# end Nimble config
+11
View File
@@ -0,0 +1,11 @@
{
"name": "ModularBot",
"version": "0.1.0",
"authors": ["Davide Cappellini"],
"description": "Plugin gun architecture — head-on gun via virtual bullet harness",
"homepage": "",
"countryCodes": ["IT"],
"gameTypes": ["classic", "1v1"],
"platform": "Nim",
"programmingLang": "Nim"
}
+187
View File
@@ -0,0 +1,187 @@
## ModularBot — plugin gun architecture tracer bullet.
## Guns: HeadOnGun (0), LinearGun (1), TsetlinGun (2), CircularGun (3) via GunHarness.
## Radar: RadarLockModule via radar harness.
## Movement: OscillatorModule (perpendicular strafing).
import std/[math, os, strformat]
import robocode_tankroyale_botapi
import radar_harness/radar_interface
import radars/radar_lock_module
import gun_harness/gun_interface
import gun_harness/virtual_bullets as vb
import gun_harness/selector
import guns/head_on
import guns/linear
import guns/circular
import guns/tsetlin
import movements/phantom_meteor
const botJsonPath = currentSourcePath().parentDir / "ModularBot.json"
const DebugVBullets = false
const DebugCircular = false
const GunNames = ["HeadOn", "Linear", "Tsetlin", "Circular"]
type
ModularBot = ref object of Bot
hasContact: bool
enemyBearing: float
lastState: WorldState
radar: RadarLockModule
tracker: VirtualTracker
headOn: HeadOnGun
linear: LinearGun
circular: CircularGun
tsetlin: TsetlinGun
mover: PhantomMeteorModule
virtualHits: int
virtualMiss: int
tick: int
currentGun: int
proc buildState(bot: ModularBot, ex, ey, espeed, eheading, eenergy: float): WorldState =
WorldState(
enemyX: ex,
enemyY: ey,
enemySpeed: espeed,
enemyHeading: eheading,
enemyEnergy: eenergy,
selfX: getX(),
selfY: getY(),
selfSpeed: getSpeed(),
selfHeading: getDirection(),
selfRadarHeading: getRadarDirection(),
selfEnergy: getEnergy(),
arenaWidth: getArenaWidth().float,
arenaHeight: getArenaHeight().float,
tick: bot.tick,
)
method onScannedBot*(bot: ModularBot, e: ScannedBotEvent) =
bot.enemyBearing = directionTo(getX(), getY(), e.x, e.y)
bot.hasContact = true
bot.lastState = bot.buildState(e.x, e.y, e.speed, e.direction, e.energy)
# Collect predictions for all power bins from all guns
var headsUp: array[len(PowerBins), GunPrediction]
var linPreds: array[len(PowerBins), GunPrediction]
var tmPreds: array[len(PowerBins), GunPrediction]
var circPreds: array[len(PowerBins), GunPrediction]
for i in 0..<len(PowerBins):
headsUp[i] = bot.headOn.predict(bot.lastState, bulletSpeed(PowerBins[i]))
linPreds[i] = bot.linear.predict(bot.lastState, bulletSpeed(PowerBins[i]))
tmPreds[i] = bot.tsetlin.predict(bot.lastState, bulletSpeed(PowerBins[i]))
circPreds[i] = bot.circular.predict(bot.lastState, bulletSpeed(PowerBins[i]))
bot.tracker.spawnBullets(0, headsUp, bot.lastState)
bot.tracker.spawnBullets(1, linPreds, bot.lastState)
bot.tracker.spawnBullets(2, tmPreds, bot.lastState)
bot.tracker.spawnBullets(3, circPreds, bot.lastState)
# Resolve bullets that have travelled far enough
let st = bot.lastState
bot.tracker.tickBullets(st, proc(gunId: GunId, binIdx: int, fe: FeedbackEvent) =
case gunId
of 0: bot.headOn.onResult(fe)
of 1: bot.linear.onResult(fe)
of 2: bot.tsetlin.onResult(fe)
of 3:
bot.circular.onResult(fe)
when DebugCircular:
let ax = st.enemyX
let ay = st.enemyY
echo fmt"[circ-vb] predicted=({fe.prediction.x:.0f},{fe.prediction.y:.0f}) actual=({ax:.0f},{ay:.0f}) miss={fe.missDistance:.1f}px hit={fe.hit}"
else: discard
if fe.hit: inc bot.virtualHits else: inc bot.virtualMiss
when DebugVBullets:
let total = bot.virtualHits + bot.virtualMiss
let pct = if total > 0: bot.virtualHits.float / total.float * 100.0 else: 0.0
echo fmt"[vbullet] gun={gunId} bin={binIdx} miss={fe.missDistance:.1f}px hit={fe.hit} | total hits={bot.virtualHits}/{total} ({pct:.1f}%)"
)
# Gun selection + fire
let (selectedGun, _, power) = selectShot(bot.tracker)
# Log gun switch + update turret/bullet colors
if selectedGun != bot.currentGun:
echo fmt"[gun] switched: {GunNames[selectedGun]}"
bot.currentGun = selectedGun
case selectedGun
of 0: setTurretColor("#FF3333"); setBulletColor("#FF6666") # HeadOn: warm red
of 1: setTurretColor("#3366FF"); setBulletColor("#6699FF") # Linear: electric blue
of 2: setTurretColor("#9933FF"); setBulletColor("#CC66FF") # Tsetlin: violet
of 3: setTurretColor("#33CC33"); setBulletColor("#66FF66") # Circular: emerald
else: discard
let pred = case selectedGun
of 1: bot.linear.predict(bot.lastState, bulletSpeed(power))
of 2: bot.tsetlin.predict(bot.lastState, bulletSpeed(power))
of 3: bot.circular.predict(bot.lastState, bulletSpeed(power))
else: bot.headOn.predict(bot.lastState, bulletSpeed(power))
let target = aimAngle(getX(), getY(), pred.x, pred.y)
let gunDir = getGunDirection()
let gunHeat = getGunHeat()
let gunDelta = (target - gunDir) mod 360.0
var normDelta = gunDelta
if normDelta > 180.0: normDelta -= 360.0
elif normDelta < -180.0: normDelta += 360.0
if shouldFire(gunDir, target, gunHeat):
discard setFire(power)
# Turn gun toward predicted point every scan
setGunTurnRate(normDelta)
method onRoundEnded*(bot: ModularBot, e: RoundEndedEventForBot) =
discard # Skip detailed logging for now
method onRoundStarted*(bot: ModularBot, e: RoundStartedEvent) =
setAdjustGunForBodyTurn(true)
setAdjustRadarForBodyTurn(true)
setAdjustRadarForGunTurn(true)
setBodyColor("#FF9900") # PhantomMeteor: deep orange/gold
setRadarColor("#00CCCC") # RadarLock: cyan/teal
setScanColor("#33FFFF") # RadarLock scan arc: bright teal
bot.radar.init()
bot.hasContact = false
bot.tick = 0
bot.currentGun = -1 # Reset to trigger initial log on first selectShot
bot.mover.resetRound()
echo fmt"[gun] starting: {GunNames[0]}"
# tracker fitness persists across rounds (rolling window carries over)
method onGameStarted*(bot: ModularBot, e: GameStartedEventForBot) =
discard
method run*(bot: ModularBot) =
while isRunning():
inc bot.tick
if not bot.hasContact:
setTargetSpeed(0.0)
setTurnRate(0.0)
setRadarTurnRate(45.0)
go()
continue
# Movement harness: oscillate perpendicular to enemy
let (spd, tr) = bot.mover.computeMove(bot.lastState)
setTargetSpeed(spd)
setTurnRate(tr)
setRadarTurnRate(bot.radar.computeScan(bot.lastState))
go()
when isMainModule:
var bot = ModularBot(
tracker: vb.initTracker(4), # 0: HeadOn, 1: Linear, 2: Tsetlin, 3: Circular
headOn: HeadOnGun(),
linear: LinearGun(),
circular: CircularGun(),
tsetlin: initTsetlinGun(),
radar: RadarLockModule(),
mover: initPhantomMeteor(),
currentGun: -1,
)
start(bot, botJsonPath)
@@ -0,0 +1,17 @@
## Minimal ModularBot for testing.
import std/os
import robocode_tankroyale_botapi
const botJsonPath = currentSourcePath().parentDir / "ModularBot.json"
type ModularBot = ref object of Bot
method run*(bot: ModularBot) =
while isRunning():
setTargetSpeed(0.0)
setRadarTurnRate(45.0)
go()
when isMainModule:
var bot = ModularBot()
start(bot, botJsonPath)
+1
View File
@@ -0,0 +1 @@
--path:"../../common_libs"
+32
View File
@@ -0,0 +1,32 @@
import std/[os, strutils]
import test_framework/test_framework
import test_framework/battle_result
import test_framework/server_manager
import test_framework/bot_compiler
import test_framework/runner_process
const
modularBotDir = currentSourcePath().parentDir.parentDir
tfAdversaries = currentSourcePath().parentDir.parentDir.parentDir /
"common_libs" / "test_framework" / "adversaries"
sittingDuck = tfAdversaries / "SittingDuck"
# Skip if JARs are missing
if not fileExists("/home/davide/Projects/tank-royale/server/build/libs/robocode-tankroyale-server-0.35.5-all.jar") or
not fileExists("/home/davide/Projects/tank-royale/runner/examples/lib/robocode-tankroyale-runner.jar"):
echo "Skipping: Tank Royale JARs not found"
quit(0)
# Compile bots
let compiledBots = compileBots(@[modularBotDir, sittingDuck])
echo "Compiled bots: ", compiledBots
# Run battle
ensureServer()
let stdout = runBattleRunner(getServerUrl(), @[modularBotDir, sittingDuck], 3, 300000, true)
echo ""
echo "Raw battle runner output (last 50 lines):"
let lines = stdout.split('\n')
let start = max(0, lines.len - 50)
for i in start..<lines.len:
echo lines[i]
@@ -0,0 +1,58 @@
import std/[os, unittest, strformat]
import test_framework/test_framework
const
modularBotDir = currentSourcePath().parentDir.parentDir
samplesDir = "/home/davide/Downloads/robocode-tankroyale/sample-bots-nim-linux-1.0.7"
target = samplesDir / "Target"
spinBot = samplesDir / "SpinBot"
crazy = samplesDir / "Crazy"
trackFire = samplesDir / "TrackFire"
# Skip if JARs are missing
if not fileExists("/home/davide/Projects/tank-royale/server/build/libs/robocode-tankroyale-server-0.35.5-all.jar") or
not fileExists("/home/davide/Projects/tank-royale/runner/examples/lib/robocode-tankroyale-runner.jar"):
echo "Skipping: Tank Royale JARs not found"
quit(0)
proc extractFinalScores(r: BattleResult, enemyName: string): tuple[modularScore, enemyScore: int] =
var modularScore = 0
var enemyScore = 0
for b in r.results:
if b.name == "ModularBot": modularScore = b.totalScore
if b.name == enemyName: enemyScore = b.totalScore
(modularScore, enemyScore)
suite "ModularBot battle suite":
test "vs Target (stationary)":
echo "\n[1/4] ModularBot vs Target"
let r = runBattle(@[modularBotDir, target], rounds = 10, timeout = 180000)
let (mb, en) = extractFinalScores(r, "Target")
let winner = if mb > en: "ModularBot" else: "Target"
echo fmt" ModularBot: {mb}, Target: {en} → {winner} wins"
check mb > 0 or en > 0 # At least one score recorded
test "vs SpinBot (circular)":
echo "\n[2/4] ModularBot vs SpinBot"
let r = runBattle(@[modularBotDir, spinBot], rounds = 10, timeout = 180000)
let (mb, en) = extractFinalScores(r, "SpinBot")
let winner = if mb > en: "ModularBot" else: "SpinBot"
echo fmt" ModularBot: {mb}, SpinBot: {en} → {winner} wins"
check mb > 0 or en > 0
test "vs Crazy (unpredictable)":
echo "\n[3/4] ModularBot vs Crazy"
let r = runBattle(@[modularBotDir, crazy], rounds = 10, timeout = 180000)
let (mb, en) = extractFinalScores(r, "Crazy")
let winner = if mb > en: "ModularBot" else: "Crazy"
echo fmt" ModularBot: {mb}, Crazy: {en} → {winner} wins"
check mb > 0 or en > 0
test "vs TrackFire (fires back)":
echo "\n[4/4] ModularBot vs TrackFire"
let r = runBattle(@[modularBotDir, trackFire], rounds = 10, timeout = 180000)
let (mb, en) = extractFinalScores(r, "TrackFire")
let winner = if mb > en: "ModularBot" else: "TrackFire"
echo fmt" ModularBot: {mb}, TrackFire: {en} → {winner} wins"
check mb > 0 or en > 0
@@ -0,0 +1,24 @@
import std/os
import std/strutils
import test_framework/test_framework
if not existsEnv("TR_SERVER_JAR") or not existsEnv("TR_BATTLE_RUNNER"):
echo "Skipping: TR_SERVER_JAR / TR_BATTLE_RUNNER not set"
quit(0)
let modularBotDir = "/home/davide/Projects/SirRoboGarage/ModularBot_garage"
let sampleBotsDir = "/home/davide/Downloads/robocode-tankroyale/sample-bots-nim-linux-1.0.7"
echo "\n=== BATTLE 1: ModularBot vs SpinBot (Circular Movement) ==="
let r1 = runBattle(@[modularBotDir, sampleBotsDir / "SpinBot"], rounds = 10)
echo "\nBattle 1 Results:"
for bot in r1.results:
echo " " & bot.name & ": score=" & $bot.totalScore & " rank=" & $bot.rank
echo "Winners: " & r1.winners.join(", ")
echo "\n=== BATTLE 2: ModularBot vs Crazy (Unpredictable Movement) ==="
let r2 = runBattle(@[modularBotDir, sampleBotsDir / "Crazy"], rounds = 10)
echo "\nBattle 2 Results:"
for bot in r2.results:
echo " " & bot.name & ": score=" & $bot.totalScore & " rank=" & $bot.rank
echo "Winners: " & r2.winners.join(", ")
@@ -0,0 +1,36 @@
import std/os
import std/strutils
import test_framework/test_framework
if not existsEnv("TR_SERVER_JAR") or not existsEnv("TR_BATTLE_RUNNER"):
echo "Skipping: TR_SERVER_JAR / TR_BATTLE_RUNNER not set"
quit(0)
let modularBotDir = "/home/davide/Projects/SirRoboGarage/ModularBot_garage"
let sampleBotsDir = "/home/davide/Downloads/robocode-tankroyale/sample-bots-nim-linux-1.0.7"
echo "\n" & "=".repeat(60)
echo "BATTLE 1: ModularBot vs SpinBot (Circular Movement)"
echo "=".repeat(60)
let r1 = runBattle(@[modularBotDir, sampleBotsDir / "SpinBot"], rounds = 10)
echo "\nRound Details:"
for i, round in r1.rounds:
echo " Round " & $i & ":"
for bot in round.results:
echo " " & bot.name & ": score=" & $bot.score & " rank=" & $bot.rank & " survived=" & $bot.survived
echo "\nFinal Scores:"
for bot in r1.results:
echo " " & bot.name & ": score=" & $bot.totalScore & " rank=" & $bot.rank & " firstPlaces=" & $bot.firstPlaces & " survived=" & $bot.survivalCount
echo "\n" & "=".repeat(60)
echo "BATTLE 2: ModularBot vs Crazy (Unpredictable Movement)"
echo "=".repeat(60)
let r2 = runBattle(@[modularBotDir, sampleBotsDir / "Crazy"], rounds = 10)
echo "\nRound Details:"
for i, round in r2.rounds:
echo " Round " & $i & ":"
for bot in round.results:
echo " " & bot.name & ": score=" & $bot.score & " rank=" & $bot.rank & " survived=" & $bot.survived
echo "\nFinal Scores:"
for bot in r2.results:
echo " " & bot.name & ": score=" & $bot.totalScore & " rank=" & $bot.rank & " firstPlaces=" & $bot.firstPlaces & " survived=" & $bot.survivalCount
@@ -0,0 +1,34 @@
import std/os
import std/strutils
import test_framework/test_framework
if not existsEnv("TR_SERVER_JAR") or not existsEnv("TR_BATTLE_RUNNER"):
echo "Skipping: TR_SERVER_JAR / TR_BATTLE_RUNNER not set"
quit(0)
let modularBotDir = "/home/davide/Projects/SirRoboGarage/ModularBot_garage"
let sampleBotsDir = "/home/davide/Downloads/robocode-tankroyale/sample-bots-nim-linux-1.0.7"
echo "\n" & "=".repeat(60)
echo "BATTLE 1: ModularBot vs SpinBot (Circular Movement)"
echo "=".repeat(60)
let r1 = runBattle(@[modularBotDir, sampleBotsDir / "SpinBot"], rounds = 10)
echo "\nFinal Scores:"
for bot in r1.results:
echo " " & bot.name & ": score=" & $bot.totalScore & " rank=" & $bot.rank & " firstPlaces=" & $bot.firstPlaces & " survived=" & $bot.survivalCount
echo "Winner: " & r1.winners[0]
echo "\n" & "=".repeat(60)
echo "BATTLE 2: ModularBot vs Crazy (Unpredictable Movement)"
echo "=".repeat(60)
let r2 = runBattle(@[modularBotDir, sampleBotsDir / "Crazy"], rounds = 10)
echo "\nFinal Scores:"
for bot in r2.results:
echo " " & bot.name & ": score=" & $bot.totalScore & " rank=" & $bot.rank & " firstPlaces=" & $bot.firstPlaces & " survived=" & $bot.survivalCount
echo "Winner: " & r2.winners[0]
echo "\n" & "=".repeat(60)
echo "SUMMARY"
echo "=".repeat(60)
echo "Battle 1 (vs SpinBot): " & (if "ModularBot" in r1.winners: "WON" else: "LOST")
echo "Battle 2 (vs Crazy): " & (if "ModularBot" in r2.winners: "WON" else: "LOST")
@@ -0,0 +1,39 @@
## Unit test for ModularBot gun selection
import unittest
import gun_harness/virtual_bullets as vb
import gun_harness/selector
import gun_harness/gun_interface
suite "Gun Selection":
test "selector picks best gun and power":
# Create a simple tracker with mock vbullets
var t = initTracker(4)
# Spawn some bullets on gun 1 (linear) to simulate tracking history
let state = WorldState(
enemyX: 100, enemyY: 100, enemySpeed: 5.0, enemyHeading: 0.0,
selfX: 50, selfY: 50, selfSpeed: 0.0, selfHeading: 0.0,
selfRadarHeading: 0.0, selfEnergy: 100.0,
arenaWidth: 800, arenaHeight: 600, tick: 1
)
# Gun 1 (linear) predictions
var linPreds: array[len(PowerBins), GunPrediction]
for i in 0..<len(PowerBins):
linPreds[i] = GunPrediction(x: 105.0 + i.float, y: 105.0 + i.float)
t.spawnBullets(1, linPreds, state)
# Simulate a hit on one of the linear gun bullets
t.tickBullets(state, proc(gunId: GunId, binIdx: int, fe: FeedbackEvent) =
if gunId == 1 and binIdx == 0:
check fe.hit == false # Just verify callback works
)
# Select best shot
let (gunId, binIdx, power) = selectShot(t)
echo "[test] Selected gun=" & $gunId & " binIdx=" & $binIdx & " power=" & $power
check gunId >= 0 and gunId < 4 # Should be one of 4 guns
check binIdx >= 0 and binIdx < len(PowerBins) # Valid power bin
@@ -0,0 +1,20 @@
import std/os
import std/strutils
import test_framework/test_framework
if not existsEnv("TR_SERVER_JAR") or not existsEnv("TR_BATTLE_RUNNER"):
echo "Skipping: TR_SERVER_JAR / TR_BATTLE_RUNNER not set"
quit(0)
let modularBotDir = "/home/davide/Projects/SirRoboGarage/ModularBot_garage"
let sampleBotsDir = "/home/davide/Downloads/robocode-tankroyale/sample-bots-nim-linux-1.0.7"
echo "\nSingle round: ModularBot vs SpinBot"
let r = runBattle(@[modularBotDir, sampleBotsDir / "SpinBot"], rounds = 1)
for i, round in r.rounds:
echo "\nRound " & $i & " details:"
for bot in round.results:
echo " " & bot.name & ": score=" & $bot.score & " survived=" & $bot.survived
echo "\nFinal: " & r.results[0].name & " " & $r.results[0].totalScore & " vs " & r.results[1].name & " " & $r.results[1].totalScore
@@ -0,0 +1,70 @@
import std/[os, unittest]
import test_framework/test_framework
import test_framework/battle_result
import test_framework/server_manager
import test_framework/bot_compiler
import test_framework/runner_process
const
modularBotDir = currentSourcePath().parentDir.parentDir
tfAdversaries = currentSourcePath().parentDir.parentDir.parentDir /
"common_libs" / "test_framework" / "adversaries"
sittingDuck = tfAdversaries / "SittingDuck"
# Skip if JARs are missing
if not fileExists("/home/davide/Projects/tank-royale/server/build/libs/robocode-tankroyale-server-0.35.5-all.jar") or
not fileExists("/home/davide/Projects/tank-royale/runner/examples/lib/robocode-tankroyale-runner.jar"):
echo "Skipping: Tank Royale JARs not found"
quit(0)
suite "ModularBot basic battle":
test "beats SittingDuck in 1 round":
# Debug: manually compile and check
try:
let compiledBots = compileBots(@[modularBotDir, sittingDuck])
echo "Compiled bots: ", compiledBots
for bot in compiledBots:
if fileExists(bot):
echo " ✓ ", bot
else:
echo " ✗ MISSING: ", bot
except CatchableError as e:
echo "Compilation error: ", e.msg
raise
# Now run battle with longer timeout for ModularBot boot
ensureServer()
let stdout = runBattleRunner(getServerUrl(), @[modularBotDir, sittingDuck], 1, 180000, true)
echo ""
echo "Raw battle runner output:"
echo stdout
echo ""
let r = parseServerOutput(stdout)
echo ""
echo "=== Battle Result ==="
echo "Rounds: ", r.rounds.len
echo "Bots: ", r.bots
echo ""
echo "Final standings:"
for result in r.results:
echo " ", result.name, ": rank=", result.rank, " score=", result.totalScore,
" survived=", result.survivalCount, " first=", result.firstPlaces
echo ""
echo "Winners: ", r.winners
echo ""
# Basic checks
check r.rounds.len == 1
check r.results.len == 2
check "ModularBot" in r.bots
check "SittingDuck" in r.bots
# ModularBot should win
var modularResult: BotResult
for b in r.results:
if b.name == "ModularBot": modularResult = b
check modularResult.name == "ModularBot"
check modularResult.rank == 1
check "ModularBot" in r.winners
+49
View File
@@ -0,0 +1,49 @@
## Gun harness — shared types and Gun concept.
## Coordinate system: 0° = East, CCW positive (Tank Royale standard).
## Bullet speed formula: 20 - 3 * power.
import std/math
const BotRadius* = 18.0 ## hit detection radius in px
type
WorldState* = object
## All raw data given to every gun every tick.
# Enemy
enemyX*, enemyY*: float
enemySpeed*, enemyHeading*: float
# Self
selfX*, selfY*: float
selfSpeed*, selfHeading*: float
selfRadarHeading*: float
selfEnergy*: float
enemyEnergy*: float
# Arena
arenaWidth*, arenaHeight*: float
# Meta
tick*: int
GunPrediction* = object
## Absolute (x, y) where the gun predicts the enemy will be.
x*, y*: float
FeedbackEvent* = object
## Outcome of a resolved virtual bullet.
prediction*: GunPrediction
bulletPower*: float
missDistance*: float ## px; < BotRadius = hit
hit*: bool
proc bulletSpeed*(power: float): float {.inline.} =
20.0 - 3.0 * power
## Gun concept — any type T implementing these two procs is a valid gun.
template isGun*(T: typedesc): bool =
compiles(
block:
var g: T
let ws = WorldState()
let p: GunPrediction = g.predict(ws, 0.0)
let fe = FeedbackEvent()
g.onResult(fe)
)
+26
View File
@@ -0,0 +1,26 @@
## Gun selector — picks best gun×power, computes aim angle, gates firing.
## Fires highest power with acceptable hit rate when gun is aimed within
## threshold and gunHeat == 0.
import std/math
import gun_interface
import virtual_bullets
const AimThresholdDeg* = 2.0 ## max angle error to fire; ponytail: tune per bot
proc aimAngle*(selfX, selfY, targetX, targetY: float): float =
## Absolute bearing in degrees (0=East, CCW+) toward (targetX, targetY).
result = radToDeg(arctan2(targetY - selfY, targetX - selfX))
proc shouldFire*(currentGunDir, targetAngle, gunHeat: float): bool =
## Returns true when gun is close enough and cool enough to fire.
var delta = (targetAngle - currentGunDir) mod 360.0
if delta > 180.0: delta -= 360.0
elif delta < -180.0: delta += 360.0
abs(delta) <= AimThresholdDeg and gunHeat <= 0.0
proc selectShot*(t: VirtualTracker): (GunId, int, float) =
## Returns (gunId, powerBinIdx, power) — the shot to take this tick.
let gunId = t.bestGun()
let (binIdx, power) = t.bestPower(gunId)
result = (gunId, binIdx, power)
+133
View File
@@ -0,0 +1,133 @@
## Virtual bullet tracker.
## Spawns virtual bullets per gun×power bin every tick (no real firing).
## Resolves by travel distance. Rolling window fitness per gun×power.
## Calls onResult() on the owning gun when a bullet resolves.
import std/math
import gun_interface
const
PowerBins* = [1.0, 1.5, 2.0, 3.0] ## 4 bins; ponytail: fixed array, add runtime config if needed
WindowSize* = 50 ## rolling window ticks for fitness
MaxBullets* = 512 ## hard cap; ponytail: ring buffer, resize if more guns added
MinHitRate* = 0.40 ## 40% threshold for acceptable power selection
type
GunId* = int ## index into the guns seq
VirtualBullet* = object
gunId*: GunId
powerBin*: int ## index into PowerBins
fireX*, fireY*: float
aimX*, aimY*: float ## predicted target (absolute)
bulletSpeed*: float
travelDist*: float ## accumulated px so far
fireDist*: float ## distance to target at fire time
active*: bool
FitnessWindow* = object
## Ring buffer of hit booleans.
hits*: array[WindowSize, bool]
count*: int ## total samples so far (capped at WindowSize for rate)
head*: int
GunFitness* = object
bins*: array[len(PowerBins), FitnessWindow]
VirtualTracker* = object
bullets*: array[MaxBullets, VirtualBullet]
head*: int ## ring buffer head
fitness*: seq[GunFitness] ## indexed by GunId
proc initTracker*(numGuns: int): VirtualTracker =
result.fitness = newSeq[GunFitness](numGuns)
proc hitRate*(fw: FitnessWindow): float =
## Returns fraction of hits in the rolling window. 0.0 when no data.
if fw.count == 0: return 0.0
let n = min(fw.count, WindowSize)
var h = 0
for i in 0..<n: h += (if fw.hits[i]: 1 else: 0)
result = h.float / n.float
proc record(fw: var FitnessWindow, hit: bool) =
fw.hits[fw.head] = hit
fw.head = (fw.head + 1) mod WindowSize
inc fw.count
proc spawnBullets*(t: var VirtualTracker, gunId: GunId,
predictions: array[len(PowerBins), GunPrediction],
state: WorldState) =
## Call once per gun per tick with predictions for all power bins.
for binIdx in 0..<len(PowerBins):
let power = PowerBins[binIdx]
let speed = bulletSpeed(power)
let pred = predictions[binIdx]
let fireDist = hypot(pred.x - state.selfX, pred.y - state.selfY)
let slot = t.head mod MaxBullets
t.bullets[slot] = VirtualBullet(
gunId: gunId,
powerBin: binIdx,
fireX: state.selfX,
fireY: state.selfY,
aimX: pred.x,
aimY: pred.y,
bulletSpeed: speed,
travelDist: 0.0,
fireDist: fireDist,
active: true,
)
t.head = (t.head + 1) mod MaxBullets
proc tickBullets*(t: var VirtualTracker, state: WorldState,
onResolved: proc(gunId: GunId, binIdx: int, e: FeedbackEvent)) =
## Advance all active bullets one tick. Resolve when bullet reaches target distance.
for i in 0..<MaxBullets:
var b = addr t.bullets[i]
if not b.active: continue
b.travelDist += b.bulletSpeed
if b.travelDist < b.fireDist: continue
# Resolved: compute miss distance against current enemy position
# Direction from fire point to aim point
let dx = b.aimX - b.fireX
let dy = b.aimY - b.fireY
let dist = hypot(dx, dy)
let (bx, by) =
if dist < 1e-6: (b.aimX, b.aimY)
else: (b.fireX + dx / dist * b.travelDist,
b.fireY + dy / dist * b.travelDist)
let missDist = hypot(bx - state.enemyX, by - state.enemyY)
let hit = missDist < BotRadius
t.fitness[b.gunId].bins[b.powerBin].record(hit)
let fe = FeedbackEvent(
prediction: GunPrediction(x: b.aimX, y: b.aimY),
bulletPower: PowerBins[b.powerBin],
missDistance: missDist,
hit: hit,
)
onResolved(b.gunId, b.powerBin, fe)
b.active = false
proc bestPower*(t: VirtualTracker, gunId: GunId): (int, float) =
## Returns (binIdx, power) with highest power that has >= MinHitRate.
## Falls back to lowest power bin if nothing qualifies yet.
result = (0, PowerBins[0])
for binIdx in countdown(len(PowerBins) - 1, 0):
let rate = t.fitness[gunId].bins[binIdx].hitRate()
if rate >= MinHitRate or t.fitness[gunId].bins[binIdx].count == 0:
return (binIdx, PowerBins[binIdx])
proc bestGun*(t: VirtualTracker): GunId =
## Pick gun with highest hit rate across all power bins.
## ponytail: O(n*bins), fine for small gun counts
var bestRate = -1.0
result = 0
for gunId in 0..<t.fitness.len:
for binIdx in 0..<len(PowerBins):
let r = t.fitness[gunId].bins[binIdx].hitRate()
if r > bestRate:
bestRate = r
result = gunId
+18 -21
View File
@@ -6,9 +6,11 @@ import std/math
import gun_harness/gun_interface
type CircularGun* = object
prevHeading: float ## enemy heading from last frame
prevTick: int ## tick at last observation
prevHeading: float ## enemy heading from the tick before current
prevTick: int ## tick of prevHeading observation
hasPrev: bool
cachedOmega: float ## omega (rad/tick) computed on first call this tick
cachedTick: int ## tick for which cachedOmega was computed
proc predict*(g: var CircularGun, state: WorldState, bulletSpeed: float): GunPrediction =
if bulletSpeed <= 0.0:
@@ -26,32 +28,27 @@ proc predict*(g: var CircularGun, state: WorldState, bulletSpeed: float): GunPre
g.hasPrev = true
return GunPrediction(x: state.enemyX, y: state.enemyY)
# Save old state before potential update (predict is called once per power bin per tick)
let oldHeading = g.prevHeading
let oldTick = g.prevTick
# Update on new tick only
# On new tick: recompute cachedOmega and advance the heading window.
# On same-tick calls (multiple power bins): reuse cachedOmega so omega
# doesn't collapse to zero on bins 1+.
if state.tick > g.prevTick:
var turnRate = state.enemyHeading - g.prevHeading
if turnRate > 180.0: turnRate -= 360.0
elif turnRate < -180.0: turnRate += 360.0
let tickDelta = max(1, state.tick - g.prevTick)
g.cachedOmega = degToRad(turnRate / tickDelta.float)
g.cachedTick = state.tick
g.prevHeading = state.enemyHeading
g.prevTick = state.tick
# Compute turn rate using captured old state
var turnRate = state.enemyHeading - oldHeading
if turnRate > 180.0: turnRate -= 360.0
elif turnRate < -180.0: turnRate += 360.0
# Divide by actual tick delta (scans may not be every tick)
let tickDelta = max(1, state.tick - oldTick)
turnRate = turnRate / tickDelta.float
# Closed-form integrated trajectory (Robowiki circular targeting)
# 0°=East: dx/dt = v*cos(θ+ω*t), dy/dt = v*sin(θ+ω*t)
# → x(t) = x₀ + (v/ω)*[sin(θ+ω*t) - sin(θ)]
# → y(t) = y₀ - (v/ω)*[cos(θ+ω*t) - cos(θ)]
let omega = g.cachedOmega
let theta = degToRad(state.enemyHeading)
let omega = degToRad(turnRate) # rad/tick
let v = state.enemySpeed
# Closed-form integrated trajectory (Robowiki circular targeting)
# 0°=East CCW+: dx/dt = v*cos(θ+ω*t), dy/dt = v*sin(θ+ω*t)
# → x(t) = x₀ + (v/ω)*[sin(θ+ω*t) - sin(θ)]
# → y(t) = y₀ - (v/ω)*[cos(θ+ω*t) - cos(θ)]
var ex = state.enemyX
var ey = state.enemyY
var t = ticks
+13
View File
@@ -0,0 +1,13 @@
## Head-on gun: predict enemy stays at current position.
## Simplest possible Gun implementation — baseline reference.
import gun_harness/gun_interface
type HeadOnGun* = object
discard
proc predict*(g: var HeadOnGun, state: WorldState, bulletSpeed: float): GunPrediction =
GunPrediction(x: state.enemyX, y: state.enemyY)
proc onResult*(g: var HeadOnGun, e: FeedbackEvent) =
discard # analytical gun — no learning
+22
View File
@@ -0,0 +1,22 @@
## Linear gun: predict enemy continues at current velocity and heading.
## Coordinate system: 0° = East, CCW positive (Tank Royale standard).
import std/math
import gun_harness/gun_interface
type LinearGun* = object
discard
proc predict*(g: var LinearGun, state: WorldState, bulletSpeed: float): GunPrediction =
let dist = hypot(state.enemyX - state.selfX, state.enemyY - state.selfY)
let ticksToArrive = dist / bulletSpeed
let headingRad = degToRad(state.enemyHeading)
var px = state.enemyX + cos(headingRad) * state.enemySpeed * ticksToArrive
var py = state.enemyY + sin(headingRad) * state.enemySpeed * ticksToArrive
# Clamp to arena bounds
px = clamp(px, 0.0, state.arenaWidth)
py = clamp(py, 0.0, state.arenaHeight)
GunPrediction(x: px, y: py)
proc onResult*(g: var LinearGun, e: FeedbackEvent) =
discard # analytical gun — no learning
+286
View File
@@ -0,0 +1,286 @@
## Tsetlin Machine gun — regression TM for aiming correction on top of linear extrapolation.
## Self-contained: includes binary encoding and TM predictor inline.
## Implements Gun interface: predict(state, bulletSpeed) → GunPrediction, onResult(FeedbackEvent).
import std/[math, random]
import gun_harness/gun_interface
# ── Binary encoding (adapted from BNNBot_garage/src/binary_encoding.nim) ─────
const
TM_FRAME_BITS = 83
TM_SELF_BITS = 40
TM_WINDOW_SIZE = 10
TM_TOTAL_BITS* = TM_FRAME_BITS * TM_WINDOW_SIZE + TM_SELF_BITS # 870
TM_MAX_DISTANCE = 1414.0 # diagonal of 1000x1000 arena
type
TmBinaryVector = array[TM_TOTAL_BITS, uint8]
TmFrameEncoded = array[TM_FRAME_BITS, uint8]
TmSelfEncoded = array[TM_SELF_BITS, uint8]
proc tmToGray(value: int): int = value xor (value shr 1)
proc tmToBits(value: int, bits: int): seq[uint8] =
result = newSeq[uint8](bits)
let gray = tmToGray(value)
for i in 0..<bits:
result[bits - 1 - i] = uint8((gray shr i) and 1)
proc tmEncodeFrame(bearing, distance, velocity, heading,
wallN, wallS, wallE, wallW, energy: float): TmFrameEncoded =
var offset = 0
# bearing sin: 8 bits
let bSin = tmToBits(clamp(int((sin(degToRad(bearing)) + 1.0) / 2.0 * 199.0), 0, 199), 8)
for i in 0..<8: result[offset + i] = bSin[i]
offset += 8
# bearing cos: 8 bits
let bCos = tmToBits(clamp(int((cos(degToRad(bearing)) + 1.0) / 2.0 * 199.0), 0, 199), 8)
for i in 0..<8: result[offset + i] = bCos[i]
offset += 8
# distance %: 7 bits
let distBits = tmToBits(clamp(int(distance / TM_MAX_DISTANCE * 99.0), 0, 99), 7)
for i in 0..<7: result[offset + i] = distBits[i]
offset += 7
# velocity: 5 bits
let velBits = tmToBits(clamp(int(velocity + 8.0), 0, 16), 5)
for i in 0..<5: result[offset + i] = velBits[i]
offset += 5
# heading sin: 8 bits
let hSin = tmToBits(clamp(int((sin(degToRad(heading)) + 1.0) / 2.0 * 199.0), 0, 199), 8)
for i in 0..<8: result[offset + i] = hSin[i]
offset += 8
# heading cos: 8 bits
let hCos = tmToBits(clamp(int((cos(degToRad(heading)) + 1.0) / 2.0 * 199.0), 0, 199), 8)
for i in 0..<8: result[offset + i] = hCos[i]
offset += 8
# wall distances: 4×7 bits
for wall in [wallN, wallS, wallE, wallW]:
let wBits = tmToBits(clamp(int(wall / 1000.0 * 99.0), 0, 99), 7)
for i in 0..<7: result[offset + i] = wBits[i]
offset += 7
# energy: 11 bits
let eBits = tmToBits(clamp(int(energy * 10.0), 0, 1500), 11)
for i in 0..<11: result[offset + i] = eBits[i]
proc tmEncodeSelf(wallN, wallS, wallE, wallW, energy: float, canFire: bool): TmSelfEncoded =
var offset = 0
for wall in [wallN, wallS, wallE, wallW]:
let wBits = tmToBits(clamp(int(wall / 1000.0 * 99.0), 0, 99), 7)
for i in 0..<7: result[offset + i] = wBits[i]
offset += 7
let eBits = tmToBits(clamp(int(energy * 10.0), 0, 1500), 11)
for i in 0..<11: result[offset + i] = eBits[i]
offset += 11
result[offset] = if canFire: 1'u8 else: 0'u8
proc tmEncodeFullVector(window: array[TM_WINDOW_SIZE, TmFrameEncoded],
self: TmSelfEncoded): TmBinaryVector =
var offset = 0
for i in 0..<TM_WINDOW_SIZE:
for j in 0..<TM_FRAME_BITS:
result[offset] = window[i][j]; inc offset
for j in 0..<TM_SELF_BITS:
result[offset] = self[j]; inc offset
# ── Tsetlin Machine (adapted from BNNBot_garage/src/tsetlin_predictor.nim) ───
const
TM_N_IN = TM_TOTAL_BITS # 870
TM_N_OUT = 2 # cx, cy pixel corrections
TM_N_LITERALS = TM_N_IN * 2 # 1740
TM_N_CLAUSES = 50 # per output; issue #184 default
TM_HALF = TM_N_CLAUSES div 2
TM_N_STATES = 32 # automaton range [-32..32]
TM_T = float(TM_HALF) # vote clamped to [-T, T]
TM_S = 1.5 # specificity
TM_RESID_MAX = 80.0 # pixel correction range
# ponytail: TM_N_STATES=32 needs int16 (int8 only fits ≤127, fine here); raise N_CLAUSES if underfitting
type
TmClauseCache = array[TM_N_OUT * TM_N_CLAUSES, uint8]
TmNet = object
states: array[TM_N_OUT * TM_N_CLAUSES * TM_N_LITERALS, int16]
# ponytail: int16 to safely hold [-32..32]; TM_N_STATES=32 fits int8 too but int16 is safer
proc tmStateIdx(outIdx, clause, lit: int): int {.inline.} =
(outIdx * TM_N_CLAUSES + clause) * TM_N_LITERALS + lit
proc tmPolarity(clause: int): float {.inline.} =
if clause < TM_HALF: 1.0 else: -1.0
proc tmMakeLiterals(input: TmBinaryVector): array[TM_N_LITERALS, uint8] =
for i in 0..<TM_N_IN:
result[i] = input[i]
result[i + TM_N_IN] = 1'u8 - input[i]
proc tmEvalClause(net: TmNet, outIdx, clause: int,
lits: array[TM_N_LITERALS, uint8]): uint8 =
var hasIncluded = false
for lit in 0..<TM_N_LITERALS:
let s = net.states[tmStateIdx(outIdx, clause, lit)]
if s > 0:
hasIncluded = true
if lits[lit] == 0: return 0'u8
return if hasIncluded: 1'u8 else: 0'u8
proc tmForwardWithCache(net: TmNet, input: TmBinaryVector,
cache: var TmClauseCache): (float, float) =
let lits = tmMakeLiterals(input)
var vx = 0.0; var vy = 0.0
for c in 0..<TM_N_CLAUSES:
let o = tmEvalClause(net, 0, c, lits)
cache[c] = o
vx += tmPolarity(c) * float(o)
for c in 0..<TM_N_CLAUSES:
let o = tmEvalClause(net, 1, c, lits)
cache[TM_N_CLAUSES + c] = o
vy += tmPolarity(c) * float(o)
vx = clamp(vx, -TM_T, TM_T)
vy = clamp(vy, -TM_T, TM_T)
(vx / TM_T * TM_RESID_MAX, vy / TM_T * TM_RESID_MAX)
proc tmLearnOne(net: var TmNet, outIdx: int, lits: array[TM_N_LITERALS, uint8],
cache: TmClauseCache, residual: float) =
var vote = 0.0
for c in 0..<TM_N_CLAUSES:
vote += tmPolarity(c) * float(cache[outIdx * TM_N_CLAUSES + c])
vote = clamp(vote, -TM_T, TM_T)
let predicted = vote / TM_T * TM_RESID_MAX
let error = residual - predicted
let pFeedback = min(1.0, abs(error) / (2.0 * TM_RESID_MAX))
for c in 0..<TM_N_CLAUSES:
if pFeedback <= 0.0: continue
if rand(1.0) >= pFeedback: continue
let pol = tmPolarity(c)
let cOut = cache[outIdx * TM_N_CLAUSES + c]
if (error > 0.0 and pol > 0.0) or (error < 0.0 and pol < 0.0):
# Type I / Ib feedback
for lit in 0..<TM_N_LITERALS:
let si = tmStateIdx(outIdx, c, lit)
var st = int(net.states[si])
if lits[lit] == 1'u8:
if rand(1.0) < (TM_S - 1.0) / TM_S: st = min(st + 1, TM_N_STATES)
else:
if rand(1.0) < 1.0 / TM_S: st = max(st - 1, -TM_N_STATES)
net.states[si] = int16(st)
else:
# Type II: shrink false literals in include range
if cOut == 1'u8:
for lit in 0..<TM_N_LITERALS:
if lits[lit] == 0'u8:
let si = tmStateIdx(outIdx, c, lit)
var st = int(net.states[si])
if st > 0:
net.states[si] = int16(max(st - 1, -TM_N_STATES))
# ── TsetlinGun public type ────────────────────────────────────────────────────
const
TM_TRACE_SLOTS = 64 # ring buffer of pending traces
# ponytail: 64 slots >> TRACE_MAX_AGE=40 ticks, safe margin; grow if many guns/bins
type
TmTrace = object
predX, predY: float # key: matches FeedbackEvent.prediction
input: TmBinaryVector
cache: TmClauseCache
alive: bool
TsetlinGun* = object
net: TmNet
frameBuffer: array[TM_WINDOW_SIZE, TmFrameEncoded]
bufferCount: int
traces: array[TM_TRACE_SLOTS, TmTrace]
traceHead: int
proc initTsetlinGun*(): TsetlinGun =
# states init at 0 (boundary); one Type I step crosses into Include
for s in result.net.states.mitems: s = 0'i16
randomize()
proc predict*(g: var TsetlinGun, state: WorldState, bulletSpeed: float): GunPrediction =
# Encode current frame and push into window
let dist = hypot(state.enemyX - state.selfX, state.enemyY - state.selfY)
let bearing = radToDeg(arctan2(state.enemyY - state.selfY, state.enemyX - state.selfX))
let frame = tmEncodeFrame(
bearing, dist, state.enemySpeed, state.enemyHeading,
state.arenaHeight - state.enemyY, state.enemyY,
state.arenaWidth - state.enemyX, state.enemyX,
state.selfEnergy, # use self energy as proxy (enemy energy not in WorldState)
)
# Shift window: index 0 = newest
for i in countdown(TM_WINDOW_SIZE - 1, 1):
g.frameBuffer[i] = g.frameBuffer[i - 1]
g.frameBuffer[0] = frame
if g.bufferCount < TM_WINDOW_SIZE: inc g.bufferCount
# Warm-up: until window is full, fall back to linear extrapolation
let ticksToArrive = if bulletSpeed > 0.0: dist / bulletSpeed else: 1.0
let headingRad = degToRad(state.enemyHeading)
let linearX = state.enemyX + cos(headingRad) * state.enemySpeed * ticksToArrive
let linearY = state.enemyY + sin(headingRad) * state.enemySpeed * ticksToArrive
if g.bufferCount < TM_WINDOW_SIZE:
return GunPrediction(x: clamp(linearX, 0.0, state.arenaWidth),
y: clamp(linearY, 0.0, state.arenaHeight))
let selfState = tmEncodeSelf(
state.arenaHeight - state.selfY, state.selfY,
state.arenaWidth - state.selfX, state.selfX,
state.selfEnergy,
true, # canFire not in WorldState; assume true
)
let vec = tmEncodeFullVector(g.frameBuffer, selfState)
var cache: TmClauseCache
let (cx, cy) = tmForwardWithCache(g.net, vec, cache)
let predX = clamp(linearX + cx, 0.0, state.arenaWidth)
let predY = clamp(linearY + cy, 0.0, state.arenaHeight)
# Store trace keyed by prediction coords
let slot = g.traceHead mod TM_TRACE_SLOTS
g.traces[slot] = TmTrace(predX: predX, predY: predY, input: vec, cache: cache, alive: true)
g.traceHead = (slot + 1) mod TM_TRACE_SLOTS
GunPrediction(x: predX, y: predY)
proc onResult*(g: var TsetlinGun, e: FeedbackEvent) =
# Find matching trace by prediction coords
for i in 0..<TM_TRACE_SLOTS:
var t = addr g.traces[i]
if not t.alive: continue
if abs(t.predX - e.prediction.x) > 0.01 or abs(t.predY - e.prediction.y) > 0.01:
continue
# Shaped reward: residual = (actual enemy pos) - (bullet impact pos)
# FeedbackEvent carries missDistance but not the direction.
# We reconstruct: aimed at (predX, predY); miss is distance to current enemy.
# Use miss distance as magnitude; direction unknown → scale back along aim vector.
# ponytail: zero-direction residual when miss=0 is fine; TM learns from magnitude via pFeedback
let missSign = if e.hit: 0.0 else: 1.0
let residualX = (e.prediction.x - t.predX) * missSign # trivially 0; real signal is missDistance
# Better: treat miss distance as residual magnitude along (enemy - pred) direction
# We don't have enemy pos here directly, but we can scale correction proportionally.
# Simplest correct signal: pass missDistance as residual magnitude for both dims.
let rMag = e.missDistance * missSign
let lits = tmMakeLiterals(t.input)
# Apply residual equally to both axes (we don't know direction split)
# ponytail: split 50/50; upgrade to directional when FeedbackEvent carries enemy pos
let r = rMag / sqrt(2.0)
g.net.tmLearnOne(0, lits, t.cache, r)
g.net.tmLearnOne(1, lits, t.cache, r)
t.alive = false
break
@@ -0,0 +1,20 @@
## Movement harness — shared types and MovementModule concept.
## Mirrors gun_interface.nim structure.
import gun_harness/gun_interface
export gun_interface.WorldState
type
MoveCommand* = tuple[speed: float, turnRate: float]
## speed: target speed in px/tick, clamped to ±8 by bot API
## turnRate: body turn rate in degrees/tick
## MovementModule concept — any type T implementing computeMove is valid.
template isMovementModule*(T: typedesc): bool =
compiles(
block:
var m: T
let ws = WorldState()
let cmd: MoveCommand = m.computeMove(ws)
)
+50
View File
@@ -0,0 +1,50 @@
## Oscillator movement — perpendicular strafing relative to enemy bearing.
## Reverses direction every PERIOD ticks; wall proximity triggers one reversal
## then locks out further wall-reversals for WALL_LOCKOUT ticks to prevent
## sign-flip every tick (which would zero net movement and park the bot).
import std/math
import gun_harness/gun_interface
import movement_harness/movement_interface
const
MaxSpeed* = 8.0 ## Tank Royale max speed
Period* = 40 ## ticks between reversals; ponytail: fixed, tune if evasion feels predictable
WallMargin* = 80.0 ## px from wall to trigger early reversal
WallLockout* = 20 ## ticks to suppress further wall-reversals after one fires
type OscillatorModule* = object
sign: float ## +1 or -1, forward/backward relative to perp heading
elapsed: int ## ticks since last reversal
wallLockout: int ## remaining ticks where wall-reversal is suppressed
proc initOscillator*(): OscillatorModule =
OscillatorModule(sign: 1.0, elapsed: 0, wallLockout: 0)
proc computeMove*(m: var OscillatorModule, ws: WorldState): MoveCommand =
inc m.elapsed
if m.wallLockout > 0: dec m.wallLockout
# Perpendicular heading to enemy: enemy bearing + 90°
let enemyBearing = arctan2(ws.enemyY - ws.selfY, ws.enemyX - ws.selfX) * (180.0 / PI)
let perpHeading = (enemyBearing + 90.0) mod 360.0
let nearWall = ws.selfX < WallMargin or ws.selfX > ws.arenaWidth - WallMargin or
ws.selfY < WallMargin or ws.selfY > ws.arenaHeight - WallMargin
if m.elapsed >= Period or (nearWall and m.wallLockout == 0):
m.sign *= -1.0
m.elapsed = 0
if nearWall: m.wallLockout = WallLockout
# Turn rate = delta from current heading toward perpendicular
var delta = perpHeading - ws.selfHeading
while delta > 180.0: delta -= 360.0
while delta < -180.0: delta += 360.0
# When moving backward, flip the turn to keep perpendicular
var normDelta = if m.sign < 0: delta - 180.0 else: delta
while normDelta > 180.0: normDelta -= 360.0
while normDelta < -180.0: normDelta += 360.0
(speed: m.sign * MaxSpeed, turnRate: normDelta.clamp(-10.0, 10.0))
+232
View File
@@ -0,0 +1,232 @@
## phantom_meteor.nim — PhantomMeteor gravity engine as a MovementModule.
## Wraps gravity.nim (GravityEngine) to satisfy the MovementModule concept.
## Fire detection, phantom bullets, waves, and danger histogram are all internal.
## All angles in radians internally; interface outputs degrees for bot API.
import std/math
import gun_harness/gun_interface
import movement_harness/movement_interface
# Inline the gravity engine types and logic here to keep the module self-contained.
# We re-export nothing from gravity.nim — it's not on the common_libs path.
# ponytail: copy instead of import; if gravity.nim moves to common_libs, collapse.
# ── Vec2 (local, unexported) ──────────────────────────────────────────────────
type
Vec2 = object
x, y: float64
proc vec2(x, y: float64): Vec2 {.inline.} = Vec2(x: x, y: y)
proc `+`(a, b: Vec2): Vec2 {.inline.} = vec2(a.x+b.x, a.y+b.y)
proc `-`(a, b: Vec2): Vec2 {.inline.} = vec2(a.x-b.x, a.y-b.y)
proc `*`(a: Vec2, s: float64): Vec2 {.inline.} = vec2(a.x*s, a.y*s)
proc magnitude(v: Vec2): float64 {.inline.} = sqrt(v.x*v.x + v.y*v.y)
proc normalize(v: Vec2): Vec2 =
let m = v.magnitude
if m < 1e-9: vec2(0.0, 0.0) else: vec2(v.x/m, v.y/m)
proc dist(a, b: Vec2): float64 {.inline.} = (a-b).magnitude
# ── Gravity engine types ──────────────────────────────────────────────────────
const NumBins = 41
type
DangerHistogram = object
bins: array[NumBins, float64]
PhantomBullet = object
pos, vel: Vec2
weight: float64
alive: bool
ticks: int
Wave = object
origin: Vec2
heading: float64
speed: float64
radius: float64
startDist: float64
GravityEngine = object
histogram: DangerHistogram
phantoms: seq[PhantomBullet]
waves: seq[Wave]
prevEnemyEnergy: float64
# ── Gravity engine internals ──────────────────────────────────────────────────
const
KBullet = 1500.0
KWall = 4000.0
KEnemy = 300.0
PreferredDist = 400.0
NumPhantoms = 25
MinDist = 20.0
WallMinDist = 40.0
VeryClose = 40.0
proc initEngine(): GravityEngine =
var h: DangerHistogram
for i in 0..<NumBins: h.bins[i] = 1.0
GravityEngine(histogram: h, phantoms: @[], waves: @[], prevEnemyEnergy: 100.0)
proc gfToBin(gf: float64): int =
int(((gf.clamp(-1.0,1.0) + 1.0) / 2.0 * float64(NumBins-1)).round).clamp(0, NumBins-1)
proc mea(speed: float64): float64 = arcsin(min(8.0/speed, 1.0))
proc detectFire(eng: var GravityEngine, energy: float64): tuple[fired: bool; power: float64] =
let drop = eng.prevEnemyEnergy - energy
eng.prevEnemyEnergy = energy
if drop >= 0.1 and drop <= 3.0: (true, drop) else: (false, 0.0)
proc spawnPhantoms(eng: var GravityEngine, enemyPos, botPos: Vec2, bspeed: float64) =
let base = arctan2(botPos.y - enemyPos.y, botPos.x - enemyPos.x)
let maxA = mea(bspeed)
for i in 0..<NumPhantoms:
let gf = if NumPhantoms == 1: 0.0 else: -1.0 + float64(i)/float64(NumPhantoms-1)*2.0
let angle = base + gf * maxA
let weight = eng.histogram.bins[gfToBin(gf)]
eng.phantoms.add PhantomBullet(
pos: enemyPos, vel: vec2(bspeed*cos(angle), bspeed*sin(angle)),
weight: weight, alive: true, ticks: 0)
proc tickPhantoms(eng: var GravityEngine) =
for i in 0..<eng.phantoms.len:
if not eng.phantoms[i].alive: continue
eng.phantoms[i].pos = eng.phantoms[i].pos + eng.phantoms[i].vel
inc eng.phantoms[i].ticks
if eng.phantoms[i].ticks >= 50: eng.phantoms[i].alive = false
if eng.phantoms.len > 200:
var live: seq[PhantomBullet]
for p in eng.phantoms:
if p.alive: live.add p
eng.phantoms = live
proc spawnWave(eng: var GravityEngine, enemyPos, botPos: Vec2, bspeed: float64) =
eng.waves.add Wave(
origin: enemyPos,
heading: arctan2(botPos.y - enemyPos.y, botPos.x - enemyPos.x),
speed: bspeed, radius: 0.0, startDist: dist(enemyPos, botPos))
proc tickWaves(eng: var GravityEngine, botPos: Vec2) =
var i = 0
while i < eng.waves.len:
eng.waves[i].radius += eng.waves[i].speed
if eng.waves[i].radius >= eng.waves[i].startDist:
let toBot = arctan2(botPos.y - eng.waves[i].origin.y,
botPos.x - eng.waves[i].origin.x)
var off = toBot - eng.waves[i].heading
while off > PI: off -= 2.0*PI
while off < -PI: off += 2.0*PI
let maxA = mea(eng.waves[i].speed)
if maxA >= 1e-9:
let gf = (off / maxA).clamp(-1.0, 1.0)
eng.histogram.bins[gfToBin(gf)] += 1.0
eng.waves.del(i)
else:
inc i
proc computeForces(eng: GravityEngine, botPos, enemyPos: Vec2, arenaW, arenaH: float64): Vec2 =
var total = vec2(0.0, 0.0)
# Priority 1: nearby phantoms
var hasClose = false
for ph in eng.phantoms:
if ph.alive:
let d = dist(ph.pos, botPos)
if d < 150.0:
hasClose = true
total = total + normalize(botPos - ph.pos) * (KBullet * (1.0 - d/150.0))
# Priority 2: wall escape (hard override)
let minW = min(min(botPos.x, arenaW-botPos.x), min(botPos.y, arenaH-botPos.y))
if minW < VeryClose:
var ex = 0.0; var ey = 0.0
if botPos.x < VeryClose: ex = 1.0
if arenaW - botPos.x < VeryClose: ex = -1.0
if botPos.y < VeryClose: ey = 1.0
if arenaH - botPos.y < VeryClose: ey = -1.0
let m = sqrt(ex*ex + ey*ey)
if m > 0.1: return normalize(vec2(ex,ey)) * 500.0
# Priority 3: distance to enemy
let de = dist(enemyPos, botPos)
if not hasClose and de > 100.0:
if de < PreferredDist:
total = total + normalize(botPos - enemyPos) * KEnemy
elif de > PreferredDist + 100.0:
total = total + normalize(enemyPos - botPos) * (KEnemy * 0.3)
# Priority 4: weak wall repulsion
let dL = max(botPos.x, WallMinDist)
let dR = max(arenaW - botPos.x, WallMinDist)
let dB = max(botPos.y, WallMinDist)
let dT = max(arenaH - botPos.y, WallMinDist)
total = total + vec2(KWall*0.5/(dL*dL) - KWall*0.5/(dR*dR),
KWall*0.5/(dB*dB) - KWall*0.5/(dT*dT))
total
# ── MovementModule wrapper ────────────────────────────────────────────────────
type PhantomMeteorModule* = object
engine: GravityEngine
proc initPhantomMeteor*(): PhantomMeteorModule =
PhantomMeteorModule(engine: initEngine())
proc resetRound*(m: var PhantomMeteorModule) =
## Clear per-round transients (phantoms, waves, energy baseline), keep histogram.
m.engine.phantoms = @[]
m.engine.waves = @[]
m.engine.prevEnemyEnergy = 100.0
proc computeMove*(m: var PhantomMeteorModule, ws: WorldState): MoveCommand =
let botPos = vec2(ws.selfX, ws.selfY)
let enemyPos = vec2(ws.enemyX, ws.enemyY)
# Advance simulation
m.engine.tickPhantoms()
m.engine.tickWaves(botPos)
# Fire detection → spawn phantoms + wave
let (fired, power) = m.engine.detectFire(ws.enemyEnergy)
if fired:
let bspeed = 20.0 - 3.0 * power
m.engine.spawnPhantoms(enemyPos, botPos, bspeed)
m.engine.spawnWave(enemyPos, botPos, bspeed)
# Compute force vector
let force = m.engine.computeForces(botPos, enemyPos, ws.arenaWidth, ws.arenaHeight)
if force.magnitude < 1e-9:
return (speed: 0.0, turnRate: 0.0)
# Enemy bearing (radians, math convention: 0=East, CCW+)
let enemyBearingRad = arctan2(enemyPos.y - botPos.y, enemyPos.x - botPos.x)
# Two perpendicular directions to enemy bearing (±90°)
let perpCCW = vec2(-sin(enemyBearingRad), cos(enemyBearingRad)) # +90°
let perpCW = vec2( sin(enemyBearingRad), -cos(enemyBearingRad)) # -90°
# Pick perpendicular direction that aligns with force vector
let fn = force.normalize
let desiredDeg =
if fn.x * perpCCW.x + fn.y * perpCCW.y >= fn.x * perpCW.x + fn.y * perpCW.y:
radToDeg(arctan2(perpCCW.y, perpCCW.x))
else:
radToDeg(arctan2(perpCW.y, perpCW.x))
# Delta from current heading
var delta = desiredDeg - ws.selfHeading
while delta > 180.0: delta -= 360.0
while delta < -180.0: delta += 360.0
# Dot-product trick: if |delta| > 90 → reverse, less turning
let goForward = abs(delta) <= 90.0
if not goForward:
delta = if delta >= 0.0: delta - 180.0 else: delta + 180.0
(speed: if goForward: 8.0 else: -8.0,
turnRate: delta.clamp(-10.0, 10.0))
@@ -0,0 +1,15 @@
## Radar harness — shared RadarModule concept.
## Coordinate system: 0° = East, CCW positive (Tank Royale standard).
import ../gun_harness/gun_interface
export gun_interface # re-export WorldState
## Radar concept — any type T implementing computeScan is a valid radar module.
template isRadarModule*(T: typedesc): bool =
compiles(
block:
var r: T
let ws = WorldState()
let rate: float = r.computeScan(ws)
)
+18
View File
@@ -0,0 +1,18 @@
## RadarLock adapter — wraps radar_lock.doRadar() as a RadarModule.
import std/math
import ../radar_harness/radar_interface
import ../radar_lock/radar_lock as radar_lock_impl
export radar_interface
type RadarLockModule* = object
proc computeScan*(m: RadarLockModule, state: WorldState): float =
## Returns radar turn rate (deg/tick) to lock onto enemy.
let enemyBearing = arctan2(state.enemyY - state.selfY,
state.enemyX - state.selfX).radToDeg
radar_lock_impl.doRadar(state.selfRadarHeading, enemyBearing)
proc init*(m: var RadarLockModule) =
radar_lock_impl.init()
+81
View File
@@ -0,0 +1,81 @@
import dev.robocode.tankroyale.runner.*;
import dev.robocode.tankroyale.client.model.*;
import java.util.List;
import java.util.Map;
import java.util.concurrent.ConcurrentHashMap;
import java.util.logging.Level;
import java.util.logging.Logger;
/**
* BNNBot validation battle: BNNBot vs Target (1 round, embedded server).
*
* Env vars:
* BNN_BOT_DIR — path to BNNBot directory (must contain BNNBot.json + BNNBot.sh)
* SAMPLE_BOTS_DIR — path to sample bots archive
* BNNBOT_CSV — forwarded to bot process: "1" enables CSV output
* ENEMY_BOT — name of enemy bot subfolder under SAMPLE_BOTS_DIR (default: Target)
* NUM_ROUNDS — number of rounds to run (default: 5)
*
* Usage: java -cp ".:$JAR" RunBNNBattle
*/
public class RunBNNBattle {
static final Map<Integer, String> idToName = new ConcurrentHashMap<>();
public static void main(String[] args) {
Logger.getLogger("dev.robocode.tankroyale").setLevel(Level.WARNING);
String bnnBotDir = requireEnv("BNN_BOT_DIR");
String sampleBots = requireEnv("SAMPLE_BOTS_DIR");
String enemyBot = System.getenv().getOrDefault("ENEMY_BOT", "Target");
int numRounds = Integer.parseInt(System.getenv().getOrDefault("NUM_ROUNDS", "5"));
System.out.println("Enemy: " + enemyBot + " Rounds: " + numRounds);
try (var runner = BattleRunner.create(b -> b.embeddedServer().suppressServerOutput())) {
var setup = BattleSetup.classic(s -> s.setNumberOfRounds(numRounds));
var bots = List.of(
BotEntry.of(bnnBotDir),
BotEntry.of(sampleBots + "/" + enemyBot)
);
var owner = new Object();
try (var handle = runner.startBattleAsync(setup, bots)) {
handle.getOnGameStarted().on(owner, event -> {
System.out.println("=== GAME STARTED ===");
for (var p : event.getParticipants()) {
idToName.put(p.getId(), p.getName());
System.out.printf(" #%d %s%n", p.getId(), p.getName());
}
System.out.printf("%-6s %-12s %-10s%n", "Turn", "Name", "RadarDir");
System.out.println("-".repeat(40));
});
handle.getOnTickEvent().on(owner, event -> {
int turn = event.getTurnNumber();
for (var state : event.getBotStates())
if (state.getName() != null) idToName.putIfAbsent(state.getId(), state.getName());
});
handle.getOnRoundEnded().on(owner, event ->
System.out.printf("%n=== ROUND %d ENDED (turn %d) ===%n",
event.getRoundNumber(), event.getTurnNumber()));
var results = handle.awaitResults();
System.out.printf("%n=== RESULTS (%d rounds) ===%n", results.getNumberOfRounds());
for (var r : results.getResults())
System.out.printf(" #%d %-25s %d pts%n", r.getRank(), r.getName(), r.getTotalScore());
}
}
}
static String requireEnv(String name) {
var v = System.getenv(name);
if (v == null || v.isBlank()) {
System.err.println("Error: " + name + " env var not set");
System.exit(1);
}
return v;
}
}