Building a tic-tac-toe model, then trying to make it small enough to matter
A phenomenal demonstration of how transformer architectures, attention mechanisms, and MoE routers work at a bare-bones, mathematically transparent level – proving that you don’t need billions of parameters to build an AI playing a game, just a brilliant structural design.
A few days ago I decided to make a model that plays tic-tac-toe. That sounds like a toy project, and it is. It is also a good excuse to force every layer of a modern model workflow into a size I can inspect: data generation, training, mixture-of-experts routing, quantization, browser inference, packaging, model formats, and finally a local runtime.
This wasn’t my first attempt. Ten years ago, I experimented with artificial neural nets but struggled to implement backpropagation. I had one project for a tic-tac-toe AI and another on deep layered neural networks in general to wire into it. Before that, I also developed a deterministic tic-tac-toe game in 2015 with interesting image map placement, and then a deterministic game in JavaScript the year prior. Tic-Tac-Toe is often a game of choice because the rules are simple to implement, and most people recognize the game and know how to play.


Here we are, a decade after my last experiment. The first project is called Palace-9. The 9 is the board. The name also nods to WarGames. In the film, “Crystal Palace” is the call sign heard from the NORAD command center.[3] My site borrows the movie’s cold-room terminals, maps, detection labels, and blinking-control-room mood, but it is deliberately fictional. It is a tic-tac-toe machine, not a model of anything military.

That distinction mattered to me because the styling was easy to overdo. The map, DEFCON-like details, and cipher display make a better interface when they are treated as set dressing. The useful part is the board, the move history, the confidence display, and the ability to watch a very small model make a decision.
The browser model was not the deployment model
My earliest version ran in the browser. It used a board-state encoder and a small mixture-of-experts model that could be loaded as JSON and played directly in JavaScript. That gave me a compact visual lab: I could click a square, examine the input, see the selected experts, inspect the intermediate values, and compare precision modes.
It also made me confront a problem most model demos hide: a browser visualization is not automatically a model deployment.
A browser-friendly model can be designed around typed arrays, plain JSON, and exactly the encoding the web page expects. Ollama does not run that JavaScript. It runs a GGUF model through llama.cpp compatible machinery, with a recognized architecture and tokenizer stored in the model file. Hugging Face expects a useful source checkpoint and supporting files. Those are different contracts.
I kept that separation explicit. The browser board-state MoE remains a visualization and playable policy experiment. The deployable Palace-9 model became a tiny causal Qwen2-MoE model trained to read a raw history such as aei and emit one move. They solve the same game problem, but they are not interchangeable artifacts.
That was one of the recurring lessons: if two things look similar on screen, they are not necessarily the same model.
Training became more real, and less convenient
The browser project started with nodes, edges, and a representation that feels intuitive when you are drawing a neural network. Real training pushed me toward matrices, tensor shapes, token IDs, attention heads, routing logits, and a lot of code that exists only to make those pieces line up.
The small scale did not remove the complexity. It made shape mistakes easier to find.
I trained against a deterministic tic-tac-toe oracle and exhaustively checked legal and invalid histories. A policy is not useful just because it can produce a plausible move after a few examples. It must avoid occupied squares, reject malformed histories, respect turn order, and make the same choice at every precision I claim to support.
Then came quantization. I wanted F32 as the reference, F16 as a practical higher-fidelity deployment option, and smaller integer or GGUF formats where they actually worked. A label is not evidence. Each precision needed a measured artifact and a complete policy test.
The first version had 36-wide layers. That worked for the original design, but 36 is a strange number once block-oriented quantizers come into the picture. Many tensor dimensions looked pleasantly square in the visualizer: a 36-by-36 weight matrix can be presented as a tidy 6-by-6 grid of blocks. That is nice for a website. It is not automatically nice for grouped quantization formats that prefer dimensions divisible by 32.
Some tensors could not be packed the way I wanted. Some had to remain higher precision. The smallest GGUF was mixed storage rather than a pure, uniform “four-bit model.” That is normal, but it is also why I stopped treating a format name as a complete description of what was inside the file.
The browser added another wrinkle. JavaScript precision simulations are useful for inspection, but they are not a substitute for a packed GGUF being loaded by a real runtime. I had to keep saying that out loud, mostly to myself: browser INT4, a fake-quantized PyTorch tensor, and Q4_K_M are related experiments, not the same thing.
The tokenizer problem was a deployment problem
For a game played with the letters a through i, a tiny hand-made vocabulary feels obvious. In a direct PyTorch experiment, it is obvious. I control the token IDs, so a vocabulary with game symbols and a few control tokens is easy to train.
The trouble starts when the same model must travel through Hugging Face, GGUF, llama.cpp, and Ollama.
The standard deployment path needs tokenizer metadata that the target runtime recognizes. Palace-9 therefore uses a byte-level GPT-2-style BPE tokenizer with 261 tokens: the 256-byte base vocabulary plus special tokens. That is much larger than a tic-tac-toe alphabet, and it is not there because the game needs expressive language. It is there because the packaging pipeline needs a portable tokenizer representation.
The context window also had to be treated as a runtime contract. The game itself needs at most eight move tokens before the ninth square is filled. But the deployed model needs a beginning-of-sequence token and a configuration that keeps the raw-history prompt stable. I used a 16-token Ollama context to give that representation room.
Those details became visible when I first tried the model in Ollama’s chat interface. I typed a letter and got a full completion instead of one move. The model had learned the game policy, but the package did not set a default output limit. Adding num_predict 1 fixed that part.

