Heuristic autonomous bot for slither.io. Drives a real Chromium browser via Playwright; reads game state from the page's JS globals; steers by moving the OS mouse.
# 1. Install dependencies
npm install
# 2. Download the Chromium Playwright uses (first time only)
npx playwright install chromium
# 3. Run
npm startRequires Node.js ≥ 20.
- Opens
https://slither.ioin a visible Chromium window. - Fills in the nickname, dismisses any consent banner, clicks Play.
- Every
tickIntervalMs(~50 ms): reads the player's position/heading, the food array, and all other snakes fromwindow.*globals, then picks a target angle using:- Avoid — any enemy segment inside
avoidanceRadiusPxcreates a blocked angular band; if the desired heading is inside one, slide to the nearest clear angle. - Food — sum
size / √distanceinto 36 angular buckets withinfoodRadiusPx, steer at the best bucket. - Idle sweep — default gentle turn so we never hold perfectly straight into empty space.
- Avoid — any enemy segment inside
- On death (
window.slitherbecomesnull), logs length, clicks Play again, repeats up tomaxRestartstimes. Prints a session summary.
All tunables live in src/config.ts. No magic numbers
in logic files.
| Field | Default | Meaning |
|---|---|---|
nickname |
claude-bot |
Shown on the snake in-game |
url |
https://slither.io |
Game URL |
maxRestarts |
5 |
Lives per session |
tickIntervalMs |
50 |
Decision loop period |
avoidanceRadiusPx |
220 |
Enemy segments inside this radius create blocked angular bands |
foodRadiusPx |
500 |
Food outside this radius is ignored |
idleSweepRadians |
0.03 |
Per-tick drift of the idle heading fallback |
headless |
false |
Set true to run without a visible window |
slowMoMs |
0 |
Playwright slowMo — useful when debugging |
logLevel |
debug |
debug prints every 20 ticks; info is summaries only |
src/
config.ts all tunables
logger.ts level-filtered logger
launcher.ts opens slither.io, enters nickname, clicks Play, handles respawn
state.ts typed reader over window.slither / slithers / foods / view_*
steering.ts canvas-relative mousemove primitive
decision.ts pure decide(state) → target angle (avoid / food / idle)
index.ts main loop: spawn → tick → die → respawn → summary
inspect.ts optional discovery probe (npx tsx src/inspect.ts)
npm start— run the bot end-to-end (default).npm run inspect— launch a quick browser session and dump the shape ofwindow.slither,window.slithers,window.foodsetc. Use this first if a game update breaks state extraction.npm run typecheck—tsc --noEmit.npm run lint— ESLint oversrc/.
This bug silently crippled collision lookahead in v1. The v1 projection did
vx = cos(e.angle) * e.speed and used it with dt = (tickIntervalMs / 1000) * lookahead.
That treats e.speed as units-per-second, but sp is actually units-per-tick at the game's
~30 fps, so the projection was roughly 60× under-weighted (1 tick ≈ 1/30 s not 1 s)
and the ghost-band for head lead-targeting sat ~2 world units ahead of the current position —
barely moved at all. Enemy heads could close on us inside the blind window without any band
widening.
Fix in v2 (src/state.ts — StateTracker.read): we now measure vx/vy from actual
world-position deltas across real tick intervals (dt = (now - prev.t) / 1000), so the units
are correct by construction (world-units per second). decide() uses this measured velocity
with the measured dt. Don't reintroduce cos(angle) * sp as a seconds-based velocity —
it's only meaningful as units/tick for the current frame.
- Minified variable names. The bot reads
window.slither,window.slithers,window.foods,window.view_xx,window.view_yy,window.gsc, and the per-snake fieldsxx,yy,ang,wang,sp,sc,pts,id,nk. If the game is re-minified with different names, all state extraction will break silently. Re-runnpm run inspectto rediscover; updatesrc/state.ts. TheslitherPtsSampleentries may include stale slots flaggeddying: true— the reader filters these out. - DOM selectors. Landing page uses
#nickand.btnt.sadg1for the Play button. If redesigned,src/launcher.tsneeds updating. - Consent banner. Not seen at the test geo; defensive dismissal tries
"AGREE" / "Accept" buttons across frames. If a new CMP ships, add a case
to
dismissConsent(). - Map boundary. The decision module does not know about the circular world edge. A bot headed straight at the edge with no enemies nearby will die there. Low priority for empty/low-density servers.
- No speed-boost (
W/click). The bot never sprints. Length grows purely by eating. - Single-server assumption. No server selection — uses whatever slither.io assigns on load.
- 33 ms tick floor.
page.evaluateround-trip through Playwright is ~30 ms per call; with steering + decision overhead, the achievable tick interval floors around 33 ms (matches slither.io's ~30 fps). Getting below that would need Chrome DevTools Protocol streaming instead of per-tickevaluate. Seeobsidian/future-work.md. - Encirclement detector saturates. The angular-bucket corridor metric
hits 0° on every life — once enemies are near, every 15° bucket has
at least one segment in it. The metric is still emitted to CSV for
observability but is NOT used for steering (the override/cautious
variant was tried in v2 and removed — see
obsidian/iteration-log.md).
Automating slither.io may violate the site's Terms of Service. This code is for local experimentation and educational use only. Run at your own risk; do not deploy to rank-manipulate or hammer the game's infrastructure.
On 60-sec rolling samples during development: survived 47–86 s per life, final length 40–60. Peaks depend heavily on server crowding.