Tic-Tac-Toe and Crystal Palace 9

Building a tic-tac-toe model, then trying to make it small enough to matter

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.

Image 1. Tic-Tac-Toe Image Map
I now have the second generation of my tic-tac-toe game setup. It now has more visuals to help people understand that it is a game of tic-tac-toe. The mapping for the 5-faced prim was a little different, but not as hard as I expected. I added in a bunch of additional tweaks to handle what happens when a game ends, timeout of existing games, and preventing other people from distracting a players game. I still need to setup the difficulty levels. After that, this thing would be ready to package up and sell. From Dedric Mauriac via bloghud.com
Image 2. Tic-Tac-Toe in Second Life

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.

Video 1. War Games: Defcon 4 – Crystal Palace
Image 3. Theatre-of-war simulation via Tic-Tac-Toe

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.

Image 4. Ollama running on the console with palace-9

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.

Image 5. Ollama running lewismoten/palace-9:f16

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.

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.

Video 2. PyTorch Crash Course

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 / precisionStatusLatest verified result
Palace-9 F16 / Q6_K / Q4_K_MFinished and published; no training activeEach runtime-validated across 978,003 cases with zero failures
Crystal-9 FP32 referenceFinished, frozen ground truth0 / 294,778 legal-policy misses
Crystal-9 F16, INT8, INT6 simulationsFinished baseline evaluations0 misses each; not packed artifacts
Crystal-9 naive all-tensor INT4Baseline only; not active30,520 misses
Crystal-9 mixed-int4-row — expert/output matrices INT4-row; other components F32Finished, retained as the verified suffix0 misses
Crystal-9 mixed-int4-row-input — adds INT4-row token and position tables; expert/output suffix frozenActive trainingBest completed continuation: 1 miss
Crystal-9 INT3 / INT2 / INT1Not started as new QAT candidatesDeferred 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 groupINT4 statusExact-policy gate
Token + position embeddingsComplete, per-row✅ 0 / 294,778
Attention Q/K/V/output weightsComplete, per-row✅ 0 / 294,778
Attention input + output biasesComplete✅ 0 / 294,778
Expert weights + output weightComplete, per-row✅ 0 / 294,778
Router weightComplete, per-row✅ 0 / 294,778
Router biasRunning
Expert biases / output biasNot started
LayerNorm parametersNot started
Packed INT4 storage + independent runtimeNot started
Invalid-input publication gatesNot 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.

Video 3. Predictive Autonomous Learning And Nuclear Command Evaluation Model

Sources

Leave a Reply

Discover more from Lewis Moten

Subscribe now to keep reading and get access to the full archive.

Continue reading