# SNNBot — SNN core + aiming loop prototype (issue #152). # 80-input population-coded → 12 LIF hidden → polar-coded (sin/cos) output decoded via atan2. # Inputs: relVelDir [0-35], speed thermometer [36-43], distance thermometer [44-51]. Raw bearing removed (irrelevant to lead). # No movement, no firing. SuperSpike three-factor rule on all weights (issue #158). # Binary reservoir alternative toggled via USE_RESERVOIR const (issue #159). # # State machine: # DECIDE → feed bearing+velocity into SNN/reservoir, store targetAngle, → WAITING # WAITING → aimTo() each tick; when error < 2° → EVALUATE # EVALUATE → measure error, compute SuperSpike/reservoir update, log, → DECIDE import std/[math, random, os, strutils, strformat] import robocode_tankroyale_botapi import radar_lock/radar_lock as radar_lock import reservoir # Diagnostic log file for bullet economy tracking (independent of test framework) let diagnosticLog = "/tmp/snnbot_diagnostic.log" let roundStatsLog = "/tmp/snnbot_round_stats.log" let aimDebugLog = "/tmp/snnbot_aim_debug.log" let biasDebugLog = "/tmp/snnbot_bias_debug.log" proc diagLog(s: string) = try: let f = open(diagnosticLog, fmAppend) f.writeLine(s) f.close() except: discard proc appendLog(path, s: string) = try: let f = open(path, fmAppend) f.writeLine(s) f.close() except: discard proc truncateLog(path: string) = try: let f = open(path, fmWrite) f.close() except: discard const USE_RESERVOIR* = true # ponytail: kept for SNN path fallback; remove when binary aimer is validated # ── Constants ────────────────────────────────────────────────────────────────── const botJsonPath = currentSourcePath().parentDir / "SNNBot.json" const N_IN = 80 # input neurons: bearing(36) + vel_dir(36) + speed(8) N_HID = 12 # hidden LIF neurons BAND_DEG = 10.0 # degrees per input band BEARING_OFFSET = 0 # neurons 0-35: relative bearing VEL_DIR_OFFSET = 36 # neurons 36-71: velocity direction SPEED_OFFSET = 72 # neurons 72-79: speed bands N_SPEED_BANDS = 8 SPEED_BAND_WIDTH = 1.0 # units/tick per band MAX_SPEED = 8.0 LEAK = 0.9 # LIF membrane leak factor THRESH = 0.08 # ponytail: THRESH=0.08 tuned for 80-input layer; raise if firing saturates MAX_GUN_TURN = 20.0 # max gun turn per tick (degrees) ETA = 0.1 # SuperSpike learning rate for hidden→output weights (r_0 from paper, bumped 2x) ETA_IH = 0.02 # ponytail: ETA_IH=0.02 bumped 4x for input→hidden (was 0.005, too conservative) # ponytail: separate input→hidden rate; add RMaxProp optimizer if convergence still unstable N_INFER = 10 # inference window ticks per DECIDE # ponytail: N_INFER=10, increase if output still noisy; decrease if too slow per tick TRACE_DECAY = 0.9 # pre-synaptic trace decay (exponential low-pass) W_CLAMP = 1.0 # ponytail: W_CLAMP=1.0, paper says ±0.1 but that's for mV-scale voltages; our unitless THRESH=1.0 needs larger weights BETA = 1.0 # surrogate steepness (unitless; potentials are unitless) NOISE_AMP = 0.3 # exploration noise amplitude # ponytail: uniform exploration noise; upgrade to annealed Gaussian if convergence needs tuning ENERGY_GUARD = 5.0 # keep buffer; Walls hits us while we shoot FIRE_POWER = 2.0 # fixed fire power BULLET_SPEED = 14.0 # 20 - 3 * FIRE_POWER COLD_K = 3 # exemplars needed before leaving cold start PATIENCE_TICKS = 5 # ticks before first fire allowed # ── SNN types ───────────────────────────────────────────────────────────────── type SNN = object wih: array[N_IN * N_HID, float] # 80×12 input→hidden weights wSin: array[N_HID, float] # hidden→sin-channel weights wCos: array[N_HID, float] # hidden→cos-channel weights vHid: array[N_HID, float] # hidden membrane potentials preTrace: array[N_IN, float] # pre-synaptic traces (low-pass of input spikes) # Random feedback weights B[h, o] for hidden error projection; fixed, never updated. # Indexed as bFb[h * 2 + o], o=0 → sin, o=1 → cos. bFb: array[N_HID * 2, float] tick: int lastSinOut: float # last forward pass sin output (weighted sum) lastCosOut: float # last forward pass cos output (weighted sum) lastSnnAngle: float # last atan2 result (degrees) proc initSNN(snn: var SNN) = randomize() for w in snn.wih.mitems: w = rand(0.2) - 0.1 # init within clamp ±0.1, room to grow to ±1.0 for w in snn.wSin.mitems: w = rand(0.2) - 0.1 for w in snn.wCos.mitems: w = rand(0.2) - 0.1 for b in snn.bFb.mitems: b = rand(2.0) - 1.0 # N(0,1)-ish; fixed forever for t in snn.preTrace.mitems: t = 0.0 snn.tick = 0 proc encodeBearing(inputs: var array[N_IN, float], bearing: float, offset: int) = ## Population-code a -180..+180 angle into 36 neurons starting at offset. ## Triangular interpolation between lo and hi band. let norm = ((bearing + 180.0) / BAND_DEG) # 0..36 let lo = int(norm) mod 36 let hi = (lo + 1) mod 36 let frac = norm - float(int(norm)) inputs[offset + lo] = 1.0 - frac inputs[offset + hi] = frac proc encodeInput(bearing: float): array[N_IN, float] = ## Bearing-only encode for overlay (velocity channels stay 0). encodeBearing(result, bearing, BEARING_OFFSET) proc encodeInputFull(bearing: float, velDirDeg: float, speed: float, hasVel: bool): array[N_IN, float] = ## Full 80-neuron encode: bearing + velocity direction + speed. encodeBearing(result, bearing, BEARING_OFFSET) if hasVel: encodeBearing(result, velDirDeg, VEL_DIR_OFFSET) # Speed: triangular interpolation over N_SPEED_BANDS bands, clamp to [0, MAX_SPEED] let s = speed.clamp(0.0, MAX_SPEED) let norm = s / SPEED_BAND_WIDTH let lo = min(int(norm), N_SPEED_BANDS - 1) let hi = min(lo + 1, N_SPEED_BANDS - 1) let frac = norm - float(int(norm)) result[SPEED_OFFSET + lo] += 1.0 - frac result[SPEED_OFFSET + hi] += frac proc surrogateDerivative(v: float): float {.inline.} = ## σ'(U) = (1 + |β(U − ϑ)|)^{-2} — peaks at threshold, gives gradient direction. let x = BETA * (v - THRESH) result = 1.0 / ((1.0 + abs(x)) * (1.0 + abs(x))) proc forward(snn: var SNN, inputs: array[N_IN, float], spikesOut: var array[N_HID, float], vHidSnap: var array[N_HID, float], sinOut: var float, cosOut: var float) = ## One SNN tick. Writes hidden spikes, pre-spike voltages, and raw sin/cos outputs. ## Caller accumulates sinOut/cosOut across N_INFER ticks, then calls atan2. inc snn.tick # Pre-synaptic trace: low-pass of input spikes for i in 0 ..< N_IN: snn.preTrace[i] = TRACE_DECAY * snn.preTrace[i] + inputs[i] # Hidden layer: LIF update for h in 0 ..< N_HID: var wsum = 0.0 for i in 0 ..< N_IN: wsum += inputs[i] * snn.wih[i * N_HID + h] # ponytail: noise removed — SuperSpike surrogate derivative provides gradient direction, no exploration needed snn.vHid[h] = LEAK * snn.vHid[h] + wsum vHidSnap[h] = snn.vHid[h] # snapshot voltage before reset (for surrogate) if snn.vHid[h] >= THRESH: spikesOut[h] = 1.0 snn.vHid[h] = 0.0 else: spikesOut[h] = 0.0 # Output: polar-coded via sin/cos channels (raw; caller does atan2) sinOut = 0.0 cosOut = 0.0 for h in 0 ..< N_HID: sinOut += spikesOut[h] * snn.wSin[h] cosOut += spikesOut[h] * snn.wCos[h] snn.lastSinOut = sinOut snn.lastCosOut = cosOut snn.lastSnnAngle = arctan2(sinOut, cosOut) * 180.0 / PI proc superSpikeUpdate(snn: var SNN, spikes: array[N_HID, float], vSnap: array[N_HID, float], preTrace: array[N_IN, float], targetAngle: float) = ## SuperSpike three-factor weight update. ## Δw = η × pre_trace × σ'(U) × error ## spikes/vSnap/preTrace: captured at DECIDE time for this inference window. ## Output error: target_rate − actual_rate (rate-coded target). ## Hidden error: projected via fixed random feedback weights B. # Target rates for sin/cos channels: map [-1,1] → [0,1] let tSin = (sin(degToRad(targetAngle)) + 1.0) / 2.0 let tCos = (cos(degToRad(targetAngle)) + 1.0) / 2.0 # Normalize accumulated spike counts to rates in [0,1] # Actual output activity using spike rates var sinAct = 0.0; var cosAct = 0.0 for h in 0 ..< N_HID: let rate = spikes[h] / float(N_INFER) sinAct += rate * snn.wSin[h] cosAct += rate * snn.wCos[h] # Map actual output to [0,1] for rate comparison let sinActNorm = (sinAct.clamp(-1.0, 1.0) + 1.0) / 2.0 let cosActNorm = (cosAct.clamp(-1.0, 1.0) + 1.0) / 2.0 # Output error (target_rate − actual_rate) let errSin = tSin - sinActNorm let errCos = tCos - cosActNorm # Update hidden→output weights: Δw = η × rate_h × σ'(U_h) × error_o for h in 0 ..< N_HID: let sg = surrogateDerivative(vSnap[h]) let rate = spikes[h] / float(N_INFER) snn.wSin[h] += ETA * rate * sg * errSin snn.wSin[h] = snn.wSin[h].clamp(-W_CLAMP, W_CLAMP) snn.wCos[h] += ETA * rate * sg * errCos snn.wCos[h] = snn.wCos[h].clamp(-W_CLAMP, W_CLAMP) # Update input→hidden weights: Δw = η × preTrace_j × σ'(U_h) × error_h # Hidden error projected via fixed random feedback: error_h = Σ_o B[h,o] × error_o for h in 0 ..< N_HID: let sg = surrogateDerivative(vSnap[h]) let errHid = snn.bFb[h * 2 + 0] * errSin + snn.bFb[h * 2 + 1] * errCos for i in 0 ..< N_IN: snn.wih[i * N_HID + h] += ETA_IH * preTrace[i] * sg * errHid snn.wih[i * N_HID + h] = snn.wih[i * N_HID + h].clamp(-W_CLAMP, W_CLAMP) # ── Bot state machine ───────────────────────────────────────────────────────── type Phase = enum DECIDE, WAITING, EVALUATE SNNBot = ref object of Bot snn: SNN res: LeadGrid phase: Phase targetAngle: float # SNN output (absolute bearing) enemyBearing: float # last known enemy bearing enemyDist: float # last known distance to enemy hasContact: bool tick: int lastSpikes: array[N_HID, float] # accumulated spike counts over N_INFER ticks lastVSnap: array[N_HID, float] # hidden voltages before reset from last tick (for SuperSpike) lastRelBearing: float # relative bearing at DECIDE time (fixed for learning) lastEnemyX: float64 # previous tick enemy position (for velocity) lastEnemyY: float64 hasLastPos: bool velDirDeg: float64 # velocity direction (degrees) from last scan delta velSpeed: float64 # speed (units/tick) from last scan delta lastDecideGunDir: float # gun heading captured at DECIDE time for EVALUATE lastVPerp: float # DECIDE-time vPerp, reused in EVALUATE lastAbsBearing: float # absolute bearing to enemy captured at DECIDE time decideEnemyX: float # enemy X frozen at DECIDE time decideEnemyY: float # enemy Y frozen at DECIDE time decideVelDirDeg: float # enemy vel direction frozen at DECIDE time decideVelSpeed: float # enemy speed frozen at DECIDE time decideDist: float # distance to enemy frozen at DECIDE time roundTick: int # ticks elapsed in current round (reset each round) bulletsFired: int # count of bullets fired this round bulletsHit: int # count of bullets that hit this round hitRateEMA: float # exponential moving average of hit rate gridHasData: bool # false during cold-start (forward returned -999) # ── aimTo helper ────────────────────────────────────────────────────────────── proc aimTo(targetAngle, gunDir: float) {.inline.} = let delta = normalizeRelativeAngle(targetAngle - gunDir) setGunTurnRate(delta.clamp(-MAX_GUN_TURN, MAX_GUN_TURN)) # ── Debug overlay ───────────────────────────────────────────────────────────── proc drawOverlay(bot: SNNBot, myX, myY, gunDir, enemyBearing, targetAngle: float) = let LINE_LEN = bot.enemyDist + 50.0 # Convert degree bearing to SVG direction (SVG y-axis is inverted; 0=east, CCW+) template toXY(bearing, len: float): (float, float) = (myX + cos(degToRad(bearing)) * len, myY + sin(degToRad(bearing)) * len) # Green: enemy direction (truth) setStrokeColor(GREEN) setStrokeWidth(2.0) let (ex, ey) = toXY(enemyBearing, LINE_LEN) drawLine(myX, myY, ex, ey) # Red: gun direction setStrokeColor(RED) let (gx, gy) = toXY(gunDir, LINE_LEN) drawLine(myX, myY, gx, gy) # Yellow: SNN target angle setStrokeColor(YELLOW) let (tx, ty) = toXY(targetAngle, LINE_LEN) drawLine(myX, myY, tx, ty) # ── Event handlers ──────────────────────────────────────────────────────────── method onScannedBot*(bot: SNNBot, e: ScannedBotEvent) = let bx = getX(); let by = getY() bot.enemyBearing = directionTo(bx, by, e.x, e.y) bot.enemyDist = distanceTo(bx, by, e.x, e.y) bot.hasContact = true if bot.hasLastPos: let dx = e.x - bot.lastEnemyX let dy = e.y - bot.lastEnemyY bot.velDirDeg = arctan2(dy, dx) * 180.0 / PI bot.velSpeed = sqrt(dx * dx + dy * dy) bot.lastEnemyX = e.x; bot.lastEnemyY = e.y bot.hasLastPos = true method onRoundStarted*(bot: SNNBot, e: RoundStartedEvent) = setAdjustGunForBodyTurn(true) setAdjustRadarForBodyTurn(true) setAdjustRadarForGunTurn(true) radar_lock.init() bot.hasContact = false bot.hasLastPos = false bot.velDirDeg = 0.0 bot.velSpeed = 0.0 bot.phase = DECIDE bot.tick = 0 bot.roundTick = 0 # Reset per-round stats bot.bulletsFired = 0 bot.bulletsHit = 0 bot.hitRateEMA = 0.5 # optimistic start bot.gridHasData = false bot.lastEnemyX = 0.0 bot.lastEnemyY = 0.0 bot.lastAbsBearing = 0.0 bot.lastDecideGunDir = 0.0 bot.targetAngle = 0.0 setTargetSpeed(0.0) setTurnRate(0.0) method onRoundEnded*(bot: SNNBot, e: RoundEndedEventForBot) = let rate = if bot.bulletsFired > 0: bot.bulletsHit.float / bot.bulletsFired.float * 100.0 else: 0.0 let msg = "ROUND_STATS round=" & $e.roundNumber & " fired=" & $bot.bulletsFired & " hit=" & $bot.bulletsHit & " rate=" & formatFloat(rate, ffDecimal, 1) & "%" diagLog(msg) appendLog(roundStatsLog, fmt"ROUND fired={bot.bulletsFired} hit={bot.bulletsHit} rate={rate:.1f}% energy={getEnergy():.0f}") method onGameStarted*(bot: SNNBot, e: GameStartedEventForBot) = initSNN(bot.snn) bot.res = initLeadGrid() truncateLog(roundStatsLog) truncateLog(aimDebugLog) truncateLog(biasDebugLog) method onBulletFired*(bot: SNNBot, e: BulletFiredEvent) = inc bot.bulletsFired # decay EMA toward miss on each confirmed fire bot.hitRateEMA = bot.hitRateEMA * 0.93 method onBulletHit*(bot: SNNBot, e: BulletHitBotEvent) = inc bot.bulletsHit bot.hitRateEMA = bot.hitRateEMA * 0.85 + 1.0 * 0.15 # ── Main loop ───────────────────────────────────────────────────────────────── method run*(bot: SNNBot) = while isRunning(): inc bot.tick inc bot.roundTick setTargetSpeed(0.0) setTurnRate(0.0) if not bot.hasContact: setRadarTurnRate(45.0) go() continue let myX = getX() let myY = getY() let gunDir = getGunDirection() case bot.phase of DECIDE: # absBearing: world-frame bearing [0,360) from directionTo — independent of bot heading let absBearing = bot.enemyBearing bot.lastRelBearing = normalizeRelativeAngle(absBearing - gunDir) when USE_RESERVOIR: var relVelDir = bot.velDirDeg - absBearing while relVelDir >= 180.0: relVelDir -= 360.0 while relVelDir < -180.0: relVelDir += 360.0 let vPerp = bot.velSpeed * sin(degToRad(relVelDir)) let gridOffset = bot.res.forward(vPerp, bot.enemyDist) bot.lastVPerp = vPerp bot.lastDecideGunDir = gunDir bot.lastAbsBearing = absBearing bot.decideEnemyX = bot.lastEnemyX bot.decideEnemyY = bot.lastEnemyY bot.decideVelDirDeg = bot.velDirDeg bot.decideVelSpeed = bot.velSpeed bot.decideDist = bot.enemyDist let rate = if bot.bulletsFired > 0: float(bot.bulletsHit) / float(bot.bulletsFired) * 100.0 else: 0.0 bot.gridHasData = gridOffset > -999.0 if gridOffset <= -999.0: bot.targetAngle = absBearing echo "RES tick=" & $bot.tick & " hitEMA=" & formatFloat(bot.hitRateEMA * 100.0, ffDecimal, 0) & "% cold-start aim=" & formatFloat(absBearing, ffDecimal, 1) & " fired=" & $bot.bulletsFired & " hit=" & $bot.bulletsHit & " rate=" & formatFloat(rate, ffDecimal, 1) & "% energy=" & formatFloat(getEnergy(), ffDecimal, 0) & " cells=" & $bot.res.totalCount diagLog("DECIDE tick=" & $bot.tick & " offset=cold-start cells=" & $bot.res.totalCount) else: # Grid learned at BULLET_SPEED — use offset directly (no scaling needed) bot.targetAngle = (absBearing + gridOffset + 360.0) mod 360.0 echo "RES tick=" & $bot.tick & " hitEMA=" & formatFloat(bot.hitRateEMA * 100.0, ffDecimal, 0) & "% offset=" & formatFloat(gridOffset, ffDecimal, 1) & " aim=" & formatFloat(bot.targetAngle, ffDecimal, 1) & " fired=" & $bot.bulletsFired & " hit=" & $bot.bulletsHit & " rate=" & formatFloat(rate, ffDecimal, 1) & "% energy=" & formatFloat(getEnergy(), ffDecimal, 0) & " cells=" & $bot.res.totalCount diagLog("DECIDE tick=" & $bot.tick & " offset=" & formatFloat(gridOffset, ffDecimal, 2) & "° cells=" & $bot.res.totalCount) else: let inputs = encodeInputFull(absBearing, bot.velDirDeg, bot.velSpeed, bot.hasLastPos) # Multi-tick inference: accumulate sin/cos and spike counts over N_INFER ticks var totalSin = 0.0; var totalCos = 0.0 var tickSpikes: array[N_HID, float] var tickVSnap: array[N_HID, float] for h in 0 ..< N_HID: bot.lastSpikes[h] = 0.0 for _ in 0 ..< N_INFER: var sinT, cosT: float bot.snn.forward(inputs, tickSpikes, tickVSnap, sinT, cosT) totalSin += sinT; totalCos += cosT for h in 0 ..< N_HID: bot.lastSpikes[h] += tickSpikes[h] # accumulate counts # Store last-tick voltages for learning bot.lastVSnap = tickVSnap bot.snn.lastSinOut = totalSin; bot.snn.lastCosOut = totalCos bot.snn.lastSnnAngle = arctan2(totalSin, totalCos) * 180.0 / PI bot.targetAngle = gunDir + bot.snn.lastSnnAngle bot.lastDecideGunDir = gunDir # Log total spike count across inference window var spikeCount = 0 var maxV = 0.0 for h in 0 ..< N_HID: spikeCount += int(bot.lastSpikes[h]) maxV = max(maxV, bot.snn.vHid[h]) echo "tick=" & $bot.tick & " infer_spk=" & $spikeCount & "/" & $(N_INFER * N_HID) & " maxV=" & formatFloat(maxV, ffDecimal, 3) bot.phase = WAITING of WAITING: aimTo(bot.targetAngle, gunDir) let err = abs(normalizeRelativeAngle(bot.targetAngle - gunDir)) # Fire gate: gun aimed, enough energy, not in cold-start let energy = getEnergy() let leadOffset = normalizeRelativeAngle(bot.targetAngle - bot.lastAbsBearing) appendLog(aimDebugLog, fmt"TICK t={bot.roundTick} err={err:.2f} offset={leadOffset:.2f} vPerp={bot.lastVPerp:.2f} dist={bot.enemyDist:.1f}") let readyToFire = err < 2.0 and energy >= ENERGY_GUARD and bot.roundTick >= PATIENCE_TICKS if readyToFire: discard setFire(FIRE_POWER) bot.phase = EVALUATE else: discard setFire(0.0) of EVALUATE: # Predictive error signal: extrapolate enemy position at bullet impact time. if bot.hasLastPos: # correctOffset: correct lead angle at BULLET_SPEED, using DECIDE-time snapshot let travelTime = bot.decideDist / BULLET_SPEED let velRad = degToRad(bot.decideVelDirDeg) let futureX = bot.decideEnemyX + cos(velRad) * bot.decideVelSpeed * travelTime let futureY = bot.decideEnemyY + sin(velRad) * bot.decideVelSpeed * travelTime # correctAngle: absolute world-frame bearing [0,360) to predicted enemy position let correctAngle = directionTo(myX, myY, futureX, futureY) # Adaptive learning dead zone: scale by DECIDE-time enemy velocity let t = (bot.decideVelSpeed / 8.0).clamp(0.0, 1.0) let adaptiveDeadZone = 0.5 + (0.3 - 0.5) * t # lerp(0.5, 0.3, t) when USE_RESERVOIR: # correctOffset: angle offset from bare bearing that a bullet at BULLET_SPEED needs let correctOffset = normalizeRelativeAngle(correctAngle - bot.lastAbsBearing) let producedOffset = bot.res.forward(bot.lastVPerp, bot.decideDist) # Bias diagnostics: log grid offset vs correct offset to detect systematic error let biasVal = producedOffset - correctOffset appendLog(biasDebugLog, fmt"BIAS tick={bot.roundTick} gridOffset={producedOffset:.3f} correctOffset={correctOffset:.3f} bias={biasVal:.3f} vPerp={bot.lastVPerp:.3f} dist={bot.decideDist:.1f}") # Learn if: cold start OR error > dead zone if producedOffset > -999.0: let aimErr = abs(normalizeRelativeAngle(producedOffset - correctOffset)) if aimErr > adaptiveDeadZone: bot.res.learn(bot.lastVPerp, bot.decideDist, correctOffset) else: bot.res.learn(bot.lastVPerp, bot.decideDist, correctOffset) let aimErr = if producedOffset > -999.0: abs(normalizeRelativeAngle(producedOffset - correctOffset)) else: -1.0 let rate = if bot.bulletsFired > 0: (float(bot.bulletsHit) / float(bot.bulletsFired) * 100.0) else: 0.0 echo "RES tick=" & $bot.tick & " hitEMA=" & formatFloat(bot.hitRateEMA * 100.0, ffDecimal, 0) & "%" & " fired=" & $bot.bulletsFired & " hit=" & $bot.bulletsHit & " rate=" & (if bot.bulletsFired > 0: formatFloat(rate, ffDecimal, 1) else: "0.0") & "%" & " energy=" & formatFloat(getEnergy(), ffDecimal, 0) & " offset=" & (if producedOffset > -999.0: formatFloat(producedOffset, ffDecimal, 1) else: "cold") & " correctOffset=" & formatFloat(correctOffset, ffDecimal, 1) & " err=" & (if producedOffset > -999.0: formatFloat(aimErr, ffDecimal, 1) else: "n/a") & " vPerp=" & formatFloat(bot.lastVPerp, ffDecimal, 2) & " cells=" & $bot.res.totalCount else: let targetRel = normalizeRelativeAngle(correctAngle - bot.lastDecideGunDir) bot.snn.superSpikeUpdate(bot.lastSpikes, bot.lastVSnap, bot.snn.preTrace, targetRel) let err = abs(normalizeRelativeAngle(gunDir - bot.enemyBearing)) # Compute verbose logging metrics var spikeCount = 0 var maxV = 0.0 for h in 0 ..< N_HID: spikeCount += int(bot.lastSpikes[h]) maxV = max(maxV, bot.snn.vHid[h]) var meanWih = 0.0 for w in bot.snn.wih: meanWih += abs(w) meanWih /= float(N_IN * N_HID) var meanWout = 0.0 for w in bot.snn.wSin: meanWout += abs(w) for w in bot.snn.wCos: meanWout += abs(w) meanWout /= float(2 * N_HID) let rate = if bot.bulletsFired > 0: (float(bot.bulletsHit) / float(bot.bulletsFired) * 100.0) else: 0.0 echo "tick=" & $bot.tick & " hitEMA=" & formatFloat(bot.hitRateEMA * 100.0, ffDecimal, 0) & "% fired=" & $bot.bulletsFired & " hit=" & $bot.bulletsHit & " rate=" & formatFloat(rate, ffDecimal, 1) & "% energy=" & formatFloat(getEnergy(), ffDecimal, 0) & " err=" & formatFloat(err, ffDecimal, 1) & "° infer_spk=" & $spikeCount & "/" & $(N_INFER * N_HID) & " maxV=" & formatFloat(maxV, ffDecimal, 3) & " |wih|=" & formatFloat(meanWih, ffDecimal, 4) & " |wOut|=" & formatFloat(meanWout, ffDecimal, 4) & " sin=" & formatFloat(bot.snn.lastSinOut, ffDecimal, 3) & " cos=" & formatFloat(bot.snn.lastCosOut, ffDecimal, 3) & " snnAngle=" & formatFloat(bot.snn.lastSnnAngle, ffDecimal, 1) bot.phase = DECIDE # Radar lock setRadarTurnRate(radar_lock.doRadar(getRadarDirection(), bot.enemyBearing)) # Debug overlay drawOverlay(bot, myX, myY, gunDir, bot.enemyBearing, bot.targetAngle) go() # ── Entry point ─────────────────────────────────────────────────────────────── when isMainModule: var bot = SNNBot(phase: DECIDE) start(bot, botJsonPath)