The chat interface created a second, subtler problem. A chat conversation is not a mutable tic-tac-toe board. Sending a, then sending e in the next message does not mean “continue the same game” unless the model and template have been built to interpret it that way. Palace-9 expects the full raw history in each fresh request: a, then ae, then aei. Once I understood that, the behavior stopped being mysterious. It was a mismatch between a conversational UI and a very narrow completion protocol.
Seeing it download was still delightful
There is a particular moment in a local-model project that makes the tedious packaging work feel worth it: watching the runtime download the thing, then seeing it answer correctly.

After the conversion, validation, Modelfile work, tags, and documentation, I could pull Palace-9 into Ollama and ask it for a move. The local model returned one token. A prompt of a returned e. It was not a large language model pretending to know tic-tac-toe. It was a deliberately tiny policy model, running locally, doing the one job it was trained to do.
I published the complete release on Hugging Face because that is where the source checkpoint, tokenizer, GGUFs, validation reports, checksums, and provenance belong together. I published the runnable variants to Ollama because it is the simplest way to let someone pull a tested GGUF and try it locally. The published family has F16, Q6_K, and Q4_K_M variants. Each one was treated as a separate runtime artifact, not as a filename copied from a table.
A visualizer from an old familiar name
One of the better surprises was discovering Netron. It can open and display a broad range of neural-network and machine-learning model formats, including PyTorch, Safetensors, and experimental GGUF support.[1] It is made by Lutz Roeder, whose projects page also lists .NET Reflector.[2]
That was a small shock of recognition. I used .NET Reflector years ago. Finding the same name behind a model visualizer felt appropriate for this project because so much of Palace-9 has been about refusing to leave the model as an opaque file.
The website lets me see an inference path in a game context. Netron lets me inspect the exported model’s structure. They do different jobs, but both answer the same basic question: what is actually in here?
I decided to ask ChatGPT to annotate the diagram and provide it with the 1MB GGUF model file.


