Devin

Solving Senet

An Egyptian race game more than 4,600 years old, solved. Each of its 8.56 billion positions gets its chance of winning under perfect play, and a small network learns to play almost as well. You can play it at the end.

finishedwritten 4 Oct 202616 min read

Senet is one of the oldest board games we know of. It first appears for certain in a painting in the tomb of Hesy-Re, an official of King Djoser, in the 27th century BC, and boards that may be Senet are older still.The dates are Peter Piccione’s. He puts Hesy-Re’s tomb at about 2686 BC, and the possible earlier boards, from burials at Abydos and Saqqara, at 3500–3100 BC. Tutankhamun was buried with it. It was played in Egypt for some three thousand years, until it died out around the start of the Christian era, and its rules were lost. What people play today are reconstructions, and the one used by most modern sets was published by the archaeologist Timothy Kendall in 1978.

This piece is about solving Kendall’s version. That means working out, for every position the game can reach, the chance that the player about to throw wins if both sides play perfectly from there on. There are 8,560,690,670 positions. Taken in the right order, a desktop computer values all of them in under three hours. The answer to the first question everyone asks is that White, who throws first, wins 50.2504% of games.

The table of values fills 34 GB, far too much for a web page. So I also trained a small neural network on it, which takes 0.8 MB and plays almost as well. You can play against it at the end.

The game

The board has thirty squares in three rows of ten. Both players race five pieces the same way round it, in an S: along the top row, back along the middle and along the bottom. White starts on the odd squares from 1 to 9 and Black on the even squares from 2 to 10.

Instead of dice there are four throwing sticks, light on one side and dark on the other. The number of light sides up is how far a piece moves, except that four dark sides count as 5. A throw of 1, 4 or 5 earns another throw.

Most of the other rules are about getting in each other’s way:

  • A piece that lands on a lone enemy piece swaps places with it. Two enemy pieces side by side protect each other: .
  • Three or more in a row form , which no piece may jump.
  • Every piece has to stop on 26, the House of Beauty: .
  • From 26, a 1 lands in 27, the House of Water, and to 15, the House of Rebirth, or to the nearest empty square below it.
  • The last three squares are for leaving. only with exactly 3, 2 or 1, and can make no other move. From 26, a 5 bears a piece off directly.
  • If no piece can move forward, one has to . If nothing can move at all, the turn passes, even after a 1, 4 or 5.

The first player to bear off all five pieces wins.Kendall gave each side seven pieces, as the oldest sets had. Most modern sets use five, as Egyptian sets did from the Eighteenth Dynasty on, and that is the game solved here. His description also leaves some questions open, and this version settles each one. Capture is by swapping, protection holds on every square, pieces on 28–30 move only by bearing off, and a throw with no legal move ends the turn. A different choice would give a different solution. Other reconstructions, such as R. C. Bell’s and Piccione’s own, are different games.

123456789101112131415161718192021222324252627282930borne off
throw–
to playWhite

White to throw.

or choose
Figure 1The board. White’s pieces are cones and Black’s are spools, the two common shapes of Egyptian game pieces. Throw the sticks, or choose a throw, and play for both sides. Shaded squares hold a piece that can move; point at one to see its move. The houses with special rules are marked in red: the ankh on 15, the sign for beauty on 26 and water on 27. On 28–30 the marks give the throw that takes a piece off. Egyptian boards marked these last five squares in every period: good, then bad or water, then 3, 2 and 1.

The sticks are what make Senet a game of chance, and they are not fair dice. Four sticks can fall in sixteen equally likely ways. Only one of those gives a 4, while six give a 2. The average throw is 2.31 squares, and three throws in eight earn another.

1
4/16
throw again
2
6/16
3
4/16
4
1/16
throw again
5
1/16
throw again
Figure 2The sixteen equally likely ways four sticks can land, grouped by the throw they give. No light side counts as 5. A 1, 4 or 5 earns another throw.

Solving a game of chance

With dice in the game, neither player can force a win, so every position has a chance of winning instead. Write V(s) for the chance that the player about to throw in position s wins, if both sides play perfectly from then on. Solving the game means finding V(s) for every position.

The values are tied together. The player to throw gets throw t with probability p(t) and picks the best legal move for it. Then they either throw again, after a 1, 4 or 5, or hand the sticks over. In the second case the opponent’s chance is V of the new position seen from their side, so the mover’s chance is one minus that. Writing R(m) for what move m is worth to the mover,

