// Shared code for the sample opponent bots (random, brawler, kiter): their policies (policy.ts)
// use these helpers, and run.ts connects a policy to the ladder.
// Bot authors should start from bots/ts-starter, which is self-contained.
//
// Two rules these helpers follow, and your bot should too:
// - For this tick, choose from what the observation offers: `moves` and `dashes` hold only what the
// engine would accept now, and `danger` says which tiles get hit when. The map is for planning
// further ahead (paths, room to manoeuvre), where the observation can't help.
// - Read every number from `hello.rules`; the ladder retunes them between releases.
export type Dir = "N" | "NE" | "E" | "SE" | "S" | "SW" | "W" | "NW";
export type Pos = [number, number];
export const DIRS: Dir[] = ["N", "NE", "E", "SE", "S", "SW", "W", "NW"];
export const VEC: Record
= { N: [0, -1], NE: [1, -1], E: [1, 0], SE: [1, 1], S: [0, 1], SW: [-1, 1], W: [-1, 0], NW: [-1, -1] };
/** The parts of `hello.rules` these bots read. Every field: /docs/rules. */
export interface Rules {
stats: { max_hp: number; move_cooldown: number };
attacks: {
ranged: { speed: number };
frostburst: { range: number; slow?: number };
repulsor: { push: number };
};
knockback: { wall_slam_stun: number };
pickups?: { health?: { amount: number } };
}
/** The parts of `hello` these bots read. */
export interface Hello {
match_id: string;
bot_id: number;
seed_public: number;
/** rows of `.` (floor) and `#` (wall) */
map: string[];
rules: Rules;
}
export interface Enemy {
id: number;
pos: Pos;
hp: number;
/** ticks until it can dash again; 0 = ready */
dash_cooldown: number;
state: string;
state_ticks_left: number;
slow_ticks_left: number;
}
/** The parts of an observation these bots read. Every field: /docs/schema/observation.json. */
export interface Observation {
tick: number;
self: { id: number; pos: Pos; hp: number; max_hp: number; state: string; cooldowns: Record };
/** visible opponents only */
enemies: Enemy[];
/** steps the engine would accept now, with the tile each reaches */
moves: { dir: Dir; tile: Pos }[];
/** dashes the engine would accept now, with the landing tile */
dashes: { dir: Dir; tile: Pos; distance: number }[];
/** every tile each visible opponent shot or area will hit, and on which tick */
danger: { tile: Pos; tick: number }[];
pickups: { pos: Pos; kind: string; ready_in: number }[];
areas: { owner: number; ticks_left: number; tiles: Pos[] }[];
zone: { center: Pos; radius: number; next_radius: number | null; shrinks_in: number | null };
events: { kind: string; [field: string]: unknown }[];
}
/** The attack half of a turn. The repulsor has no aim, so it needs no target. */
export interface Attack {
name: string;
target?: Pos;
}
/** What a bot does in one tick: a movement (a step or a dash, or neither), then an optional attack. */
export interface Turn {
move?: Dir;
dash?: Dir;
attack?: Attack;
/** said to everyone, at most hello.rules.shout.max_length characters */
shout?: string;
}
export interface Ctx {
hello: Hello;
map: string[];
/** what a policy notes across the ticks of one match: the enemy's last sighting and step, a temperament */
memory: Record;
/** seeded per match (from hello.seed_public and this bot's id), so games vary but stay reproducible */
rand: () => number;
}
/** mulberry32: tiny seeded PRNG returning floats in [0, 1). */
export function seededRandom(seed: number): () => number {
let a = seed >>> 0;
return () => {
a = (a + 0x6d2b79f5) >>> 0;
let t = a;
t = Math.imul(t ^ (t >>> 15), t | 1);
t ^= t + Math.imul(t ^ (t >>> 7), t | 61);
return ((t ^ (t >>> 14)) >>> 0) / 4294967296;
};
}
export const cheb = (a: Pos, b: Pos) => Math.max(Math.abs(a[0] - b[0]), Math.abs(a[1] - b[1]));
export const add = (a: Pos, d: Dir, k = 1): Pos => [a[0] + VEC[d][0] * k, a[1] + VEC[d][1] * k];
const same = (a: Pos, b: Pos) => a[0] === b[0] && a[1] === b[1];
/** The octant the vector from a to b falls in (same rule the engine uses). */
export function dirTo(a: Pos, b: Pos): Dir | null {
const dx = b[0] - a[0];
const dy = b[1] - a[1];
if (!dx && !dy) return null;
const t = 0.41421356;
if (Math.abs(dy) < Math.abs(dx) * t) return dx > 0 ? "E" : "W";
if (Math.abs(dx) < Math.abs(dy) * t) return dy > 0 ? "S" : "N";
if (dx > 0) return dy > 0 ? "SE" : "NE";
return dy > 0 ? "SW" : "NW";
}
export const tile = (map: string[], p: Pos) => (p[1] < 0 || p[1] >= map.length || p[0] < 0 || p[0] >= map[0]!.length ? "#" : map[p[1]]![p[0]]!);
/**
* For planning paths: could a fighter at `from` step in `d`, walls alone considered? Not into a
* wall, and not diagonally past a wall corner. For this tick's step, use `observation.moves`.
*/
export function canStep(map: string[], from: Pos, d: Dir): boolean {
if (tile(map, add(from, d)) === "#") return false;
const [dx, dy] = VEC[d];
if (dx && dy && (tile(map, [from[0] + dx, from[1]]) === "#" || tile(map, [from[0], from[1] + dy]) === "#")) return false;
return true;
}
// The searches below run several times a tick, so they work on tile numbers (y * width + x) with
// typed arrays rather than positions and strings, and look up steps in a table made once per map.
const stepTables = new WeakMap();
/** `canStep` for every tile and direction of a map (by its rows), worked out on first use: index tile * 8 + direction. */
function stepTable(map: string[]): Uint8Array {
let table = stepTables.get(map);
if (!table) {
const w = map[0]!.length;
table = new Uint8Array(map.length * w * 8);
for (let y = 0; y < map.length; y++)
for (let x = 0; x < w; x++) for (let k = 0; k < 8; k++) table[(y * w + x) * 8 + k] = canStep(map, [x, y], DIRS[k]!) ? 1 : 0;
stepTables.set(map, table);
}
return table;
}
/** The tiles in `blocked` that are on the map, as tile numbers. */
function blockedSet(map: string[], blocked: Pos[]): Set {
const w = map[0]!.length;
return new Set(blocked.filter((p) => p[0] >= 0 && p[1] >= 0 && p[0] < w && p[1] < map.length).map((p) => p[1] * w + p[0]));
}
/** First step of a BFS path from `from` to any tile within `range` of `goal`, avoiding walls and `blocked`. */
export function stepToward(map: string[], from: Pos, goal: Pos, range = 0, blocked: Pos[] = []): Dir | null {
const w = map[0]!.length;
const steps = stepTable(map);
const block = blockedSet(map, blocked);
// the direction of the first step on the way to each tile reached: -1 not reached, 8 the start
const first = new Int8Array(map.length * w).fill(-1);
const queue = new Int32Array(map.length * w); // each tile is queued at most once
let tail = 0;
queue[tail++] = from[1] * w + from[0];
first[queue[0]!] = 8;
const dist = new Int32Array(8);
const order = [0, 1, 2, 3, 4, 5, 6, 7];
for (let head = 0; head < tail; head++) {
const i = queue[head]!;
const x = i % w;
const y = (i - x) / w;
const via = first[i]!;
if (Math.max(Math.abs(x - goal[0]), Math.abs(y - goal[1])) <= range) return via === 8 ? null : DIRS[via]!;
// Prefer straight-line directions first so paths look natural: nearest the goal first, ties in DIRS order.
for (let k = 0; k < 8; k++) {
const [dx, dy] = VEC[DIRS[k]!];
dist[k] = Math.max(Math.abs(x + dx - goal[0]), Math.abs(y + dy - goal[1]));
order[k] = k;
}
order.sort((a, b) => dist[a]! - dist[b]! || a - b);
for (const k of order) {
if (!steps[i * 8 + k]) continue;
const [dx, dy] = VEC[DIRS[k]!];
const n = i + dy * w + dx;
if (first[n] !== -1 || block.has(n)) continue;
first[n] = via === 8 ? k : via;
queue[tail++] = n;
}
}
return null;
}
/** BFS steps from `from` to `goal` (walls avoided), or Infinity if unreachable. */
export function pathLength(map: string[], from: Pos, goal: Pos, blocked: Pos[] = []): number {
const w = map[0]!.length;
const steps = stepTable(map);
const block = blockedSet(map, blocked);
const count = new Int16Array(map.length * w).fill(-1);
const queue = new Int32Array(map.length * w);
let tail = 0;
queue[tail++] = from[1] * w + from[0];
count[queue[0]!] = 0;
const target = goal[1] * w + goal[0];
const onMap = goal[0] >= 0 && goal[1] >= 0 && goal[0] < w && goal[1] < map.length;
for (let head = 0; head < tail; head++) {
const i = queue[head]!;
if (onMap && i === target) return count[i]!;
for (let k = 0; k < 8; k++) {
if (!steps[i * 8 + k]) continue;
const [dx, dy] = VEC[DIRS[k]!];
const n = i + dy * w + dx;
if (count[n] !== -1 || block.has(n)) continue;
count[n] = count[i]! + 1;
queue[tail++] = n;
}
}
return Infinity;
}
/** Is `p` inside the zone, `margin` tiles in? Once a shrink is announced, inside the next zone. */
export function inSafeZone(obs: Observation, p: Pos, margin = 0): boolean {
const radius = obs.zone.shrinks_in === null ? obs.zone.radius : obs.zone.next_radius!;
return cheb(p, obs.zone.center) <= radius - margin;
}
/** Is tile `p` hit by an opponent's shot or area between `first` and `last` ticks from now (0 = the tick we reply to)? */
export function hitIn(obs: Observation, p: Pos, first: number, last: number): boolean {
return obs.danger.some((d) => same(d.tile, p) && d.tick - obs.tick >= first && d.tick - obs.tick <= last);
}
/**
* Leave a tile that a shot or area will hit within 2 ticks: a step to a tile that stays safe, or
* a dash (invulnerable on its own tick) to a landing that is safe after it. Shots fired this tick
* from 2 tiles or closer land at once and never show in `danger`: up close, keep moving.
*/
export function evadeDanger(obs: Observation): Turn | null {
if (obs.self.state !== "idle" || !hitIn(obs, obs.self.pos, 0, 2)) return null;
for (const m of obs.moves) if (!hitIn(obs, m.tile, 0, 2) && inSafeZone(obs, m.tile)) return { move: m.dir };
for (const d of obs.dashes) if (!hitIn(obs, d.tile, 1, 2) && inSafeZone(obs, d.tile)) return { dash: d.dir };
return null;
}
/**
* First step toward the best stocked pickup within `maxSteps`, or null. Skips pickups the enemy
* is closer to, ones outside the (shrinking) zone, and health that would mostly be wasted.
*/
export function pickupStep(obs: Observation, ctx: Ctx, maxSteps: number): Dir | null {
const me = obs.self;
const enemy = obs.enemies[0];
const heal = ctx.hello.rules.pickups?.health?.amount ?? 0;
let best: { p: Observation["pickups"][number]; steps: number } | null = null;
for (const p of obs.pickups) {
if (p.ready_in > 0 || !inSafeZone(obs, p.pos)) continue;
if (p.kind === "health" && me.hp > me.max_hp - heal * 0.6) continue;
const steps = pathLength(ctx.map, me.pos, p.pos, enemy ? [enemy.pos] : []);
if (steps > maxSteps || (enemy && cheb(enemy.pos, p.pos) < steps)) continue;
if (!best || steps < best.steps) best = { p, steps };
}
return best ? stepToward(ctx.map, me.pos, best.p.pos, 0, enemy ? [enemy.pos] : []) : null;
}
/** How much room there is around a tile: open tiles within 2 (0–24). */
export function openness(map: string[], p: Pos): number {
let n = 0;
for (let dx = -2; dx <= 2; dx++)
for (let dy = -2; dy <= 2; dy++) {
if (!dx && !dy) continue;
if (tile(map, [p[0] + dx, p[1] + dy]) === ".") n++;
}
return n;
}
/** How good a tile is to back off to: far from the threat, roomy, and not far from the zone's centre. */
function retreatScore(obs: Observation, ctx: Ctx, to: Pos, threat: Pos): number {
const centre = cheb(to, obs.zone.center) * (inSafeZone(obs, to, 2) ? 0.3 : 1);
return 3 * cheb(to, threat) + 0.6 * openness(ctx.map, to) - 0.8 * centre + ctx.rand() * 0.5;
}
/**
* Best step away from `threat`, from `observation.moves`: far from it, toward open space and the
* zone centre, so a retreating bot circles around instead of backing into a corner.
*/
export function retreatStep(obs: Observation, ctx: Ctx, threat: Pos): Dir | null {
let best: { dir: Dir; score: number } | null = null;
for (const m of obs.moves) {
if (hitIn(obs, m.tile, 0, 2)) continue;
const score = retreatScore(obs, ctx, m.tile, threat);
if (!best || score > best.score) best = { dir: m.dir, score };
}
return best?.dir ?? null;
}
/** The best escape dash from `observation.dashes`: one that really gets away, lands safe and stays in the zone. */
export function escapeDash(obs: Observation, ctx: Ctx, threat: Pos): Dir | null {
let best: { dir: Dir; score: number } | null = null;
for (const d of obs.dashes) {
if (d.distance < 2 || cheb(d.tile, threat) < 2 || !inSafeZone(obs, d.tile) || hitIn(obs, d.tile, 1, 2)) continue;
const score = retreatScore(obs, ctx, d.tile, threat);
if (!best || score > best.score) best = { dir: d.dir, score };
}
return best?.dir ?? null;
}
/** Ticks one patrol leg lasts. */
const PATROL_TICKS = 30;
/**
* Where to go while the enemy is out of sight: where it was last seen, then around the zone,
* one corner of a square half its radius out every PATROL_TICKS. Never park and wait: two bots
* that do can spend a whole match on opposite sides of a pillar.
*/
export function searchGoal(obs: Observation, ctx: Ctx): Pos {
const last: Pos | undefined = ctx.memory.lastSeen;
if (last && cheb(obs.self.pos, last) > 1) return last;
ctx.memory.lastSeen = undefined;
const r = Math.max(1, Math.floor(obs.zone.radius / 2));
const [cx, cy] = obs.zone.center;
const corners: Pos[] = [[cx - r, cy - r], [cx + r, cy - r], [cx + r, cy + r], [cx - r, cy + r]];
return corners[Math.floor(obs.tick / PATROL_TICKS) % corners.length]!;
}
/** A bot's decisions: its version label and its turn for each observation. */
export interface Policy {
version: string;
decide: (obs: Observation, ctx: Ctx) => Turn;
}
/** The per-match context a policy keeps, from the match's `hello` (the parsed message, as it arrived). */
export function newCtx(message: unknown): Ctx {
const hello = message as Hello;
return { hello, map: hello.map, memory: {}, rand: seededRandom(hello.seed_public * 7919 + hello.bot_id) };
}
/**
* The frame that answers an observation (the parsed message, as it arrived). A crash in `decide`
* is logged and the tick is idle, so one bug never costs the match.
*/
export function reply(policy: Policy, message: unknown, ctx: Ctx): string {
const obs = message as Observation;
let turn: Turn;
try {
turn = policy.decide(obs, ctx);
} catch (e) {
console.error(e);
turn = {};
}
return JSON.stringify({ tick: obs.tick, ...turn });
}
/** Where the fighter ends its movement this tick under `turn` (its own tile if it neither steps nor dashes). */
export function endTile(obs: Observation, turn: Turn): Pos {
const found = turn.move ? obs.moves.find((m) => m.dir === turn.move) : turn.dash ? obs.dashes.find((d) => d.dir === turn.dash) : null;
return found ? found.tile : obs.self.pos;
}
/**
* Note where the enemy is and when it last stepped, for `searchGoal`, `enemyStepIn` and leading
* shots. Call once per observation, first thing. A step from inside a frostburst area costs the
* area's slow on top of the move cooldown.
*/
export function watchEnemy(obs: Observation, ctx: Ctx): void {
const enemy = obs.enemies[0];
const prev = ctx.memory.enemyPrev;
if (enemy && prev && prev.tick === obs.tick - 1 && cheb(prev.pos, enemy.pos) === 1) {
const slow = prev.slowed ? (ctx.hello.rules.attacks.frostburst.slow ?? 0) : 0;
ctx.memory.enemyMoveReady = obs.tick - 1 + ctx.hello.rules.stats.move_cooldown + slow;
ctx.memory.enemyStep = [enemy.pos[0] - prev.pos[0], enemy.pos[1] - prev.pos[1]];
} else if (enemy && prev && prev.tick === obs.tick - 1) ctx.memory.enemyStep = null;
ctx.memory.enemyPrev = enemy ? { tick: obs.tick, pos: enemy.pos, slowed: enemy.slow_ticks_left > 0 } : null;
if (enemy) ctx.memory.lastSeen = enemy.pos;
}
/** Ticks from now until the enemy can step again (0 = it can step this tick), as far as we saw it step. */
export function enemyStepIn(obs: Observation, ctx: Ctx): number {
return Math.max(0, (ctx.memory.enemyMoveReady ?? 0) - obs.tick);
}
/**
* How many ticks, starting with this one, the enemy can't leave its tile: it is stunned or
* recovering from a dash, or it just stepped and its dash is on cooldown too.
*/
export function pinnedFor(obs: Observation, ctx: Ctx, enemy: Enemy): number {
if (enemy.state === "stunned" || enemy.state === "recovering") return enemy.state_ticks_left;
return Math.min(enemyStepIn(obs, ctx), enemy.dash_cooldown);
}
/** Ticks after firing that a standard shot from `from` reaches `to` (0 = this tick). */
export function shotArrival(rules: Rules, from: Pos, to: Pos): number {
return Math.max(0, Math.ceil(cheb(from, to) / rules.attacks.ranged.speed) - 1);
}
/** Where a repulsor shove from `from` leaves a target at `target`, and whether a wall stops it (a slam). */
export function shove(map: string[], from: Pos, target: Pos, push: number): { tile: Pos; slam: boolean } {
const dir = dirTo(from, target)!;
let p = target;
for (let k = 0; k < push; k++) {
if (!canStep(map, p, dir)) return { tile: p, slam: true };
p = add(p, dir);
}
return { tile: p, slam: false };
}
/**
* The attack phase, fired from `from` (where the movement ends). Combos first: a shot the pinned
* enemy can't dodge, then a shove into a wall (a slam stuns, which sets up that shot) or into one of
* our areas that explodes before they can leave. Otherwise a mix of shots, frostburst and shoves.
* Returns null when nothing is ready or no enemy is in sight. `aim` overrides where the standard
* shot is aimed (to lead a moving target); `shoveChance` is the chance of a shove that is neither
* a slam nor a trap (a kiter shoves to make room).
*/
export function chooseAttack(obs: Observation, ctx: Ctx, from: Pos, aim?: Pos, shoveChance = 0.4): Attack | null {
const enemy = obs.enemies[0];
if (!enemy) return null;
const rules = ctx.hello.rules;
// Each weapon has its own cooldown.
const ready = (name: string) => obs.self.cooldowns[name] === 0;
const d = cheb(from, enemy.pos);
if (d === 0) return null;
// A sure hit: the shot lands before the enemy can step or dash off its tile.
if (ready("ranged") && shotArrival(rules, from, enemy.pos) < pinnedFor(obs, ctx, enemy)) return { name: "ranged", target: enemy.pos };
if (d === 1 && ready("repulsor")) {
const landing = shove(ctx.map, from, enemy.pos, rules.attacks.repulsor.push);
// A slammed target stays put for the stun; otherwise only an area exploding this tick is sure.
const holds = landing.slam ? rules.knockback.wall_slam_stun + 1 : 1;
const trap = obs.areas.some((a) => a.owner === obs.self.id && a.ticks_left <= holds && a.tiles.some((t) => same(t, landing.tile)));
if (landing.slam || trap || ctx.rand() < shoveChance) return { name: "repulsor" };
}
// Frostburst is cast on the target's tile and needs it in sight from where the movement ends. We
// only know sight from where we stand, so cast it standing still. Best on a target that can't dash out.
const stuck = enemy.state === "recovering" || enemy.dash_cooldown > 0;
const castable = same(from, obs.self.pos) && d > 1 && d <= rules.attacks.frostburst.range;
if (castable && ready("frostburst") && !enemy.slow_ticks_left && ctx.rand() < (stuck ? 0.6 : 0.25)) return { name: "frostburst", target: enemy.pos };
return ready("ranged") ? { name: "ranged", target: aim ?? enemy.pos } : null;
}