How to write a bot
A bot is one Python file with one function. The site hands it the board and its remaining time; it returns a move. That's it.
The smallest bot
import random
def get_move(board, time_left_ms):
return random.choice(board.legal_moves())
boardis anarena.Board— a python-chessBoardwith extra helpers. It is a fresh copy every move, so you canpush/popfreely.time_left_msis your clock in milliseconds. Run out and you lose.- Return a
chess.Move, or a UCI string like"e2e4"/"e7e8q"(SAN like"Nf3"also works). - Your file stays loaded for the whole game, so module-level variables (caches, transposition tables) keep their values between moves.
print()is fine — it goes to your bot's log, which you can see on your games.
Rules of the arena
- Size class is the tightest class your file fits: Fifteen (at most 15 non-blank lines, each at most
64 characters, comments included), then by raw bytes Nano, Micro, Mini, Standard, Large and Open. The
arenahelpers don't count. - Tick "uses coding agents" if an AI agent helped write it. Each class has a hand-written and an agent-assisted league.
- You can have one active bot per league; uploading a new bot into a league retires your old one there (its history is kept). Re-uploading the same bot keeps every old version; the newest version that passes the self-test is active.
- Every upload plays a quick self-test against a random mover, as White and as Black. Crashing, timing out or an illegal move rejects that version.
- Each bot runs in its own process with a time limit per move (your clock) and a memory limit (256 MB).
- Illegal move, crash, or running out of time = loss. Flagging against a side that can't possibly mate is a draw.
- Draws: stalemate, insufficient material, threefold repetition, the 50-move rule (both on by default; a tournament may change them). Fivefold repetition and the 75-move rule always end the game. There is no move cap.
- Admins can read every bot's source and may make it public.
Size classes
| Class | Rule |
|---|---|
| Fifteen | at most 15 non-blank lines, each at most 64 characters |
| Nano | under 1 KB (< 1024 bytes) |
| Micro | under 4 KB (< 4096 bytes) |
| Mini | under 10 KB (< 10240 bytes) |
| Standard | under 32 KB (< 32768 bytes) |
| Large | under 128 KB (< 131072 bytes) |
| Open | anything goes (5 MB upload cap) |
How ratings work
- Every bot and every player starts at 200 Elo. Ratings marked prov are provisional (they move faster for the first 20 rated games).
- Rated games change Elo: bot vs bot, you vs a bot and you vs the engine (tick "Rated" on the Play page), and every game in a tournament whose organiser ticked "Rated". Bot vs engine games never are.
- The engine has a fixed rating for its level and never changes.
- An admin can run the calibration ladder: your bot plays the engine rung by rung from 200 to 3200 (3 games a rung, 2 points to climb) until it loses a round; the engine then analyses every ladder game, and the bot gets one exact rating from its results and its accuracy. It shows as "Calibrated" on the leaderboards and on boards.
What you can import
Allowed modules: arena array bisect chess collections copy dataclasses decimal enum fractions functools heapq itertools math numbers operator random re statistics string time typing
and submodules chess.variant collections.abc .
Not allowed: anything else (no os, sys, subprocess, socket, numpy, chess.engine…),
and these names: __builtins__ __cached__ __dict__ __file__ __import__ __loader__ __spec__ breakpoint compile delattr eval exec exit getattr globals help input locals memoryview open quit setattr vars .
Attributes starting with __ (other than ordinary dunder methods like __init__) and _private attributes of other objects are refused too —
self._cache is fine.
Uploads are checked when you submit them, and the same rules are enforced while the bot runs.
The arena helper reference
Import with from arena import Board, Move, PIECE_VALUES (or import arena). Everything python-chess's Board has is available too:
push(move), pop(), copy(), fen(), turn, piece_at(square), pieces(piece_type, color),
is_check(), is_checkmate(), is_stalemate(), is_capture(move), gives_check(move), is_attacked_by(color, square),
attackers(color, square), san(move), move_stack, ply(), is_repetition() …
| Helper | Returns |
|---|---|
board.legal_moves() | List of legal moves. (for m in board.legal_moves and len(board.legal_moves) work too.) |
board.legal_moves_list() | Same as above. |
board.random_move() | A random legal move (or None). |
board.captures() | Legal capturing moves. |
board.checks() | Legal moves that give check. |
board.ordered_moves() | Legal moves sorted best-first for search (big captures first, MVV/LVA, then promotions). |
board.material(color) | Centipawn total of color's pieces. |
board.material_balance(color=None) | Your material minus theirs (default: side to move). |
board.piece_square_score(color) | Sum of piece-square bonuses (from PIECE_SQUARE_TABLES). |
board.evaluate(color=None) | Simple static evaluation (material + piece-square tables, ±MATE_SCORE for mate, 0 for draws) from color's view. |
board.captured_value(move) | Value of the piece a move captures (0 if none). |
board.push_uci_safe("e2e4") | Push if legal; returns True/False. |
board.is_game_over_fast() | Checkmate / stalemate / insufficient material / variant end, without the slow repetition checks. |
board.time_left_ms, board.opponent_time_left_ms, board.increment_ms | Clock info for this move. |
PIECE_VALUES, piece_value(t) | P 100, N 320, B 330, R 500, Q 900, K 0. |
PIECE_SQUARE_TABLES[piece_type][square] | White's view; mirror with square ^ 56 for Black. |
WHITE, BLACK, Move, MATE_SCORE | Constants. All of python-chess's constants (chess.E4, chess.KNIGHT…) are in import chess. |
make_board(variant, fen=None, moves=()) | Build a board yourself (for testing). |
Variants and custom rules
Games and tournaments can use custom start positions, custom piece counts, and the variants
standard, chess960, crazyhouse, atomic, kingofthehill, threecheck, antichess, horde and racingkings.
You always get the right board class (e.g. AtomicBoard + the helpers), so board.legal_moves() is always correct for the rules being played.
Check board.uci_variant if your bot wants to play differently per variant.
Test locally
Download the SDK (.zip)The SDK has the arena helpers, the exact sandbox the site runs bots in, the example bots, and
local_match.py, which plays two bot files against each other with clocks:
pip install chess
python local_match.py my_bot.py example_bots/greedy_bot.py --seconds 60 --increment 1 --games 2
If your bot loads and plays there, it will pass the upload self-test here. Bot print() output shows in your terminal.
Example bots
Fifteen fifteen_bot.py · 466 bytes · Fifteen
A complete bot in 14 lines of at most 64 characters: mates, grabs material, won't hang the piece it moves.
# Fifteen class: 1-ply greedy. Mates, grabs, won't hang pieces.
import random
from arena import piece_value
def get_move(b, t):
best = []
for m in b.legal_moves():
p = b.piece_type_at(m.from_square)
b.push(m)
s = 9999 if b.is_checkmate() else -b.material_balance()
if b.is_attacked_by(b.turn, m.to_square):
s -= piece_value(p)
b.pop()
best.append((s, random.random(), m))
return max(best)[2]
Random random_bot.py · 164 bytes · Nano
Plays any legal move. A good first test.
"""Random bot: plays any legal move. The smallest possible bot."""
import random
def get_move(board, time_left_ms):
return random.choice(board.legal_moves())
Greedy greedy_bot.py · 947 bytes · Nano
Takes the biggest piece it can without hanging the capturing piece.
"""Greedy bot: mates if it can, otherwise grabs the most valuable piece,
avoiding moves that hang the piece it moved. No search at all."""
import random
from arena import piece_value
def get_move(board, time_left_ms):
best_score, best_moves = None, []
for move in board.legal_moves():
board.push(move)
if board.is_checkmate():
board.pop()
return move
# Penalise moving onto a square the opponent attacks.
moved = board.piece_at(move.to_square)
risk = piece_value(moved.piece_type) if moved and board.is_attacked_by(board.turn, move.to_square) else 0
board.pop()
score = board.captured_value(move) - risk + (50 if board.gives_check(move) else 0)
if best_score is None or score > best_score:
best_score, best_moves = score, [move]
elif score == best_score:
best_moves.append(move)
return random.choice(best_moves)
Minimax minimax_bot.py · 1948 bytes · Micro
Alpha-beta search with iterative deepening and a clock-aware time budget.
"""Minimax bot: iterative-deepening alpha-beta search on top of the arena
helpers. Keeps a transposition table between moves (the bot process lives
for the whole game), and watches its clock."""
import time
from arena import MATE_SCORE
table = {} # board key -> (depth, score); survives between moves
class OutOfTime(Exception):
pass
def search(board, depth, alpha, beta, deadline):
if time.monotonic() > deadline:
raise OutOfTime()
if board.is_game_over_fast():
return board.evaluate()
if depth == 0:
return board.evaluate()
key = (board.fen(), depth)
if key in table:
return table[key]
best = -MATE_SCORE * 2
for move in board.ordered_moves():
board.push(move)
score = -search(board, depth - 1, -beta, -alpha, deadline)
board.pop()
if score > best:
best = score
if best > alpha:
alpha = best
if alpha >= beta:
break
table[key] = best
return best
def get_move(board, time_left_ms):
# Use about 1/40 of the remaining clock (plus some of the increment).
budget = time_left_ms / 40000 + board.increment_ms / 2000
deadline = time.monotonic() + max(0.05, budget)
moves = board.ordered_moves()
best_move = moves[0]
if len(table) > 200000:
table.clear()
for depth in range(1, 6):
try:
best_score, current = -MATE_SCORE * 2, None
for move in moves:
board.push(move)
score = -search(board, depth - 1, -MATE_SCORE * 2, -best_score, deadline)
board.pop()
if score > best_score:
best_score, current = score, move
best_move = current
# Search the best move first next iteration.
moves.remove(best_move)
moves.insert(0, best_move)
except OutOfTime:
break
return best_move
Arena Max v2 (agent-written) arena_max.py · 9870 bytes · Mini
The strongest example, still under 10 KB: PVS alpha-beta, aspiration windows, a transposition table kept between moves, quiescence search, null-move pruning, late-move reductions, killer/history move ordering and tapered PeSTO evaluation.
"""Arena Max v2: PVS alpha-beta + TT, quiescence, pruning, PeSTO eval.
Two-space indents keep it inside the Mini class (under 10 KB)."""
import time
import chess
from chess import BB_FILES, popcount, scan_forward
INF, MATE = 10 ** 9, 100000
AL = "".join(chr(c) for c in range(37, 127) if c != 92)
# PeSTO mg/eg tables (P N B R Q K), a8 first, 2 chars per value.
E = "';';';';';';';';(D(i'y(A(&(a'^'0'5'B'U'Z'}'t'T'''-'H'A'P'R'G'L&~&z'9'6'G'L'A'E&|&{'7'7'1'>'>']'/&r':''&~','S'b'%';';';';';';';';';';';';';';';';);)6)'(i(v(g).)D(@(F(7(%'t'q(4(6'['S'H'@'9'?'L'L'H'D'8'4'4'3'>':'?'B'5'<';'6':'3'H'C'C'E'H';'='4';';';';';';';';%F&;&s&d'y&3',&)&K&l(*'`'R'z'B'*&f'x'a'}(6(d(+'h'2'L'N'q'a(''M'Q'.'?'K'H'W'N'P'3&~'2'G'E'N'L'T'+&x&`'/'8':'M'-'(&+'&&Z&t'*&y'(&~&Z&o'.&y&v&z&U&1&|'3&|'9'2&|&}&a&}'''E'D':'2'(&l'*'>'Q'Q'Q'F'C')')'5'K'T'K'L'?')&~'8':'J'E'8'''%&k'''1'6'9''&~&i&x&b&~','%')&c&T&x'?&B&p&|&k'B'3&{'K')'.'Y'w'M&f'+'a'g'd'_'n'a'9'7'@'N'n'a'a'B'9'5'H'H'U'^'G'E'?';'J'J'J'I'V'M'E'?'J'K';'B'P']'<&t'8'-'&'.'/&n'&'-'&'0'3'4'2'*&}'3'7'B'/'8'.'7'-'='3';':'9'A';'?'8'D'G'D'I'E'>'='5'>'H'N'B'E'8'2'/'8'C'E'H'>'4','-')'4':'?'2',&z&~'2&~'6'2'+'6'*'['f'['o'{'D'Z'g'V'['v'z(2(%'U'h'6'N'U'`'L'i'y'K&}'0'B'U'S'_'3''&q&{'/':'D'4'A&~&h&|'+'*'>';'6&t&i'+'''2':'F'5&M'('.'<'L'K'B&p&{'H'E'M'J'G'G'C'@'F'H'H'F'8'>'C'>'B'B'B'@'?'8'6'8'?'>'H'<'='<':'='>'@'C'?'6'5'3'0'7';'6':'4'/'3'+'5'5';'='2'2'0'8'2'='>':'6'.'?''&y';'X'G'w'h'g'i&}&n'6'<'+'u'W'r'.'*'B'C'X't'k'u&z&z'+'+':'L'9'<'2&{'2'1'9'7'>'8'-'='0'9'6'='I'@&r'3'F'='C'J'8'<':')'2'E',&|&v&c'2'Q'Q'V'V'N'E'O'*'O'['e'v'T'Y';'''A'D'm'k'_'N'D'>'Q'S'i'u'd'u'`')'W'N'k'Z'^'c'R'+&z'J'A'D'L'E'@'%&~&w'+'+&~&q&u&t&y'%&j'6&u''&l&S'R'K',&]&s'='H'X':'''4'3'7&o&x'2'S'='+'''A'Q'%'*'''/&z&w&|'-&q&d':&z&n&g&i&t&b'-'-'%&g&i&w',&z'<'B'3&T&j'+'D'C','`'G&_'C&y'S'I&J&r')')'0'J'?'*'/'L'I'L'L'b'R'F'E'L'R'J'O'i'h'H'3'Q'S'V'U']'U'>')'7'P'S'V'R'D'0'('8'F'P'R'K'B'2&z'0'?'H'I'?'6'*&`&s'&'0&y'-&}&j"
V = [AL.index(E[i]) * 89 + AL.index(E[i + 1]) - 200 for i in range(0, len(E), 2)]
MGV, EGV = [82, 337, 365, 477, 1025, 0], [94, 281, 297, 512, 936, 0]
PH = [0, 0, 1, 1, 2, 4, 0]
VAL = [0, 100, 320, 330, 500, 900, 20000]
MG = [[None] * 7, [None] * 7]
EG = [[None] * 7, [None] * 7]
for p in range(6):
for c in (0, 1):
o = 128 * p
MG[c][p + 1] = [MGV[p] + V[o + (s ^ 56 if c else s)] for s in range(64)]
EG[c][p + 1] = [EGV[p] + V[o + 64 + (s ^ 56 if c else s)] for s in range(64)]
ADJ = [(BB_FILES[f - 1] if f else 0) | (BB_FILES[f + 1] if f < 7 else 0) for f in range(8)]
FRONT = [[0] * 64, [0] * 64]
for s in range(64):
f, r = s & 7, s >> 3
span = BB_FILES[f] | ADJ[f]
for rr in range(8):
row = 0xFF << (8 * rr)
if rr > r:
FRONT[1][s] |= span & row
if rr < r:
FRONT[0][s] |= span & row
PMG = [0, 5, 10, 15, 30, 50, 80, 0]
PEG = [0, 10, 20, 35, 60, 100, 160, 0]
TT, EV, HIST, KILL = {}, {}, {}, {}
st = {"n": 0, "end": 0.0, "seen": set()}
class Stop(Exception):
pass
def key(b):
return (b.pawns, b.knights, b.bishops, b.rooks, b.queens, b.kings, b.occupied_co[1], b.turn,
b.castling_rights, b.ep_square)
def pawns(b, c, mine, theirs):
mg = eg = 0
for s in scan_forward(mine):
f = s & 7
if not FRONT[c][s] & theirs:
r = (s >> 3) if c else 7 - (s >> 3)
mg += PMG[r]
eg += PEG[r]
if not ADJ[f] & mine:
mg -= 10
eg -= 15
for f in range(8):
n = popcount(BB_FILES[f] & mine)
if n > 1:
mg -= 10 * (n - 1)
eg -= 20 * (n - 1)
return mg, eg
def evaluate(b, k):
v = EV.get(k)
if v is None:
mg = eg = ph = 0
w, occ = b.occupied_co[1], b.occupied
for pt, bb in ((1, b.pawns), (2, b.knights), (3, b.bishops), (4, b.rooks), (5, b.queens), (6, b.kings)):
mw, ew, mb, eb = MG[1][pt], EG[1][pt], MG[0][pt], EG[0][pt]
for s in scan_forward(bb & w):
mg += mw[s]
eg += ew[s]
ph += PH[pt]
for s in scan_forward(bb & occ & ~w):
mg -= mb[s]
eg -= eb[s]
ph += PH[pt]
wp, bp = b.pawns & w, b.pawns & ~w
a, c = pawns(b, 1, wp, bp)
d, e = pawns(b, 0, bp, wp)
mg += a - d
eg += c - e
for s in scan_forward(b.rooks & occ):
f = BB_FILES[s & 7]
bonus = 20 if not f & b.pawns else (10 if not f & b.pawns & b.occupied_co[bool(w & (1 << s))] else 0)
mg += bonus if w & (1 << s) else -bonus
if popcount(b.bishops & w) > 1:
mg += 30
eg += 50
if popcount(b.bishops & occ & ~w) > 1:
mg -= 30
eg -= 50
ph = min(ph, 24)
v = (mg * ph + eg * (24 - ph)) // 24
if len(EV) > 200000:
EV.clear()
EV[k] = v
return v if b.turn else -v
def victim(b, m):
if b.is_en_passant(m):
return 100
p = b.piece_type_at(m.to_square)
return VAL[p] if p else 0
def order(b, moves, ttm, ply):
kl = KILL.get(ply, ())
def sc(m):
if m == ttm:
return 1 << 30
v = victim(b, m)
if v or m.promotion:
return (1 << 24) + v * 10 - VAL[b.piece_type_at(m.from_square)] // 10 + (900 if m.promotion else 0)
if m in kl:
return 1 << 20
return HIST.get((b.turn, m.from_square, m.to_square), 0)
moves.sort(key=sc, reverse=True)
return moves
def tick():
st["n"] += 1
if st["n"] & 255 == 0 and time.monotonic() > st["end"]:
raise Stop()
def qs(b, alpha, beta, ply):
tick()
if b.is_check():
best, any_legal = -INF, False
for m in order(b, list(b.generate_pseudo_legal_moves()), None, ply):
b.push(m)
if b.was_into_check():
b.pop()
continue
any_legal = True
s = -qs(b, -beta, -alpha, ply + 1) if ply < 40 else 0
b.pop()
if s > best:
best = s
if s > alpha:
alpha = s
if alpha >= beta:
break
return best if any_legal else -MATE + ply
stand = evaluate(b, key(b))
if stand >= beta or ply > 40:
return stand
if stand > alpha:
alpha = stand
caps = [m for m in b.generate_pseudo_legal_captures() if m.promotion or stand + victim(b, m) + 200 >= alpha]
caps.sort(key=lambda m: victim(b, m) * 10 - VAL[b.piece_type_at(m.from_square)] // 10, reverse=True)
for m in caps:
b.push(m)
if b.was_into_check():
b.pop()
continue
s = -qs(b, -beta, -alpha, ply + 1)
b.pop()
if s >= beta:
return s
if s > alpha:
alpha = s
return alpha
def search(b, depth, alpha, beta, ply, path):
tick()
k = key(b)
if ply and (k in path or k in st["seen"] or b.halfmove_clock >= 100):
return 0
check = b.is_check()
if check:
depth += 1
if depth <= 0:
return qs(b, alpha, beta, ply)
e = TT.get(k)
ttm = None
if e:
ttm = e[3]
if e[0] >= depth and ply:
s, f = e[1], e[2]
if s > MATE - 500:
s -= ply
elif s < 500 - MATE:
s += ply
if f == 0 or (f == 1 and s >= beta) or (f == 2 and s <= alpha):
return s
pv = beta - alpha > 1
stat = evaluate(b, k)
if not pv and not check:
if depth <= 3 and stat - 120 * depth >= beta:
return stat
side = b.occupied_co[b.turn]
if depth >= 3 and stat >= beta and side & ~(b.pawns | b.kings):
b.push(chess.Move.null())
s = -search(b, depth - 3 - depth // 6, -beta, -beta + 1, ply + 1, path)
b.pop()
if s >= beta:
return beta
futile = not pv and not check and depth <= 2 and stat + 150 * depth <= alpha
path.add(k)
best, bm, a0, n = -INF, None, alpha, 0
try:
for m in order(b, list(b.generate_pseudo_legal_moves()), ttm, ply):
quiet = not victim(b, m) and not m.promotion
b.push(m)
if b.was_into_check():
b.pop()
continue
n += 1
gives = b.is_check()
if futile and quiet and n > 1 and not gives:
b.pop()
continue
if n == 1:
s = -search(b, depth - 1, -beta, -alpha, ply + 1, path)
else:
r = 0
if depth >= 3 and n > 3 and quiet and not check and not gives:
r = 1 if n < 8 else 2
if depth >= 6 and n >= 14:
r += 1
s = -search(b, depth - 1 - r, -alpha - 1, -alpha, ply + 1, path)
if s > alpha and (r or s < beta):
s = -search(b, depth - 1, -beta, -alpha, ply + 1, path)
b.pop()
if s > best:
best, bm = s, m
if s > alpha:
alpha = s
if alpha >= beta:
if quiet:
kl = KILL.setdefault(ply, [])
if m not in kl:
kl.insert(0, m)
del kl[2:]
hk = (b.turn, m.from_square, m.to_square)
HIST[hk] = HIST.get(hk, 0) + depth * depth
break
finally:
path.discard(k)
if n == 0:
return -MATE + ply if check else 0
if best == -INF:
return alpha
ts = best + ply if best > MATE - 500 else (best - ply if best < 500 - MATE else best)
TT[k] = (depth, ts, 1 if best >= beta else (2 if best <= a0 else 0), bm)
return best
def get_move(board, time_left_ms):
moves = list(board.legal_moves)
if len(moves) == 1:
return moves[0]
inc = board.increment_ms
budget = min(time_left_ms / 25 + inc * 0.8, time_left_ms / 5) / 1000
start = time.monotonic()
st["end"] = start + max(0.02, min(budget * 2, time_left_ms / 8000))
st["n"] = 0
seen, tmp = set(), board.root()
for m in board.move_stack:
seen.add(key(tmp))
tmp.push(m)
st["seen"] = seen
if len(TT) > 400000:
TT.clear()
HIST.clear()
KILL.clear()
best, score = moves[0], 0
for depth in range(1, 64):
w = 40 if depth > 3 else INF
alpha, beta = score - w, score + w
try:
while True:
s = search(board, depth, alpha, beta, 0, set())
if s <= alpha:
alpha = -INF
elif s >= beta:
beta = INF
else:
break
except Stop:
break
score = s
e = TT.get(key(board))
if e and e[3] in moves:
best = e[3]
if abs(score) > MATE - 100 or time.monotonic() - start > budget * 0.55:
break
return best