1. A move history goes in – not a board bitmap
Each character token (a-i, or ! for invalid) becomes one 36-number embedding. Position in the history says whose turn it is: odd = X, even = O.
The GGUF permits 16 tokens; a legal game history needs at most 9.
The tokenizer has 261 byte-level tokens, though Palace9 uses 10 game symbols.
2. Attention reads relationships across the move sequence
This single transformer block has 9 query heads and 3 key/value heads (grouped-query attention). It can link earlier moves with the latest one: threats, blocks, forks, and whether a square has already been played.
36-wide state ÷ 9 heads = 4 values per query head.
3. The router chooses 2 of 9 compact experts
ffn_gate_inp scores nine experts for each token. Only the top two are used – that is the MoE part. Each routed expert expands 36 values to 18 internal values, applies its learned transformation, then returns to 36.
“9 experts” does not mean one expert per tic-tac-toe square.
They are learned pathways; any can help with any pattern.
4. The shared expert is always active
The right-hand FFN path is a shared 5,632-wide expert. Unlike the nine routed experts, it runs on every token. It gives the model a common fallback / general pathway; the selected routed experts add specialized corrections.
The graph is asymmetric because this shared path is much larger.
5. ADD boxes are residual connections
After attention and after the feed-forward system, the incoming 36-value state is added back in. That preserves basic move information while each stage contributes a learned update rather than replacing everything outright.
6. The last layer scores possible next characters
Output is 36 × 261: one score (logit) per tokenizer token. For tic-tac-toe, the intended winning candidates are a-i, with ! reserved for invalid histories.
Constrained decoding to a-i! would make the game interface safer; the 261-way output alone can still score unrelated byte tokens.
Plain-English trip through one turn
“acbe” → encode the ordered move history → attention compares its moves → router picks 2 helpful pathways → shared expert + selected experts refine the state → output ranks the next move, for example “i”.
Architecture: Qwen2MoE • blocks: 1 • width: 36 • context: 16
MoE: 9 experts, 2 used/token • expert width: 18 • q heads: 9 / kv: 3
Green notes explain mechanics; amber notes flag details worth knowing.
Crystal-9: the next pass is about the floor, not the ceiling
Palace-9 proved the whole path: train a tiny MoE policy, expose it in a browser, export it, validate it, publish it on Hugging Face, and run it through Ollama. That experiment is done enough to be useful.
Now I am starting a clean sibling project called Crystal-9. The name comes from WarGames and Crystal Palace, but the technical reason for starting over is simpler: I want to choose shapes for the low-bit problem from the beginning.
Crystal-9 begins with 32-wide layers, an eight-token game context, and a custom minimal vocabulary for direct training. I had to start from scratch to use the 32-wide layers, which give me more options for training, quantization, and later sharding the model for fun.
The F32 reference run already performs perfectly on its legal-history evaluation. F16, INT8, and INT6 survive the first simulated precision gate without policy misses. Plain post-training INT4 does not. That is not a disappointment so much as a useful boundary. It says the next step is quantization-aware training, better scaling, or a different layout, not a dishonest INT4 badge.
The current status of training a new model:
| Model / precision | Status | Latest verified result |
|---|---|---|
| Palace-9 F16 / Q6_K / Q4_K_M | Finished and published; no training active | Each runtime-validated across 978,003 cases with zero failures |
| Crystal-9 FP32 reference | Finished, frozen ground truth | 0 / 294,778 legal-policy misses |
| Crystal-9 F16, INT8, INT6 simulations | Finished baseline evaluations | 0 misses each; not packed artifacts |
| Crystal-9 naive all-tensor INT4 | Baseline only; not active | 30,520 misses |
| Crystal-9 mixed-int4-row — expert/output matrices INT4-row; other components F32 | Finished, retained as the verified suffix | 0 misses |
| Crystal-9 mixed-int4-row-input — adds INT4-row token and position tables; expert/output suffix frozen | Active training | Best completed continuation: 1 miss |
| Crystal-9 INT3 / INT2 / INT1 | Not started as new QAT candidates | Deferred until this INT4 progression is stable |
INT4 is harder, so my PyTorch strategy has shifted to freezing the parts of the layers that work in a mixed-precision model, focusing on INT4 weaknesses, then moving to earlier layers and repeating. This change in strategy is working and avoids spreading the layers out to preserve information when I can fine-tune specific model weights.
| Component group | INT4 status | Exact-policy gate |
|---|---|---|
| Token + position embeddings | Complete, per-row | ✅ 0 / 294,778 |
| Attention Q/K/V/output weights | Complete, per-row | ✅ 0 / 294,778 |
| Attention input + output biases | Complete | ✅ 0 / 294,778 |
| Expert weights + output weight | Complete, per-row | ✅ 0 / 294,778 |
| Router weight | Complete, per-row | ✅ 0 / 294,778 |
| Router bias | Running | — |
| Expert biases / output bias | Not started | — |
| LayerNorm parameters | Not started | — |
| Packed INT4 storage + independent runtime | Not started | — |
| Invalid-input publication gates | Not started | — |
INT3, INT2, and INT1 are still experiments. They may never become useful Ollama artifacts, and I will not call them working because a tensor can be rounded to one bit. I’m trying to convert a deterministic function into a deterministic model. If a low-bit format does not shrink materially, load in the real runtime, tokenize the prompt correctly, and pass the game-policy tests, it is not a release.
The goal now is not to make tic-tac-toe more impressive. The goal is to find out how small an honest, locally runnable mixture-of-experts model can be.
Sources
- [1] https://github.com/lutzroeder/netron — Netron on GitHub
- [2] https://www.lutzroeder.com/projects — Lutz Roeder projects
- [3] https://www.youtube.com/watch?v=HsSH9ibLzlc — WarGames Crystal Palace clip
- Demo: https://lewismoten.github.io/palace-9/
- Hugging Face: https://huggingface.co/lewismoten/palace-9
- Ollama: https://ollama.com/lewismoten/palace-9
- GitHub:
- Palace-9 model (2026): https://github.com/lewismoten/palace-9
- Tic-Tac-Toe AI (2016): https://github.com/lewismoten/tic-tac-toe-ai
- Artificial Neural Network (2016): https://github.com/lewismoten/artificial-neural-network
- Octothorpe-xo (2016): https://github.com/lewismoten/octothorpe-xo
- Tic-Tac-Toe LSL (2015): https://github.com/lewismoten/Tic-Tac-Toe-lsl
- Tic-Tac-Toe (2014): https://github.com/lewismoten/tic-tac-toe