V(s)=∑t=15p(t)maxm⁡R(m),

where

R(m)={1if m wins the game,V(s′)if the mover throws again,1−V(s′)if the opponent throws next.

Here s′ is the position after the move, from the point of view of whoever throws next. When there is no legal move, R is one minus the value of the same board with the other player to throw.

The usual way to solve a game without dice is to work backwards from the end. First come the positions one move from a finished game, then two moves, and so on. That fails for Senet, because its positions lead round in circles. Captured pieces go backwards, the House of Water sends pieces back to 15, pieces are sometimes forced to retreat, and a turn with no legal move hands the same board straight back.

The smallest example has one piece each, yours on 30 and theirs on 29, with you to throw. A 1, with chance 416, takes your piece off and wins. Any other throw leaves you no move, so they throw, and they win with a 2, chance 616. If they miss, the same position comes back to you. Your chance V therefore depends on itself, V=14+34⋅58V, so V=817≈47.1%. That is worse than a coin toss, although you throw first and are further along. A piece on 30 gets off only with a 1, which comes up a quarter of the time, but one on 29 needs a 2, which comes up three times in eight.

So the values are the solution of one large system of equations. Each value depends on others and, through some chain of moves, on itself. The standard way to solve such a system is value iteration, which goes back to Lloyd Shapley’s 1953 paper on stochastic games. Start from any guess, then compute every value afresh from the equation, using the current guesses on the right-hand side, and repeat. After n rounds, or sweeps, each value looks n throws ahead, with whatever lies beyond still guessed. The game ends sooner or later, so the guesses matter less and less, and the values settle.

their piece onyour piece on1110101515202026263030
123456789101112131415161718192021222324252627282930

Your piece (the cone) on 24, theirs on 26, you to throw: a guess of you win 50.0%.

sweep0
largest change–
off the database by0.492
105 sweeps
Figure 3Value iteration on the 812 positions with one piece a side, run in your browser. Each square is a position: your piece’s square down the side, theirs along the top, you to throw. Ink means you are likely to win, red that you are likely to lose, and paper an even chance. Every value starts at 50%, and each sweep recomputes them all from the last. Point at a square to see its position. The readouts compare the values with the real solver’s database.

Figure 3 starts from the guess that every position is a coin toss. , only the positions where your piece can bear off on this throw have learned anything. After two, so have those where theirs can bear off on the next. , more than half the values have moved by a percentage point or more. , no value moves by more than 10−9, and the values agree with the database of the full solve to within 10−7.

The finished picture mostly says that whoever is further ahead is more likely to win, but the last squares break the pattern. Whatever square the other piece is on, the best place for yours is 29, which it leaves with a 2, the commonest throw. With yours on 29 and theirs on 1, you win 99.2%.

Eight and a half billion positions

A position is fixed by the squares each side occupies and by whose throw it is. A piece can stand on 29 squares, since one that lands in the House of Water is sent straight back. Each side can have from one to five pieces on the board. That makes 8,560,690,670 positions.

They fall into 25 layers by how many pieces each side has left (Figure 4). Bearing off is the one thing that can never be undone. So a layer leads only to itself, to its mirror image with the sides swapped, and to layers with one piece fewer. The solver therefore works through the layers from the fewest pieces up. It solves each group of layers by value iteration, with the smaller layers’ values already known.

pieces of the other side
pieces of the side to throw
12345
181210,96295,004593,7752.9million
210,962142,5061.2million7.1million32.8million
395,0041.2million9.5million54.6million240million
4593,7757.1million54.6million300million1.26billion
52.9million32.8million240million1.26billion5.05billion

8,560,690,670 positions in all; the corner with five pieces a side holds 59% of them.

Figure 4The 25 layers of positions, by the number of pieces each side still has on the board. Each black square’s area is the layer’s share of the largest, the five-against-five layer; the layers with few pieces are too small to see. A layer and its mirror image are solved together, so the solver works in 15 groups, from the top left corner.

Done naively, this would still be slow. The solver, written in Rust, makes it fast in four ways:

  • It keeps the values of the group being solved in memory and updates them in place, so a value changed early in a sweep is used by the rest of the same sweep.
  • It visits positions in order of how far their pieces have travelled, furthest first. Ordinary moves go forward, so most of a position’s successors are already up to date when it is reached.
  • It updates each position together with its mirror image, the same board with the other player to throw. It solves the pair’s equations for forfeited turns exactly, instead of letting the value bounce between them.
  • It runs on all 32 threads of an Intel i9-14900K at once.

The largest layer is stored at 24 bits a position instead of 32 so that it fits in memory, which peaked at 22.7 GB. The whole solve took 2 hours 56 minutes, two thirds of it on the five-against-five layer.

11e-21e-41e-61e-8050100sweeps

5v5: 5,047,562,520 positions, 49 sweeps, 1 h 58 min, the largest group, in ink. Pressing solve solves the endgames with up to two pieces a side in your browser and checks them against the database (41 KB).

165,242 positions, 41 KB to check them
Figure 5How the full solve converged, from its logs: the largest change to any value in each sweep, on a log scale, for each of the 15 groups of layers. The largest group, five pieces a side, is in ink. Point at a line to pick out its group. Press solve and your browser solves the three smallest groups the same way, in red: all 165,242 positions with up to two pieces a side. Then it compares its values with the database’s.

The values are computed in floating point. A group’s iteration stops once a sweep changes no value by more than 3×10−7 (or 10−7 for the smaller groups), so every stored value is slightly off. How far off has been measured. An independent solver, written separately in Python with 64-bit numbers, was run until nothing changed by 10−13. It agrees with the database to within 1.5×10−7 on all 3,917,900 positions with up to five pieces in total.

There is no independent check for the larger layers. The project estimates that no value in the table is off by more than about 10−6, but that is an estimate, not a proof. Either way it is far below anything a player could feel. Two moves would have to be within 2×10−6 of each other for the perfect player to choose the worse one.

What the solution says

With perfect play, White wins 50.2504% of games. White throws first, but Black’s pieces start one square further along. Taken together, the two favour White by half a percentage point.

White’s first throw matters more than that. With a 2, the commonest throw, White has only one legal move, 9 to 11, because every other piece would land on one of its own. It is a poor move: White’s chance drops to 48.70%. With a 1, 3 or 5, the best move is always with the back piece, from square 1. It takes the Black piece it lands on and sends it back to square 1. With a 5 that move lifts White to 52.32%.

12345678910111213141516171819202122232425262728293052.3%50.7%50.4%50.2%49.3%
throw5
chance1/16
  1. 1–6, swap 52.32%
  2. 3–8, swap 50.70%
  3. 7–12 50.42%
  4. 9–14 50.20%
  5. 5–10, swap 49.34%
White throws before the throw, White wins 50.25%
Figure 6White’s first move for each throw, from the solved database. Each legal move is labelled with White’s chance of winning after it, and the best one is drawn. Point at another piece, or at its row, to see its move. The dots place each chance on a scale from 48% to 53%, with a tick at 50%.

Two perfect players set against each other for 20,000 games took 198 throws a game on average. In 132 of them the player had a real choice between two or more moves, and 3.5 were forfeited. Of the moves played, 71% were ordinary, 14% were captures, 8% were forced backwards, 2% went into the Water and 4% bore a piece off.

Fortunes swing. In one game in five the eventual winner was at some point below a 25% chance, and in one in forty below 10%. But in none of the 20,000 games did a player come back from below 1%.

Players without the table

The table also measures other players exactly: for every decision a player makes, it says how much chance of winning the move gave away. Figure 7 adds this up over thousands of games for the usual kinds of game program.

A random mover throws away 32% a game. A hand-made evaluation, which weighs the race, protected pieces, the risk of capture and blockades, halves that. Searching one, two or three throws ahead helps. But the 3-throw search still gives away 8% a game, and the perfect player beats it 58% of the time.The head-to-head matches are played in pairs that replay the same throws with the colours swapped, which takes out much of the luck. The margin of error is ±0.3 percentage points for 100,000 games, and ±0.6 for the 25,000 of the slow 3-throw search.

playerwin chance given away per gameperfect wins
Random mover31.7%81.43%
Hand-made evaluation15.1%65.11%
… with a 1-throw search11.0%61.00%
… with a 2-throw search8.9%58.96%
… with a 3-throw search8.0%58.04%
Network0.44%50.49%
… with a 1-throw search0.16%50.07%
Figure 7Win chance given away per game by each player’s decisions, measured with the database over 20,000 games (5,000 for the 3-throw search). On the right, the perfect player’s share of wins against it over 100,000 games (25,000). The red rows are the network of the next section.

A network that fits in a page

The database is the perfect player, but it takes 34 GB. To fit most of its strength into something small, I trained a neural network to imitate it: given a position, predict the database’s value.

The training data was 205 million positions, 71 million of them distinct, each labelled with its value from the database. Most came from games of the perfect player with some random moves mixed in, and 20 million were random positions. The network is a plain multilayer perceptron. Its 72 inputs say which squares each side occupies, how many pieces each has borne off and how far each still has to go. Three hidden layers of 512, 256 and 128 units lead to a single output, with 201,729 numbers in all. Training made twelve passes over the data, at about a minute a pass on one graphics card. On positions that never occur in its training data, its estimates are off by 0.08 percentage points on average.

72inputs
512
256
128
1output

201,729 weights and biases; trained on 205 million positions labelled by the database

average error on held-out records, percentage points
0.000.050.100.1514812
passes over the data · 31 minutes in all
Figure 8The network, with its layers drawn to scale, and the average error of its estimates on held-out records after each pass over the training data, as recorded during training. The training is not repeated here: it ran once, on an RTX 4090.

An error of 0.08 points is small, but what matters is whether the network picks the right moves. Used directly, it chooses the move whose result it rates highest. That gives away 0.44% a game, eighteen times less than the 3-throw search. Letting it look one throw ahead before trusting its estimates brings that down to 0.16%. It then plays a perfect move in 92% of real decisions. Against the perfect player over 100,000 games it scored 49.93%, within the margin of error of an even match.

Figure 9 repeats one of the project’s tests in your browser. It takes 1,631 decisions from games of a perfect player that sometimes explored. None of their positions occurs in the training data. They were chosen so that the hard cases are well represented: moves into the Water, backward moves, blockades, bearing off and near-ties.

123456789101112131415161718192021222324252627282930

Pressing check downloads the network (807 KB) and 1,631 decisions with the database’s value of every move (127 KB), and has the network choose a move in each, alone and with a 1-throw search.

1,631 decisions
Figure 9The network tested in your browser on 1,631 held-out decisions, against the database’s value of every legal move. Press check to run it, alone and with a 1-throw search, in a background worker. Afterwards the slider steps through the decisions from the network’s worst to its best.

Play the network

Figure 10 is the network with its 1-throw search, the strongest player that runs without the database, running entirely in your browser.

123456789101112131415161718192021222324252627282930borne off
throw–
your chance–

Pressing new game downloads the network (807 KB), once.

play against
Figure 10A game against the network. Throw, then press a shaded piece to move it. With hints on, each of your moves is labelled with the network’s estimate of your chance after it. The line under the board is its estimate of White’s chance before every throw.

Checking the answer

A table of eight and a half billion numbers can’t be checked by eye, so it was checked in other ways:

  • Three implementations of the rules agree move for move on a million random positions. One is the solver’s own, in Rust. One was written in Python only from the written rules. The third is the browser engine this page’s code was ported from.
  • The independent Python solver reproduces every value with up to five pieces in total to within 1.5×10−7.
  • The equation was recomputed on 200,000 positions from every layer (every position of the smaller ones), and the table satisfies it to within 1.3×10−7.
  • In 20,000 games between two perfect players, White won 50.18%, within the margin of error of the table’s 50.25%.
  • The network gives the same outputs in Rust, in PyTorch and in the browser, to within a millionth.

Two of these checks run on this page: the endgame solve in Figure 5 and the held-out decisions in Figure 9.

What the table can’t say is whether this is the game the Egyptians played. Senet’s rules are lost, and Kendall’s are a careful guess, so this is the solution of one reconstruction. Others would have their own.

The project

The solver, the network and the checks quoted here are from my senet project. Every number comes from its records: the solve’s logs, its benchmark and strategy reports, and the network’s training log and manifest. The rules, the network’s forward pass and the endgame solver on this page are ports of the project’s code, tested against it before publishing. They make the same moves as its Rust engine on a million positions, give the same network outputs to within 2 × 10−8, and find the same endgame values as its database to within 1.1 × 10−7.

Cite this

@misc{senet,
  author = {Devin O'Keefe},
  title  = {Solving Senet},
  year   = {2026},
  url    = {https://devinokeefe.com/senet/}
}

Changelog

  • 4 Oct 2026First version.

Source: github.com/devinokeefe/senet