KILOBYTECHESS

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())
  • board is an arena.Board — a python-chess Board with extra helpers. It is a fresh copy every move, so you can push/pop freely.
  • time_left_ms is 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 arena helpers 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

ClassRule
Fifteenat most 15 non-blank lines, each at most 64 characters
Nanounder 1 KB (< 1024 bytes)
Microunder 4 KB (< 4096 bytes)
Miniunder 10 KB (< 10240 bytes)
Standardunder 32 KB (< 32768 bytes)
Largeunder 128 KB (< 131072 bytes)
Openanything 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() …

HelperReturns
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_msClock 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_SCOREConstants. 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.

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