---
title: Arena Fighter — tester observations and feedback (2026-10-05)
summary: Public tester notes on Arena Fighter (arena.bagofassets.com), the SAINTCON bot game by Tristan Rhodes. What we built and played, ladder-wide numbers from 416 archived matches, what works well, and feedback (a centre-pillar stalemate, docs links that 404 on the hosted ladder, no local test path for outside players, small inconsistencies).
updated: 2026-10-05
canonical: https://unaen.org/llmwiki/topics/saintcon/arena-fighter-feedback
markdown: https://unaen.org/llmwiki/topics/saintcon/arena-fighter-feedback.md
---

# Arena Fighter — tester observations and feedback

Notes from testing **Arena Fighter** (https://arena.bagofassets.com/), the bot-vs-bot game **Tristan Rhodes**
is building for SAINTCON. We are copec (with an AI assistant). We play on the ladder as **`copec-gk`**.
Everything below was measured on engine `0.21.0`, rules `0.21.1`, protocol `0.3` (ladder commit `1b3f6bf`),
on 2026-10-05. Feedback is meant to help, and the short version is: **this is a fun, well-documented game.**

## The game in one paragraph

Two robots fight on a 21×21 grid. Your bot is a program you run. It connects over a WebSocket and gets
1 second each tick to answer "move where, attack what?", and both bots act at the same moment. There are
three weapons:
- a **shot** that can bounce off one wall;
- a **frostburst** patch that slows, then explodes 6 ticks later;
- a point-blank **repulsor** shove that stuns you if it knocks you into a wall.

There are health and power pickups, fog of war, and a shrinking safe zone. The last robot standing wins.
Every game is rated, separately for each version of your bot.

## What we did

- **Read only what the hosted ladder publishes.** We didn't use the game's repo or tooling. We archived the
  public docs, schemas, viewer, API and every replay and match-stats record as they appeared.
- **Wrote our own client from those docs**: standard-library Python, run under PyPy.
- **Played 21 rated matches over three bot versions.** Results: 14 wins, 4 losses, 3 draws. The best
  version reached a rating of about 1800, eighth on the leaderboard at the time. The leaders were about
  2000–2150.
- **Computed ladder-wide numbers** from the 416 match-stats records we had archived (all bots, all maps).

## What works really well

- **The docs are enough to build a bot from scratch.** In 21 matches our hand-written client had **0 rejected
  turn parts, 0 timeouts and 0 schema errors**. The agent guide's checklist and the "pitfalls the house bots
  hit" section are excellent.
- **`observation.moves`, `observation.dashes` and `observation.danger` are a great design choice.** The
  engine tells you which steps are legal and exactly which tiles get hit on which tick, so newcomers
  don't have to get corner-cutting or projectile geometry right before they can play.
- **Specific rejection reasons** in the next observation made the (few) mistakes easy to spot.
- **Deterministic, self-contained replays** with a per-tick state hash, plus `end.stats` and
  `/api/matches/<id>/stats` (damage by source, hits per weapon). After one match we could see exactly why
  we lost hp.
- **Versions rated separately** means experimenting is cheap: a broken version only hurts itself.
- **Match pace.** Lockstep with a 1 s ceiling means fast bots play fast. The median game ended at tick 165.

## Feedback

### 1. Centre-pillar stalemate (design)

On **`pillars-21`** and **`columns-21`** the zone centre, tile (10,10), is a **wall**. Even the final zone
(radius 1, the 3×3 around the centre) contains floor tiles on opposite sides of that wall with no line of
sight between them. Two bots can park on either side from early in the match until tick 600. They never
see each other and never take zone damage, and the game ends in a draw at full hp.

- **How we know:** all 3 of our draws (vs `house-ts-starter`, two on pillars-21, one on columns-21) were
  exactly this. From our own observations, we sat at (10,11) from about tick 100 to 599, the enemy was
  never visible, the score was 130–130 hp with 0 damage either way, and each side took about 17 steps.
  Ladder-wide, `timeout_draw` is rare (3 of 416 archived matches), and those 3 are very likely ours. So
  it's uncommon today, but it's a free draw for any bot that finds it.
- **Ideas:**
  - keep the zone centre clear of walls, or end the zone on a 3×3 with clear sight;
  - or reveal both fighters to each other inside the final zone;
  - or score a no-damage timeout as a loss for both.

  (A bot *can* break it with a blind bounce shot, since shots don't need visibility, but the game
  probably shouldn't rely on that.)

### 2. Hosted docs link to files the ladder doesn't serve

On the hosted ladder, `/docs/agents`, `/docs/spec` and `/docs/bot-guide` link to repository paths that
return 404:
- `SPEC.md`, `BOT_GUIDE.md`, `FRAME_DATA.md`, `API.md`, `AGENT_GUIDE.md`, `HUMAN_PLAY.md`,
  `RANGED_ATTACKS.md`, `../CHANGELOG.md`, `../rules/rules.json`;
- the house bots' `bots/{lib,brawler,kiter,ts-starter}/bot.ts`.

Most have a hosted twin (`/docs/spec`, `/docs/bot-guide`, …). But **`RANGED_ATTACKS.md` and `HUMAN_PLAY.md`
have none**, so a remote player can't read the weapons page the agent guide points them to. Neither can they
read the house-bot code that "Steal from the house bots" tells them to copy. Rewriting links to the hosted
paths, and serving those two pages plus the house bots, would close it.

### 3. Outside players have no local test path

The agent guide's whole local loop needs the game repository:
- `npm run arena -- new-bot`, `check-protocol`, `check-bot`;
- `run`, `replay --from/--swap`.

A SAINTCON player who only has the ladder URL can test **only on the rated ladder**, against whoever the
matchmaker picks. Options, roughly in order of effort:
- say so in the docs (and suggest a throwaway version label for first tests);
- add an **unrated practice queue** against house bots (e.g. `{"type": "queue", "mode": "practice"}`);
- publish the CLI/engine.

### 4. Small inconsistencies

- `hello.map` is the array of map rows (with `hello.map_name` as the name), but in replay headers `map` is
  the map **name** and the rows are under `map_data.tiles`.
- The `/api` endpoint list puts three routes in one string (`.../suspend|activate|ban`). That's fine for
  people, but tools that read the list as paths trip over it.
- New versions swing a lot: we saw −259 and +137 rating changes from single games on a fresh version. That's
  expected for Glicko-2 starting at high uncertainty, but one sentence in the player docs would save
  newcomers a scare.

### 5. The engine ships in the replay viewer (added later on 2026-10-05)

The viewer's JavaScript bundle contains the full engine: match setup, the tick step and the per-tick state
hash. The viewer needs it to replay games from the turn-only replay files.

- **How we know:** run headless, it reproduces every replay we have archived (1,300+), every tick's hash.
- **Effect:** outside players can already replay and simulate games locally. That partly answers finding 3,
  but it's undocumented.
- **Ask:** if that's intended, document it. If it isn't, it's worth knowing before the event.
- **Still server-side only:** the observation (fog, `danger`, `moves`/`dashes`). A player wanting realistic
  local tests has to rebuild those from the docs.

### 6. Same pairing, same game

The engine is deterministic and the seed only picks spawn sides. So on the open map (`arena-21`), two
deterministic bots replay the identical game every time they meet.

- **How we know:** we saw identical tick counts, damage and shot counts in separate matches with different seeds.
- **Effect:** against a deterministic opponent, a line of play that wins once wins every time. Bots that
  remember past games can farm it.
- **Possible fixes:** seed-driven variation such as mirrored maps or randomised spawn offsets, or note it as
  intended.

### 7. Engine edge cases (found by running the engine over every replay plus randomised games)

None of these show up in normal ladder play today. They're listed so they can be decided on deliberately.

- **Zero-length shot.** The `bad_target` check runs before repulsors resolve. A fighter shoved onto its own
  shot's target tile in the same tick spawns a projectile with origin = target: 0/0 gives NaN. That shot never
  hits anything and never expires in practice (its range is 10⁹ milli-tiles). Its position hashes as `null`.
- **Prototype attack names.** `rules.attacks[name]` is a plain-object lookup, so an attack named `toString`,
  `constructor` or `valueOf` passes validation. That gives a NaN cooldown and an immortal NaN projectile. A
  `hasOwnProperty` check would close it.
- **Power boosts knockback.** The power buff multiplies wall-slam and collision damage too: every
  attacker-credited damage goes through one function. The spec says "weapon damage ×1.5".
- **Simultaneous repulsors.** Both hits are decided before any knockback. The second shove is then aimed from
  the first-shoved fighter's new position, up to 3 tiles away.
- **Corners.** Shots and line of sight pass through the exact corner between two diagonal walls, a corner a
  fighter isn't allowed to step through. A shot that meets a wall only diagonally ends there even with a bounce
  left, because bounces need an orthogonal wall.
- **Collision code.** In 1v1 the shove always pushes away from the only other fighter, so collisions can't
  happen.
  - The `collision` event reports nominal rather than actual damage.
  - Collision damage ignores invulnerability.
  - Match stats count it for both fighters, including the attacker, who takes 0.
- **Null turn.** In a replay file, a `null` turn value crashes the replay loop's reason-stripping step. String,
  array and number turns just become idle.

## Observations about the game (ladder-wide, 416 matches)

These come from the public `/api/matches/<id>/stats` records across all bots, maps and versions.

| | |
|---|---|
| maps | arena-21 135, columns-21 133, pillars-21 148 |
| how games end | last standing 410, timeout draw 3, timeout on hp 2, both dead 1 |
| match length | median **165 ticks**, mean 221, 90th percentile 447 (of 600) |
| share of all damage | shot **62%** (5% of all damage came after a bounce), wall slam **12%**, zone 9%, frostburst 9%, repulsor 8% |
| hit rate (hits / uses) | shot **17%**, frostburst **7%**, repulsor **76%** |
| wall slams | about **2 per match** |

What stands out:
- **The repulsor is the most efficient weapon on the ladder.** Including its wall slams, it does about 20%
  of all damage from only ~1,600 uses (vs ~23,000 shots). It connects 76% of the time. Bots mostly fire it
  only when adjacent, so the rate flatters it a little.
  - In our 4 losses, repulsor + wall slam was the largest damage source twice and present in a third.
    Shots decided the fourth.
  - A shove can follow a 3-tile dash in the same turn, so its real threat range is about 4 tiles. That's
    easy to underestimate from the docs' "range 1".
- **Frostburst rarely connects** (7%). It works as area denial and a slow, not as damage. That may be
  intended, but it's the weakest damage source per use.
- **The zone does matter** (9% of damage) even though most games end before it gets small.
- **Games are short.** Half end by tick 165, so the opening and the first close-range exchange decide a
  lot.

## Our bot, briefly

A tile-scoring policy. Each tick it rates every tile it could end on:
- certain incoming hits, from `danger`;
- the no-warning close-range shot threat;
- every tile the enemy could step or dash to and shove from;
- zone, range to the enemy with line of sight, pickups and open space.

It then picks an attack: a slam if available, shots when ready, frostburst when the enemy can't dash out.
It decides in well under a millisecond. Against the top bots it still gets slammed into walls, and it
loses shot exchanges.

Thanks to Tristan for the game. We're happy to share replays, numbers or our archive.

