A Khet (laser chess) engine in pure Python: full rules, an alpha-beta searcher, a browser UI to play against, and self-play game generation.
A Rust port of the board, evaluation and search lives at khet and is roughly 25× faster. The two are held to identical node counts by a recorded fixture, so this repo doubles as the reference implementation.
Khet is played on an 8×10 board. Each side has 13 pieces:
| piece | count | behaviour |
|---|---|---|
| Sphinx | 1 | Fires the laser. Cannot move; has only two legal facings. Immune to every beam, including its own. |
| Pyramid | 7 | A single mirror. Reflects a beam striking either of its two mirrored faces, and is destroyed by a beam striking either of the other two. |
| Scarab | 2 | A double-sided mirror. Reflects from all four directions and can never be destroyed. May swap places with an adjacent pyramid or anubis of either colour. |
| Anubis | 2 | Armoured on one face. Absorbs a beam striking that face, destroyed from any other. |
| Pharaoh | 1 | Destroyed by a beam from any direction, which ends the game. |
A turn is: move one piece one square in any of the eight directions, or rotate it 90°, and then fire your own laser. Never both, and the shot is not optional.
That last part is what makes Khet unlike chess. There is no quiet move — every single turn ends in a beam crossing the board, which can just as easily destroy your own piece as your opponent's. A player who moves a pyramid out of the way may find their own laser now reaches their own pharaoh. Losing that way counts as a loss.
Each colour also owns some restricted squares — their own file plus two squares beside the opponent's — that the other colour may not enter. Silver moves first.
pip install -e .
khet play # opens a browser game against the engine
khet play --color red --time-limit 5
khet selfplay --depth 4 # watch the engine play itself in the terminal
khet benchmark-strength --games 10 # the search against a random mover
khet play starts a small local server and opens a browser. Click one of your
pieces, then a highlighted square to move it; hollow rings mark scarab swaps.
The curved arrows rotate the selected piece, as do Q and E.
Esc deselects. The beam is drawn after each move, so you can see
exactly what your shot did.
Useful flags for play: --color, --time-limit (seconds the bot may think),
--max-depth, --port (0 picks a free one), --host, --no-browser.
The engine is written to be PyPy-friendly, and the difference is large. If you have PyPy, use it:
pypy3 -m pip install -e .
pypy3 -m khet.cli play
pieces.py — Piece encoding and the laser rules. A piece is a 6-bit integer
packing type, colour and orientation, not an object; the search copies these by
the thousand. The laser interaction table (LASER[code][direction] → new
direction, destroyed, or absorbed) and the rotation-legality tables are derived
from the rules once at import.
board.py — The position and everything that mutates it. 80 piece codes in
a flat list, board geometry and Zobrist keys precomputed at import, moves packed
into single integers, and make/unmake so the search never copies a board.
Also laser resolution: _fire_laser for the search, laser_path and
beam_scan for the UI and for asking whether a beam is about to destroy
something.
evaluation.py — What a position is worth, from one colour's point of view.
Material, pyramid advancement toward the far rank, and a bonus for friendly
pieces orthogonally adjacent to your own pharaoh, all folded into piece-square
tables at import. Scarabs and sphinxes score zero because they cannot be
destroyed; the pharaoh scores zero because losing it is terminal and the search
handles that with a mate score.
tree_search.py — The search. Iterative-deepening negamax with alpha-beta,
a transposition table, killer moves and history ordering. Every move after the
first is searched with a null window, and moves late in the ordering are
searched with the depth reduced as well; anything that comes back better than
expected is re-searched properly. Searcher(reference=True) runs plain
alpha-beta instead — no null windows, no reductions — which the tests use to
check the pruned search against something with no bets in it.
bot.py — Move selection. AlphaBetaPlayer wraps the searcher and decides
what to actually play: best move, a random pick among moves tied for best, or a
softmax sample over the root scores when temperature is set. RandomPlayer is
a baseline opponent.
selfplay.py — Generates and inspects self-play games.
python -m khet.engine.selfplay generate --games 1000 --depth 4 --out games.jsonl
python -m khet.engine.selfplay diversity --out games.jsonl
python -m khet.engine.selfplay show --out games.jsonl --index 0
Games are one JSON object per line, storing the move list rather than positions,
so a game replays exactly and a file stays small. Generation is append-only,
resumable, and seeded per game — interrupt it, extend it, or shard it across
processes with disjoint --seed-start ranges. Openings are randomised and the
first stretch of each game is sampled rather than played best-move, because
without that every game is identical; diversity reports whether it worked.
benchmark.py — Three workloads, in increasing order of realism: perft
(move generation and make/unmake only), search (a fixed-depth search), and
selfplay (whole games, reported in games and positions per hour).
python -m khet.engine.benchmark
pypy3 -m khet.engine.benchmark # for comparison
server.py — A threaded HTTP server on the standard library only. Each
browser gets its own game, keyed by an opaque cookie, so the URL can be shared
without two people fighting over one board. Every response carries the whole
game state rather than a delta, which means the page can always re-render from
scratch.
static/index.html — The page: a single file, no build step, no
dependencies. Draws the board as SVG and animates the beam after each move. The
board is a fixed 684px wide, so it wants a desktop browser; the copy in
khet has been made responsive if you need to play on a phone.
khet/cli.py — The khet command. play, selfplay,
benchmark-strength.
pytest # fast suite
pytest -m slow # adds deep perft
The suite pins the rules — perft counts, and the fact that the classic setup
maps onto itself under a 180° rotation with the colours exchanged — and pins the
search: reference mode must equal an unpruned negamax, and through depth 2,
before reductions engage, the pruned search must equal it too. It also covers
the browser UI's JSON API and the self-play record format.
MIT