Inside Magnikaru: from a chess move to a model-guided reply
How Magnikaru encodes a chessboard, evaluates it with a Transformer, searches for a reply and carries the result back to its web interface.
A player moves a piece. The application needs to understand the resulting position, explore possible replies and put one of them back on the board. Magnikaru divides that work between chess rules, a neural position evaluator, an alpha-beta search and a web interface.
My contribution to this Data Science Group project was the frontend, as credited in the repository README. This post follows the shared codebase at revision aa60c9b, including the model and training code, to explain how those pieces connect.
The central distinction is between evaluating a position and choosing a move. The neural network returns one number for a board. Search turns repeated evaluations of future boards into a move choice. There is no move-probability head in the model wired into the server.
The application architecture
The browser template loads chess.js and chessboard2.js, and supplies spaces
for the board, evaluation bar, move history and game status. A Flask frontend
serves that page and exposes /api/get_server_move. It forwards requests to
a separate Flask model server at http://localhost:8900/api/make_move.
The model server reconstructs the board with python-chess, evaluates the
submitted position, searches for a reply and returns JSON. The frontend
service passes that JSON back to the browser. Keeping the model behind its
own service means the browser only needs to exchange chess state and results;
it does not load PyTorch or the checkpoint.
This division is visible in Frontend/app.py and ChessServer/app.py. The frontend also has a random-move endpoint, but the model-backed route calls the search engine. The response's leftover “Random move” message does not describe how that route selects its move. Stockfish files are present in the repository, but this request path uses the custom evaluator and search.
Deployment: what Docker starts
The committed Docker setup packages the frontend Flask service. The
Compose file
defines one service named magnikaru, builds from the Frontend directory,
sets PORT=9000 and publishes container port 9000 on host port 9000:
services:
magnikaru:
build:
context: .
dockerfile: Dockerfile
environment:
- PORT=9000
ports:
- 9000:9000The
Dockerfile
starts from python:3.12.11-alpine3.22, sets /app as the working directory,
installs requirements.txt, copies the frontend source and runs python app.py.
Installing requirements before copying the rest of the application lets Docker
reuse that dependency layer when only application files change. Those
requirements include Flask, Flask-CORS, python-chess, python-dotenv and requests.
The image does not install PyTorch or copy the sibling ChessServer directory.
From the repository root, the documented container workflow is:
cd Frontend
docker compose up --buildThat exposes the frontend at http://localhost:9000. It does not start
the model server. There is no ChessServer service, model-image build,
checkpoint mount, GPU configuration or service-readiness check in the committed
Compose file. The missing browser script still has to be restored for the
page to complete a turn.
How the two services communicate
The frontend serves both the HTML page and the browser-facing API. A move request travels over ordinary synchronous HTTP:
Browser
POST /api/get_server_move on the frontend origin
-> Frontend Flask service, port 9000
POST http://localhost:8900/api/make_move
-> ChessServer Flask service, port 8900
position evaluation + move search
<- JSON containing move, score and message
<- Frontend relays that JSON to the browserThe inference request contains the current FEN. The model server constructs a fresh board from it for each request; this path does not store a separate server-side game session. The browser must send the position after each player move. There is no WebSocket connection or polling loop in the committed Python services: the frontend waits for the model server's response and returns it.
For a manual setup, ChessServer is started separately in its Python environment
with python app.py from ChessServer, after installing its dependencies and
providing a compatible checkpoint. It binds to 0.0.0.0:8900. The frontend
can be started with python app.py from Frontend, with PORT=9000 in the
environment or its .env file. With both processes running directly on the
same host, the hardcoded localhost:8900 destination reaches ChessServer.
The source and checkpoint issues described later still need resolving before
inference can run.
Why the container boundary changes the address
Inside the frontend container, localhost refers to that container. A
ChessServer process running on the Docker host, or in another container,
will not be reached through the current hardcoded URL. Publishing frontend
port 9000 only makes the frontend reachable from outside; it does not change
where the frontend's outgoing inference request goes.
For a two-container deployment, I would make the model URL configurable,
add a chessserver service on the same Compose network and have the frontend
call http://chessserver:8900/api/make_move. That is a proposed deployment
change, not configuration present in this revision. The model container would
need its own PyTorch-compatible image, corrected imports and the checkpoint
at the path the engine loads. Only the frontend needs a published port for
browser access; the model API can remain on the internal container network.
If ChessServer stays on the host, the frontend instead needs a reachable host address configured for that Docker environment. Either layout keeps the browser talking to the frontend, with inference remaining a server-to-server request.
The current frontend forwards model HTTP failures as a JSON error with status 500. Its requests.post call has no explicit timeout, so an inference request
that stalls can also hold the frontend request open. A deployment-ready version
would set an inference timeout, check model readiness and run the Flask apps
under a production WSGI server; both committed entry points currently use
Flask's built-in server. The repository does not include a reverse proxy,
TLS setup or a hosted deployment URL.
Representing a position for the model
A FEN string records piece placement, the side to move, castling rights,
en passant state and move counters. In the evaluator, the board is exported
to PGN and passed through the same pgn_to_tensor helper used during training.
Calling it with end_steps=0 retains the submitted position.
utils.py produces two inputs:
| Input | Shape per position | Contents |
|---|---|---|
| Board | 7 × 8 × 8 | One color channel and six piece-type channels |
| Metadata | 13 | Side to move, four castling flags and eight en passant file flags |
The color channel uses 1 for a White piece, 0 for a Black piece and 0.5
for an empty square. The remaining channels mark bishops, kings, knights,
pawns, queens and rooks. A White knight therefore has color 1 and a 1
in the knight channel; an empty square has color 0.5 and no piece-type flag.
The metadata matters because identical piece placement can still describe different legal situations. Whose turn it is and whether castling or en passant is available can change the position. The neural inputs omit the FEN move counters and repetition history, so they do not encode every condition relevant to a draw.
The Transformer used by the server
The repository contains a residual CNN and several Transformer experiments.
Both the training entry point and engine instantiate
ChessTransformerClassification(pos_type="fixed", attention_type="cross", num_layers=4).
The implementation to follow is
models/transformers.py.
Board [batch, 7, 8, 8] Metadata [batch, 13]
| |
64 square tokens, 7 values each |
| |
Append fixed square position |
64 tokens, 8 values each |
| |
Prepend a zero token |
65 tokens, 8 values each |
| |
+---- 4 cross-attention blocks -----+
|
Read output token 0
|
Linear 8 -> 32, ReLU
|
Linear 32 -> 1
|
Sigmoid
|
Scalar position evaluationTurning squares into tokens
The tokenizer rearranges the board into 64 vectors of seven values. In the
configured fixed-position mode, it appends one value to each vector:
1/64, 2/64, …, 64/64. That gives the model information about where a piece
sits, alongside what piece it is. These are concatenated scalar positions,
rather than sinusoidal embeddings.
It then prepends an eight-value zero token. There are now 65 tokens, each with width eight. The model eventually reads token zero to obtain a summary for classification.
The tokenizer supports projecting metadata into that first token in self-attention mode. In the configured cross-attention mode, that option defaults to false: the first token starts at zero, and metadata enters through attention instead.
How game state conditions attention
Each cross-attention block projects the 13 metadata features into an eight-dimensional query. The board-token sequence supplies keys and values. The operation follows scaled dot-product attention:
Q = metadata projected from 13 to 8 dimensions
K = token sequence projected from 8 to 8 dimensions
V = token sequence projected from 8 to 8 dimensions
attention = softmax(Q Kᵀ / sqrt(8)) VAn implementation detail matters here: the metadata query is repeated across all 65 query positions. Within a block, every position therefore receives the same attention result before its residual addition. The per-position residual still carries each token's own state. This is a single-head, metadata-conditioned summary of the sequence; it does not create a separate square-specific query for every piece.
After attention, the block adds the residual and applies layer normalization. A feed-forward network expands each token from eight to 32 values, applies ReLU and projects back to eight. A second residual addition and layer normalization finish the block. This repeats four times.
The classifier reads token zero, applies 8 → 32 → 1 linear layers with ReLU
between them, then a sigmoid. The resulting scalar lies between zero and one.
Under the binary labels in the PGN preprocessing pipeline, it estimates the
White-win class. It is not a calibrated evaluation of every possible chess
outcome, especially since that pipeline excludes draws.
Dataset preparation and training
The repository documents more than one data path. Its
ChessServer README
names the Kaggle dataset datasnaek/chess. The older
CSV splitter
keeps games where both ratings exceed 1300 and the move list contains at least
20 entries, then makes an 80/20 split within each processed chunk.
The separate PGN preparation scripts provide the clearest match for the current
loader, which expects pgn and Result columns. The
PGN filter
reads a local games.pgn, rejects missing or invalid player ratings, keeps
games with a rating difference of at most 150 and excludes draws. The
PGN splitter
maps a White win to 1 and a Black win to 0, exports PGN text and creates an
80/20 train/validation split with seed 42.
These are distinct preparation paths, not evidence that all filters were combined in one dataset. The CSVs and original PGNs are absent, and their filenames differ across scripts and configuration. The repository therefore does not establish the final training set's size, class balance or exact provenance for the referenced checkpoint.
Learning backward from the end of a game
The current iterable dataset samples a position from each game and uses that
game's eventual result as its label. end_steps means how many plies to remove
from the end, where one ply is a move by one player. It is not the engine's
search depth.
In
lightning_model.py,
the training data loader sets K = 2 × epoch + 1. At epoch zero it samples
offsets zero or one from the end. With the configured 15 epochs, the last
epoch allows offsets through 29. Exponential sampling weights favor larger
offsets within the available range, and a minimum-step setting tries to retain
at least ten plies when the game is long enough.
This curriculum starts near the recorded result and gradually includes earlier positions, where predicting the outcome is harder. Validation enumerates offsets zero through 29 and records accuracy by offset. Short games can clip different offsets to the same position, so those buckets need care when interpreting results.
The Lightning wrapper uses binary cross-entropy and Adam. The
configuration
sets a batch size of 128 and learning rate of 0.001. This is supervised outcome
prediction from recorded games. The loss does not directly teach the model
which move to play; search supplies that part at inference time.
What happens after a player moves
Consider a player opening with e4. The board after that move has Black to
move. The intended browser integration sends that updated FEN to
/api/get_server_move. The following steps are implemented on the server:
- The frontend Flask service reads the request's
fenfield and forwards it to the model server. It currently usesjson=json.dumps(payload), so the HTTP body contains a JSON string rather than a plain JSON object. The model server compensates by callingjson.loads(request.json). - The model server builds
chess.Board(fen)and evaluates that position once. This evaluation becomes the response's displayed score. - It calls
bot_predict_movewith search depth three. Legal moves come frompython-chess; the neural network does not generate move notation. - The selected move is converted into Standard Algebraic Notation with
board.san(move). The server returns that string and the score, and the frontend service relays the JSON to the browser.
For an illustrative response, a move field might contain e5 or Nf6.
Those examples explain the notation; they are not measured predictions from
the checkpoint.
How a candidate move gets its score
engine.py
implements recursive alpha-beta search. For each legal move, it pushes the
move onto the board, searches the resulting position and pops the move to
restore the parent. At depth zero, it encodes the leaf position and calls the
model under torch.no_grad().
Depth three explores a bot move, an opponent response and another bot move, unless a game-ending state stops a branch earlier. A candidate's value is backed up from its descendants by alternating maximum and minimum. That lets the search account for an opponent's response instead of choosing whichever immediate board the evaluator likes most.
Alpha and beta track bounds from alternatives already examined. When a branch cannot improve the current choice, the loop stops exploring it. The source evaluates leaves individually; it does not batch those model calls or order moves before searching.
The color convention needs to agree with the label
For a White-win score, White should maximize and Black should minimize. The
search maximizes when its color argument is 1 and minimizes when it is 0.
However, the API hardcodes COLOR = 1 with a comment calling that Black.
With the PGN pipeline's 1 = White win labels, that means a Black reply is
chosen by maximizing White's score. Deriving the maximizing side from
board.turn would align search with that label convention. The older dataset
helper uses the opposite label mapping, and the checkpoint itself is absent,
so its actual training convention cannot be settled from the filename alone.
There is another boundary to handle: game-ending positions are still sent to the evaluator rather than assigned exact win/draw/loss values. If the submitted board has no legal move, search returns no move and the API still attempts SAN conversion. Explicit terminal handling would give both search and the UI a reliable result at the end of a game.
From the returned score to the screen
The API transforms the submitted position's model output p as follows:
score = 1600 × p − 800
p = 0.25 -> score = -400
p = 0.50 -> score = 0
p = 0.75 -> score = +400This is a linear display scale from approximately -800 to +800. The source provides no calibration that makes it a Stockfish-style centipawn evaluation. Under the PGN labels, positive values indicate a stronger White-win prediction. The returned score evaluates the board before the bot reply; it is neither the chosen move's backed-up search value nor a fresh evaluation after that move.
The
HTML template
defines myBoard, evalBar, pgnTable and gameStatus. Its
CSS
gives the evaluation bar an initial 50% White portion and a height transition,
and defines a game-over overlay.
The intended final step is to apply the returned SAN move to the browser's
chess state, redraw the board, append the move to the history, refresh game
status and map the evaluation onto the bar. But the template references
static/scripts/index.js, which is absent from this revision. The actual
move event handlers, bar mapping, promotion handling and error behavior cannot
be verified. The backend response and UI containers establish the interface
between them; the committed code does not establish a complete runnable round trip.
To reproduce the application, that browser script and the trained checkpoint
need to be supplied. The engine also imports ChessServer.cnn, while the
committed CNN file lives under ChessServer/models/cnn.py. Its checkpoint name
mentions learned embeddings even though the constructor selects fixed
positions and cross-attention; compatibility needs checking against the actual
weights. These gaps leave playing strength and runtime behavior unverified,
but the source makes the architecture clear: encode a board, score future
positions, search for a reply and carry the result back to the chess interface.