Encoding tic-tac-toe in 15 bits
cbarrick.dev
cbarrick.dev
https://www.craig-wood.com/nick/oxo2d/
I generated this with a C program that tried to minimise the number of boards. There are 427 pages including the index.
This has been through quite a few revisions of my website and I lost the source to the C program so it remains a historical artifact only!
(About halfway down the page. It's as an example application of binary decision diagrams.)
But, hey, Steve Huffman sent me a Reddit sticker!
Every page is a grid state and each cell contains the number of the page you're supposed to go to if you pick this cell
(this is my best recollection of the "this is how insane it is" story, numbers may be slightly off)
Iterate through a billion possible shuffles a second and every yearly anniversary, take a 1M pace around the equator. When you get back to your starting point (after 40,075 years), take 1cc out of the Pacific Ocean.
When the ocean is empty, put one A4 piece of 0.05mm paper on a pile. Now you start filling the ocean 1cc every circumnavigation. When it's full, A4 piece of paper on the pile. etc.
By the time you exhaust the list of shuffles, the pile will be 188 light years tall.
(I don’t remember the exact details now so take it with a grain of salt.)
I believe the optimal response to:
X . .
. O .
. . .
Should be X . .
. O .
. . X
Which offers a reasonable opportunity for "O" to mess up by choosing a corner. But instead your script plays X X .
. O .
. . .
Which guides the human player to reflexively counter accurately without thinking.This would be relevant if humans made their choice with a significant amount of randomness. But I argue that in this specific situation, very nearly every human will play to block the obvious threat.
I believe humans will be more likely to pick one of the two losing moves in the slightly more abstract/delayed threat scenario, than they would be to pick one of the 5 losing moves in the immediate threat scenario.
I also believe humans are more likely to pick one of the 4/8 losing moves if you open center than one of the 7/8 losing moves if you open corner, though.
The full state machine enumerated here:
START
case YOU_ARE_FIRST_PLAYER == T: DRAW
case YOU_ARE_FIRST_PLAYER == F: DRAW
Of course, for deterministic games and optimal play, either the first player always wins, the second play always wins, or it is always a draw.More interesting, if just one player (i.e. the computer player) always plays optimally, and favors the fewest states, that reduces the number of possible "valid" game states. So maybe less than 10 bits needed?
765 - 2^9 = 765 - 512 = 253 states would need to become unreachable for that to help.
Consider a "genome" for a tictactoe player to be a 19,682-long array that holds "next moves" at each slot in the array. For any given gamestate, look up the player's next move via their genome.
Randomize genomes originally. Make tons of them. Compete w/ each other, then play around with all sorts of fun mating/mutation strategies for swapping different moves between genomes.
Fun to see a perfect tictactoe player evolve, super approachable toy example.
There are at most 9 moves in a game; after that the board is full. The first move has 9 options, the next 8, the next 7, etc. That's 9! sequences of moves. And this is an upper bound, because some prefixes lead to shorter games.
Computing ceil(log2(9!)), we get 19 bits -- not for a game-state, but for an entire play-history.
One could do better using an exact game-tree. You could think of it as a simple version of arithmetic coding. Or you could just think of it as assigning an index (0, 1, 2, ...) to each leaf visited in some specified tree-traversal order.
I had a "win statistic" for each possible play, for each game state. It taught itself to play by playing partially random moves, then going back and updating the win statistic for each game state in the play chain.
I was mesmerized, watching it teach itself to play.
That is, I'd love to see an exploration of how big both the encoding and the executing program are and how they play against each other. As others have already noted, you could skip the encoding of the board as a "first class" piece of data and instead have a large program where the state of the board is implied by where in the code you are. In a sense, this is a complete minimization of the board's encoding and should result in a maximal sized code.
It would be fun to plot how these two values interact with each other.
People are rightfully pointing out that this can be compressed further.
My challenge to you: Implement a compressed representation along with the get_cell and set_cell methods, without resorting to lookup tables!
Also, check out Alejandra's blog at https://goose.love/!
(And yeah, you need 12 or 13 bits, not 10, if you don't want to eliminate symmetries.)
I don’t see how that makes a difference. You can always replace a lookup table by code, for example:
a = [832, 54, 743]
vs func a(i) =
if i = 0 return 832
if i = 1 return 54
return 743
The classic example of this are the definitions of cons, car and cdr in SICP as lambdas: (define (cons x y)
(lambda (m) (m x y)))
(define (car z)
(z (lambda (p q) p)))
(define (cdr z)
(z (lambda (p q) q)))
See https://stackoverflow.com/a/21769444 for an explanation.For pure functions taking finite inputs, the reverse is possible, too. For example, you can define and on booleans as a 2 × 2 array
and = [[false, false], [false, true]]
and then do a and[x, y] lookup to evaluate it. That’s why some functional languages (for example scala) do not make a distinction between array indexing, hash table lookups, and function calls. After all, they all are mathematical functions taking a single value and producing one.I think I would judge solutions not on avoiding lookup tables, but on size of the encoding and, for programs that produce equal size encodings, the total number of bytes in the programs, using “less is better” as criterion for both.
I agree that the challenge isn't rigorously defined. But the spirit is to not allow this kind of trick.
It follows the principle that one can represent a board by choosing k filled spots from 9 and then choosing k / 2 Os from k. These two combinations can be converted into an index, which can then be offset according to k to prevent collisions with indices from other ks. This offset is not pretty, but it works.
I haven't thoroughly tested my code, but the principle should work even if there's a bug or two :)
With some luck I'll find time to write a clearer explanation or a blog post.
Also by discarding any boards that are only reachable after some winning solution.
Honestly, the most efficient solution is probably to enumerate all valid reachable board states, and then just encode them in a look-up table. It's a tiny game.
Damn it, I nerd sniped myself. After hacking together a little program, it looks like there's 5,620 valid reachable board states, so 13 bits is enough to encode them all.
https://stackoverflow.com/questions/7466429/generate-a-list-...
In tic tac toe, I'm not sure there is a such a board. The information being encoded does not track order of play. Any board could be formed with the final move being the center to win?
XXX
OOO
XOXI wrote a solver (https://github.com/phimuemue/brainball_solver) for "brainball" (https://twistypuzzles.com/cgi-bin/puzzle.cgi?pkey=889), a (rubik-like) puzzle involving 13 cells arranged in a circle.
Each cell has: A number (from 1 to 13). - 4 bits. Two sides colored white resp. yellow. - 1 bit. The goal is to arrange the cells in number-ascending order, all white sides face up.
Representing each cell would lead to 13*(4+1)=65 bits. A "canonicalized" representation where the cell number 13 is implicitly at a certain position shrinks the representation to 12*(4+1)=60 bits and fits into a 64-bit integer (https://github.com/phimuemue/brainball_solver/blob/master/sr...).
This was one of the things that made my solver reasonably fast (others were lookup tables (https://github.com/phimuemue/brainball_solver/blob/master/bu...), and aggressive function inlining (e.g. https://github.com/phimuemue/brainball_solver/blob/master/sr...)).
Use 9-bits to store positions of either blanks or Xs, depending on which there are more of. 1 extra bit to indicate which case it is. Then you have at most 6 remaining cells, so 6 more bits to indicate which of the two remaining options each contains. 16 bits total.
This also has the benefit that you can use fewer bits for many game states, e.g. a blank board can be represented in 10 bits.
The 15 bit encoding could be visualized in a different way, too, that is using 3 bits to represent two successive cells state:
000 "__"
001 "X_"
010 "_X"
011 "XX"
100 "XO"
101 "OX"
110 "O_"
111 "_O"
There are 5 successive cells (4.5 actually, 9/2, but we need to be discrete here, and discard the last), so 5*3 = 15.However this encoding shows that if you want to represent multiple boards one after the other you are actually just using 13.5 bits for each board, as it should be in theory.
Because to represent 18 total cells (two boards) you need 9*3 bits = 27/2 = 13.5 bits per board.
The encoding into those 5 bits is precisely the base-3 encoding from the article, so I'll leave it as an exercise for the reader.
Looking at the linked paper, 765 is after deduplicating the same state rotated. In a real game, you would need to know which orientation is used, so you'd need a couple of extra bits for that.
Two?
I.e.,
[O X -]
[- - -]
[- - -]
can be rotated 90 degrees 4 times (2 bits) to return to the same arrangement.It can also be rotationally flipped 2 times (1 bit), clockwise <--> counter-clockwise, to return again. With the dual state for the above non-rotated original:
[O - -]
[X - -]
[- - -]
So three bits to extract symmetry (or recreated the broken symmetry)But ... some arrangements have instance symmetry where only 1 bit of rotation symmetry is needed, and no rotation-flips:
[O - X]
[- - -]
[X - O]
So sometimes 3 bits of symmetry will contain redundant information. (i.e. rotations 0 and 2, and rotations 1 and 3, look the same. Rotation flip changes nothing.And this field requires 0 symmetry bits, as all rotations and flips are identities (1 step to return = 0 bits)
[- - -]
[- X -]
[- - -]
A representation that always uses the minimum number of bits but has a straightforward relationship to the actual playing field is illusive[1]https://web.archive.org/web/20020513063952/http://www.mathre...
int player = grid[row][col];
if (row == (col+ col +2)%3){
if (grid[(row+1)%3][(col+2)%3] == player && grid[(row+2)%3][(col+1)%3]==player){
return true;
}
}First bit to mark whether X or O starts, then 4 bits for the placement of the first symbol (9 empty squares numbered 0-8 counting from top left), then only 3 bits for the second one (one square is already taken so there are only 8 possible positions left), etc. which gives us 4+3+3+3+3+2+2+1 = 21 bits for the 8 moves before there is only one unfilled square left.
I did something like this a while ago here, to produce the smallest possible implementation of the game in pure HTML: https://code.up8.edu/-/snippets/6
I just went over the section again, and I'll do my best to break down his algorithm: He starts by picturing 2 3x3 grids - one for the X’s and another for the O’s. This setup introduces 18 boolean variables - x1 to x9 for the X's, and o1 to o9 for the O's. I think this can be neatly represented as a bit array (see https://github.com/denkspuren/BitboardC4/blob/master/Bitboar...).
Now, creating a full-scale truth table for these 18 variables means we would be looking at 2^18, or 262,144 rows. However, it turns out only 4520 of those are legal inputs. So, the question becomes, how do we find patterns in this boolean chain to build our tic-tac-toe strategies.
Knuth simplifies it by considering only the following strategies - (a.) winning - when putting an X in a cell wins the game - like when two cells in any line already have X's. (b.) blocking - when putting an X in a cell stops the other player from winning - like when two cells in any line have O's. (c.) forking - when putting an X in a cell opens up multiple winning moves - you store all the winning line possibilities (horizontal, vertical, and diagonal lines - all 9) in a set, and for each winning line (say {i, j, k}), you place an X on i, leave k empty, and see if a move on j creates two win chances. (d.) defending - similar, when putting an X in a cell stops the other player from creating multiple win chances. (e.) And just making any legal move - when a cell does not have an X or an O.
The priority order of moves follows the order above. There's also a bit of priority within the legal moves - like the middle spot is best since it's part of more winning lines, while the edges are the last choice.
As I am writing all this, I think it might not seem all that cool until you actually check out the boolean equations for yourself! Pretty neat stuff.
1st / 9 / 3 bits (-1)
2nd / 8 / 3 bits (0)
3rd / 7 / 3 bits (+1) (used to complete 1st)
4th / 6 / 3 bits (+2)
5th / 5 / 2 bits (-1) (use 4th extra)
6th / 4 / 2 bits (0)
7th / 3 / 1 bit (-1) use 4th extra
8th / 2 / 1 bit (0)
9th / 1 / 0 bits
Total = 18 bits. This is for the entire game history, from the first to last move, and every board state in between. Decoding this 18 bit mask is not really that challenging, since there are only 3 special cases, and the 9th move requires no bits to store the move (since there is only one possible move). To reconstruct the state at any given time, you need to start from the keyframe and move forward.
18 bits for entire game history.
This reasoning is not true. Let's say that the 1st move has 9 possible choices of A through I, while the 3rd move has 7 possible choices of 1 through 7 (which would depend on prior moves). When the 3rd move is made, your choice for the 3rd move has to be fixed and there is no "spare" choice available. If you chose to use that spare choice, you must use 3 bits allocated for the 1st move somehow differently so that both 1st and 3rd moves are uniquely determined.
As a concrete example, let's represent A/1 as 000, B/2 as 001, ..., G/7 as 110 and H as 111. Any combination of [A-G][1-7] should be trivial to encode. If the first move is I however, you have to use the spare choice for the 3rd position to represent that fact. The 3rd move itself then has to move into bits originally used by the 1st move. Note that this leaves one unused bit pattern: 111111, which demonstrates why your reasoning is incorrect---this happens because the number of possibilities muliplies, not adds, and 9 * 7 < 8 * 8. In general the optimal packing of 9 moves would need 9 * 8 * ... * 1 = 362 880 possibilities, which exceeds 2^18 = 262 144 possibilities which 18 bits can encode, so you actually need 19 bits. (Or 20 bits if you also account for 623 530 mid-game states.)
Where GP's idea breaks down pragmatically (as well as logically) is when he tries to take _two_ spare representations and use them to encode _two_ extra states, for bits 6, 5, and 3. It's true that 6 = 2^3 - 2, 5 = 2^2 + 1, 3 = 2^1 + 1; but you can't represent 6×5×3 = 90 states using only 2^(3+2+1) = 64 representations.
In short: If A = 2^a + k and B = 2^b - k, then we can cancel the k when adding A+B, but not when multiplying A×B.
For example, suppose that you need to store a permutation of 4 digits, [1,3,0,2]. The rightmost digit is base 4 and simply stores 1 directly. The second digit is base 3, but scale 4, and stores the position of 3 in [0,2,3] (because 1 has been removed from the set), i.e., 2. The third digit is 0 (base 2 scale 12) by similar reasoning, and the "fourth digit" would be base 1, which is always 0. Multiplying it out, you get 012 + 24 + 1 = 9.
Decoding follows a similar process:
9 % 4 = 1 9 / 4 = 2 [0,1,2,3][1] = 1
2 % 3 = 2 2 / 3 = 0 [0,2,3][2] = 3
0 % 2 = 0 0 / 2 = 0 [0,2][0] = 0
[2][0] = 2
The particularly observant reader will notice that the decoding algorithm is simply a deterministic Fisher-Yates shuffle, and the common optimization where you swap items from the source list instead of shifting them applies here as well.This technique allows you to store permutations of up to 12 items in 32 bits, or 20 items in 64.
To store the game state I did use 3 bits for each cell (0=empty, 1=X, 4=O), total 27 bits. That structure allowed to conveniently check if a win state was reached by multiplying the 3 cells values of the column/row/diagonal of the last move (multiplication required less code bytes than addition). 111=1 or 444=64 were winning states.
However I have a formula in the code that I fail to remember what was its use:
15.5I-2.5I²-20
Full source is here: https://web.archive.org/web/20010517135352/http://www.multim...I think I'd want to represent those trillions of game states in the the 10-bit representation it mentioned earlier and keep around a uint32_t[765] to map that to the more practical 18-bit version. (or rather something like 13-bit and uint32_t[5477] because llimos pointed out that the 765 is after rotation.)
[1] I think the word "unpacked" here is just carelessly chosen rather than a contrast to the "pack them tightly using 18 bits for the base-4 representation or 15 bits for the base-3 representation" in the paragraph I elided.
Huffman or arithmetic coding can pretty easily provide that “fraction” of a bit, at the cost of making decoding more expensive (and not random access).
And if this is a hot part of the code a lookup table for 256 values may be a speedup and is very manageable. Especially if you compress it to 128 bytes (although that may lose the performance you were after, calculating how much cache is worth using is a hard problem).
Maybe? What if you brute forced a block cipher to encode/decode each 10-bit mapping into the raw 15-bit mapping (i.e., a 9-digit base-3 number).
It may take some crunching to find it, but only once. Maybe someone else can tell me how feasible this is.
There is also an equivalent set of "worst" choices, and even for the "best" choices, it has four rotations, since there are four corners that X could start from that are topographically the same.
Still cool though :-)
However, upshot, taking account of the symmetries, this is doable on a single sheet of paper, though I'd suggest making the board smaller than you may initially think.
> we need nine base-3 digits... Representing this in binary will cost us… 15 bits!
https://cbarrick.dev/posts/2024/02/19/tic-tac-toe#:~:text=we...
Only supported by a few browsers; maybe just Chrome?
- Works on Chrome/Chromium desktop
- Chrome for iPhone (highlights, but doesn't jump)
More info:
- https://support.google.com/chrome/answer/10256233?hl=en&co=G...
- https://stackoverflow.com/a/62162093/117030
- https://wicg.github.io/scroll-to-text-fragment/
Inspired by this I've spent some time thinking about how to encode games of battleship, and honestly it's genuinely a fun problem to explore. (I'm not going to share any of my ideas because I've deliberately not looked into the literature on this and I'm sure it's well-explored.)