Bot programming tutorialsMighty Professional
Build a Legend Bot ยท Machine learning

Neural Networks for Game Bots

To learn a policy, a bot needs a function from what it sees to a move, with thousands of numbers that training can adjust. A neural network is that function, and the work for a game is deciding what goes in and what comes out. This page covers how a network reads a board or a set of units, turns its scores into legal moves, lets several units share one turn, and fits inside a 100,000-character submission. Then it builds the first step of the Phase 5 project: a light-cycle network in PyTorch and the same network in Rust, checked to pick the same moves.

Time~65 minLevelIntermediatePrereqsWhen to Use Machine Learning (weights, training, the project's plan). Neural Networks and Convolutional Networks for depth.StackC++20 & Rust ยท Python & PyTorch ยท browser demos
โ—‚ Build a Legend BotPhase 5 ยท Machine learningNext ยท Behavior Cloning โ–ธ
What you'll be able to do by the end

Read a network as weighted sums and a simple bend, and say what its policy head and value head each output. Turn a board into stacked grids of 0s and 1s, and a unit into a short list of numbers measured from its own point of view. Make illegal moves impossible with a mask, and explain why the mask has to stay on when the bot plays. Have several units decide in order so they stop chasing the same target. Count a network's weights against the 100,000-character limit and its multiply-adds against the turn's clock. Build the project's light-cycle network in PyTorch and Rust and check that both pick the same moves.

01A network is weighted sums and a bend

The evaluation trained in When to Use Machine Learning had three weights, one for each feature it measured: territory, free neighbours and the distance between the heads. A bot that learns its whole policy, its rule for choosing a move in any position, needs thousands of weights, arranged so that together they can say things like "the cell ahead is blocked, but the cell to the right opens into a big region". A neural network is the usual arrangement: a function built from two simple parts used over and over, a weighted sum and a bend that turns negative numbers into zero.

One unit of a network (a neuron) takes a list of numbers, multiplies each by its own weight, adds them up with one more number called the bias, and passes the total through ReLU, which keeps a positive total and turns a negative one into 0. A layer is many units reading the same inputs, each with its own weights, and a second layer reads the first layer's outputs. The bend is what lets a stack of layers express rules with an "if" in them. Without it, two layers of weighted sums would collapse into a single weighted sum. Neural Networks builds this from scratch and trains it; this page is about what a game bot feeds in and reads out.

Worked example

Two inputs: the cell ahead is blocked (1) and the cell to the right is free (0). Hidden unit A has weights โˆ’2 and 1 and bias 0.5: โˆ’2 ร— 1 + 1 ร— 0 + 0.5 = โˆ’1.5, which ReLU turns into 0. Hidden unit B has weights 1 and โˆ’1 and bias 0: 1 ร— 1 โˆ’ 1 ร— 0 = 1.

The output for "go straight" weighs A by 1.5 and B by โˆ’2: 1.5 ร— 0 โˆ’ 2 ร— 1 = โˆ’2, a low score for driving into a wall.

Clear the cell ahead (input 0) and A gives 0.5 while B gives 0, so the score for going straight becomes 1.5 ร— 0.5 = 0.75. Unit A only speaks up when the way ahead is open; ReLU is what lets it stay silent otherwise.

A whole layer does this for every unit at once. Store its weights as a grid with one row per unit, and the layer is a matrix times the input list, the operation the network spends nearly all its time on:

hidden = ReLU(Wโ‚ ยท input + bโ‚)
scores = Wโ‚‚ ยท hidden + bโ‚‚

In words: each hidden unit is one row of Wโ‚ multiplied entry by entry with the input and added up, plus its bias, with a negative result set to 0. Each output score is one row of Wโ‚‚ combined the same way with the hidden values, plus a bias. Training adjusts every entry of Wโ‚, bโ‚, Wโ‚‚ and bโ‚‚.

The output scores are called logits: one per move, any size, positive or negative. The next section turns them into probabilities.

The widget runs a small trained network one decision at a time. It plays the purple cycle in a light-cycle game against the flood-fill bot from Evaluation Functions, which moves wherever the most cells stay reachable. Its input is the 9 ร— 9 square around its head, turned so the cycle always faces up: 81 numbers, 1 for a blocked cell and 0 for a free one. Turning the view lets one set of weights serve every heading. It has 16 hidden units and 3 outputs (straight, right, left), so 81 ร— 16 + 16 + 16 ร— 3 + 3 = 1,363 weights and biases. It was trained to copy the flood-fill bot with the method Behavior Cloning teaches. It's a demo network, much smaller than the project's in section 10.

Follow one decision through the network

Press Step to move through the purple cycle's decisions, or Play to run through them all. The dashed square on the board is what the network reads. On the right, the 81 inputs feed 16 hidden units: a unit lights up green when its weighted sum is positive and stays dark when ReLU turns it to 0. Lines show the largest contributions, amber for one that pushes a value up and blue for one that pushes it down. The three bars are the move probabilities, with each move's logit beside its bar; a cross marks a move into a wall. In this game the network makes 48 decisions, and every one matches the move the flood-fill bot would make.

02Two heads: which move, and who's winning

A bot asks its network two different questions. When it plays: which move? When it trains by self-play, also: how is this game going for me? One network answers both. Its shared layers, the trunk, read the position; then it splits into two small output parts, each called a head.

The head outputs one logit per move, and a softmax turns the logits into probabilities. It raises e (about 2.718) to the power of each logit, which makes every number positive and keeps their order, then divides each by the total so they add up to 1.

Worked example

Logits for straight, right and left: 2, 1 and 0.5.

eยฒ = 7.39, eยน = 2.72, e0.5 = 1.65, and the total is 11.76.

Probabilities: 7.39 / 11.76 = 0.629 for straight, 2.72 / 11.76 = 0.231 for right, 1.65 / 11.76 = 0.140 for left. Add 10 to every logit and the probabilities stay the same: only the gaps between logits count.

As a formula, the softmax gives each move a share of the total that grows with its logit:

pk = ezk / ฮฃj ezj

In words: a move's probability is e raised to its logit, divided by the same thing added up over all the moves. A logit one higher than another makes that move e โ‰ˆ 2.7 times as likely.

Dividing every logit by a number T before the softmax, the temperature, controls how sharp the probabilities are: below 1 the top move takes more, above 1 they flatten toward even. To play, a bot either takes the most likely move, called greedy play, or draws a move at random in proportion to the probabilities. Self-play training draws, so the bot keeps trying moves it rates a little lower. Self-Play Reinforcement Learning covers when drawing pays at play time too.

The head outputs one number: the network's guess at how the game will end for the player to move, squashed by tanh into the range from โˆ’1 (a sure loss) to +1 (a sure win). Self-play training uses it to judge whether a move turned out better or worse than expected, and a search can use it to score positions it can't play out (Networks Inside Search). The bot doesn't need it to choose a move. The project's network spends 577 of its 11,618 weights (counting biases, as this page does throughout) on the value head, and the bot that ships leaves them out.

Both heads read the same trunk, so features learned for one job are there for the other, and the trunk's weights are stored once in the file.

03A board as stacked grids

A Tron Battle board is 30 cells wide and 20 high.[1] A network needs numbers, and the most direct way to give it a board is one grid per fact: a grid with a 1 wherever your trail is and 0 elsewhere, another for your opponent's trail, another with a single 1 at your head, and so on. Each grid is a , and the stack of planes is the network's input.

A common question when you first read trainer code: what are all these tensor expressions? A tensor is an array with any number of axes. A list of numbers has one axis, a grid two, a stack of planes three, and a batch of 512 stacks four, with shape 512 ร— 8 ร— 20 ร— 30. Tensor code applies one operation to the whole array at once, which is what lets PyTorch hand it to a GPU. When the project's model writes hidden.mean((2, 3)), it averages each channel over axes 2 and 3, the board's rows and columns, turning 16 grids into 16 numbers.

The widget encodes a recorded game between two flood-fill bots on a smaller 15 ร— 10 board, with seven planes. A network's planes describe the board from the side of the player about to move: "own" means the mover's and "enemy" the other player's. The widget's "Seen by" control picks the side. The Voronoi planes mark the free cells each player reaches first, as in Evaluation Functions.

A board as stacked grids

Press Play to step through the game, or Step for one move at a time. Each small grid is one plane, coloured wherever its fact holds. Untick planes and the tensor shrinks; switch "Seen by" and the own and enemy planes swap places, because the network reads the board from the side of the bot about to move. At the defaults the stack is 7 ร— 10 ร— 15 = 1,050 numbers, 4,200 bytes as 32-bit floats. Every value is 0 or 1, so packed as bits the same stack fits in 132 bytes.

Reading the board from the mover's side means one set of weights plays both seats. The project's network uses eight planes on the full 30 ร— 20 board. It drops the widget's legal-moves plane, since section 05 handles legality with a mask, and adds two:

Plane1 onWhy it's there
boardevery cellThe convolutions below pad the edge with zeros, so a plane of ones lets them tell the edge from an empty cell. KataGo's first input plane is the same idea: "Location is on board".[2]
own trail, enemy trailyour cells (head included); every opponent's cellsWhere you can't go.
own head, enemy headyour head; each opponent's headWhere each move starts.
own Voronoi, enemy Voronoifree cells you reach strictly first; free cells an opponent reaches strictly firstTerritory, the evaluation's main feature.
own reachablefree cells you can still reach at allA region you've been cut off from is missing from it.

Worked example

The project's input: 8 planes ร— 20 rows ร— 30 columns = 4,800 numbers per position.

As 32-bit floats that's 4,800 ร— 4 = 19,200 bytes. Every value is 0 or 1, so 8 values fit in a byte: 4,800 / 8 = 600 bytes.

A training batch of 512 positions takes 512 ร— 19,200 = 9.8 MB as floats and 512 ร— 600 = 307 KB packed.

The layer from section 01 gives every input its own weight, so a pattern it learns in one corner of the board teaches it nothing about the opposite corner. A convolution slides one small grid of weights over every cell instead. The grid, a 3 ร— 3 kernel, covers the cell and its eight neighbours in every input plane, so each output cell is the weighted sum of the 3 ร— 3 patch around it. The same 8 ร— 3 ร— 3 = 72 weights read every patch, so a pattern learned anywhere is recognized everywhere. Each kernel produces one output grid, a channel; the project's first layer has 16 kernels, so it outputs 16 channels, from 16 ร— 72 + 16 = 1,168 weights. Convolutional Networks covers padding and the backward pass.

One 3 ร— 3 layer sees one cell in each direction. Stacking layers widens the view, and dilation widens it faster: a kernel with dilation d reads the cells d apart instead of the direct neighbours. The project's trunk stacks a plain first layer and four residual layers, each adding a correction to what came before, with dilations 1, 2, 4 and 8.

Worked example

A 3 ร— 3 layer with dilation d reaches d cells further in each direction. The five layers have dilations 1, 1, 2, 4 and 8, so together they reach 1 + 1 + 2 + 4 + 8 = 16 cells in each direction.

Each output cell can depend on a window 16 + 1 + 16 = 33 cells across, centred on it. From the middle of the board that spans all 30 columns. With five plain layers (all d = 1) it would be 11 cells across.

The reach of a stack of 3 ร— 3 layers, in cells across, is:

reach = 1 + 2 ร— (d1 + d2 + โ€ฆ + dn)

In words: every 3 ร— 3 layer extends the window by its dilation on both sides, so the window grows by twice the sum of the dilations around the single centre cell.

Some facts concern the whole board at once, such as who holds more territory. KataGo, David Wu's Go program, adds global pooling layers for this. In Wu's words, they let the convolutions "condition on global context, which would be hard or impossible with the limited perceptual radius of convolution alone", and he notes that the general idea isn't new, citing earlier work on image classification.[2] The project uses the simplest version: average each of the 16 channels over the board, pass those 16 numbers through a 16 ร— 16 layer, and add the result to every cell, 272 weights in all. KataGo's layer also takes each channel's maximum. Pduhard, 3rd in Back Track King (Summer Challenge 2026), used the mean-only version with 32 channels and counted 1,056 weights for it.[3]

04Units as short lists of numbers

Planes suit games played on a grid. Mad Pod Racing has no grid: a pod is a position, a speed and an angle, and the checkpoints sit at arbitrary coordinates. The natural input there is one short list of numbers per unit, often called a token.

Raw map coordinates make a network learn the same lesson once for every spot on the map: "the checkpoint is straight ahead" looks different at (2000, 3000) and at (12000, 7000). Measure from the unit instead, in its own frame: how far ahead is the checkpoint, and how far to the side? pb4 and Agade, whose network took the top of the Coders Strike Back leaderboard in 2019 (the game is now Mad Pod Racing), suggest a first runner network whose whole input is fewer than a dozen numbers: "the position of the next 2 checkpoints relative to the pod, its current speed and its current angle".[4][5]

Worked example

Pod A is at (5000, 4000), heading straight down the screen, and its checkpoint is at (5000, 7000). Pod B is at (10000, 1000), heading right, and its checkpoint is at (13000, 1000).

In map coordinates the two situations share no numbers. Measured from each pod, both checkpoints are 3,000 units straight ahead and 0 to the side, so the network sees the same input twice, and what it learns in one place applies in the other.

The widget in section 01 does the same on a grid: its 9 ร— 9 view is turned to face the cycle's heading, so "wall ahead" is the same input whichever way the cycle is going.

Units also come in varying numbers: three trolls this turn and five the next, a handful of monsters in range. A fixed set of outputs, one per possible choice, can't follow that. Instead, describe each candidate with its own list of numbers, score every list with the same small network, and take a softmax over the scores. The output then points at one of the inputs, however many there are. Vinyals, Fortunato and Jaitly named their version of this a Pointer Network, after an output that "uses attention as a pointer to select a member of the input sequence".[6]

delineate, who won Spring Challenge 2026 (Troll Farm) with a network trained by reinforcement learning, picked the next troll to build this way: the network "produces a logit for each troll type by concatenating some board state, global state, and the specific troll target's attributes through a shared MLP". He tried a flat output of 144 logits first, one per troll type, and found it less able to generalize "between similar trolls".[7] The project's move head works the same way. Each of the four moves is a candidate, described by the 16 numbers the trunk computed at the cell the move enters plus two numbers about the board after the move, and one small network scores all four.

When units have to react to each other, attention lets each unit's list take in the others', weighted by relevance (Transformers covers it). A single light cycle has no other units of its own to coordinate with.

05Never pick an illegal move

A network outputs a logit for every move, whether the rules allow it or not. A light cycle that turns into a wall crashes, and in a one-against-one game a crash is a loss. The fix is a : before the softmax, set every illegal move's logit to minus infinity. eโˆ’โˆž is 0, so an illegal move's probability comes out exactly 0, and the legal moves share all of it in the same ratios as before.

Worked example

The logits from section 02 again, 2, 1 and 0.5, but now the way straight ahead is blocked. Without a mask, straight gets 0.629: a bot that draws its move crashes about five times in eight, and a greedy bot every time.

With the mask, straight's logit is โˆ’โˆž, so the softmax adds up only eยน + e0.5 = 2.72 + 1.65 = 4.37. Right gets 2.72 / 4.37 = 0.622 and left 1.65 / 4.37 = 0.378.

The ratio between them hasn't changed: 0.231 / 0.140 and 0.622 / 0.378 are both 1.65.

Written with a mask entry mk that's 1 for a legal move and 0 for an illegal one:

pk = mk ezk / ฮฃj mj ezj

In words: an illegal move contributes nothing to the total and gets probability 0, and each legal move gets its usual share of a total that only counts legal moves. Setting zk to โˆ’โˆž computes exactly this.

The widget uses the trained network from section 01 on 16 positions from its games where the way straight ahead is blocked, and compares its probabilities with and without the mask.

Never pick an illegal move

Press Step to go through 16 positions where the purple cycle can't go straight. The left panel marks each candidate cell, a cross for illegal and a ring for legal. The middle bars compare the move probabilities without the mask (pink) and with it (green); the right bars count 1,000 moves drawn from each. Without the mask, the trained network puts 83.7% of its probability on blocked moves, averaged over the 16 positions, and 854 of the 1,000 unmasked draws in the first position crash. With the mask, no draw crashes. Switch to the untrained network, the same weights before training, and blocked moves get 43.0% on average. Raise the temperature and the probabilities flatten toward even.

Why does a trained network still favour the walls? It learned with the mask on. The mask took illegal moves out of every training example's loss, so nothing ever pushed their logits down; to the training, they didn't exist. The project's network shows the same thing at full size: with the mask removed, the trained clone's favourite move would have been illegal in 47% of its self-play decisions (15,590 of 33,266).[8] A network trained with a mask has to play with it. delineate's Troll Farm network masked the same way: in the troll action phase, "only the possible valid moves for that troll are allowed".[7]

06Several units, one turn

Many CodinGame games give you several units a turn: three heroes in Spring Challenge 2022, a team of trolls in Troll Farm, three track placements and a disruption in Back Track King. The obvious design runs the network once and gives each unit its own probabilities, and each unit draws its own action. That works until two units want the same thing.

Worked example

Two heroes, two monsters. Each hero gives monster A probability 0.7 and monster B 0.3, and each draws on its own.

Both pick A with chance 0.7 ร— 0.7 = 0.49, and both pick B with chance 0.3 ร— 0.3 = 0.09. They chase the same monster 58% of the time.

Flatter preferences don't save you: three units choosing evenly among four targets all pick different ones with chance (4 ร— 3 ร— 2) / 4ยณ = 0.375, so they collide 62.5% of the time.

For two units, the chance of a collision adds up, over every target, the chance that both pick it:

P(collision) = ฮฃt p1(t) ร— p2(t)

In words: two independent draws land on the same target with the product of their chances for it, summed over the targets. The more the two units like the same target, the bigger the sum.

The fix is to decide in order. The first unit picks. Then the network runs again, or a small head runs, with the first pick added to its input, and the second unit chooses knowing what's taken; and so on down the list. A later unit can avoid a target that's already covered, or join it on purpose when two units on one target is the right play.

The Back Track King network in the author's lab decided a turn in four slots, three placements and then the disruption, and each later slot saw the placements already chosen that turn.[9] delineate's Troll Farm network got the same coordination another way. He trained it with every game turn split into steps, one for the build plan and then one per troll, with an input plane marking which troll was choosing. At play time he ran one network pass for the plan and one per troll, then a beam search over combinations of their top moves to find the most likely set of moves for all trolls together: "pick a troll, try each of its top X moves, use those moves to invalidate some other troll moves, pick the best moves for the next troll".[7]

Three units, one turn

Three units each want one target, and nearer targets get more probability; the faint lines show each unit's preferences. Press Play to draw one turn per step. In the first map the units decide independently, as with one output per unit, and a pink ring marks a target picked twice. In the second map they decide in order and each skips the targets already taken, so no two ever share one. The bars give the exact chance of a collision: 68.6% at the defaults when the units decide alone. Drag "How peaked" to 0, no preferences at all, and it's 62.5%; drag it to 6 and it climbs to 99.2%, because all three units like the same nearest target.

From the author's lab

In Spring Challenge 2022, played as a permanent puzzle, the lab's network chose each of its three heroes' actions independently. On October 26, 2026 it was 1st in Silver; in the 52 of its 133 ranked battles that it lost, there were 254 turns on which two of its heroes cast CONTROL, the spell that steers a monster, on the same monster.[9]

07Fit the network in 100,000 characters

Everything you submit to CodinGame is one source file, and "The code size for any game is limited to 100k characters."[10] The weights travel inside that file, written out as text, and the network runs inside the turn's time limit, under 100 ms in Tron Battle.[1] So two counts size a network: its weights against the file, and its multiply-adds against the clock.

Each weight is stored with some number of bits, and each character of the file carries some bits of it. Plain text with a 64-symbol alphabet, as in base64, carries 6 bits a character.

Worked example

The project's shipped network has 11,041 weights. At 8 bits each that's 88,328 bits, and at 6 bits a character, 88,328 / 6 = 14,722 characters: under a sixth of the limit.

A network ten times bigger, 110,000 weights, needs 110,000 ร— 8 / 6 = 146,667 characters at the same rates. It doesn't fit before a single line of code.

characters โ‰ˆ weights ร— bits per weight / bits per character + code

In words: the file holds every weight's bits, spread over characters that each carry a few bits, plus the bot's code. Fewer bits per weight, compression and more bits per character all make room; Shipping a Network covers each one.

Winning bots have come within 2% of the limit. reCurse's Fall Challenge 2023 bot used 99,264 characters, 65,714 of them for the network's weights, stored at 8 bits each except the first layer's at 16.[11] delineate's Troll Farm network had about 101,000 parameters, not counting its value heads, in a 98,000-character file.[7]

Time is a different count. A convolution weight is used once at every cell of the board, and a dense weight once per decision. The project's first layer has 1,152 weights besides its biases and applies them at all 600 cells: 691,200 multiply-adds. The four residual layers add 5,529,600 more. The move head's 321 weights run once per legal move. In all it's about 6.2 million multiply-adds a decision, almost all in the convolutions, from a network whose file cost is set by just 11,041 weights. (The Rust loop skips the taps that fall off the board, which leaves about 5.1 million.) In a release build on the author's desktop (an Intel i9-11900KF, busy with other jobs at the time), a typical decision, the features plus the forward pass, takes 1 to 2 ms: far inside the 100 ms. That's a typical turn; the slowest turn is the one the limit judges, and CodinGame's machine may be slower than yours (one loop in Fast Bots ran about 3.3 times slower there, on one day), so Shipping a Network measures the exported bot's slowest turn and shows how to check it on CodinGame. delineate reports using "only around 2-3ms out of the 50ms budget each turn".[7]

08What goes in decides what the network can do

A common question when you design a learned bot: what do you pass the network as options? All of the game's data, or the moves you already think are good?

From the author's lab

In Back Track King, the lab's early learners took the second route. The hand-written contest bot proposed a shortlist of candidate plans every turn, and the learner that looked best, a Q-learner with about 17,700 parameters (October 13, 2026), only re-ranked that shortlist. It could never play a move the old bot hadn't proposed. The rebuild handed the network the whole board instead: 62 planes of facts per cell plus 12 numbers about the game, read by a policy of 104,820 weights that made every one of the turn's decisions itself. That network reached Legend on the permanent ladder after the contest.[9]

The whole state isn't the full answer either. The project's first network got exactly what this page has described up to section 03: the eight planes and nothing else, with each move's logit read from a per-cell score at the cell it enters. Both it and the final design were trained the same way, to copy the Voronoi bot from Evaluation Functions (the one-ply bot that picks the move with the best territory margin, with room breaking ties) on the same 3,000 recorded games. Behavior Cloning covers how. Each match is 400 games, with a 95% interval on the share won:[8]

Planes onlyPlanes + room and margin per move
Weights11,31411,618
Agreement with the teacher, held-out decisions94.82%97.85%
Agreement on contested decisions88.89%96.02%
Against the teacher.235 [.196, .279].490 [.441, .539]
Against the flood-fill bot.608 [.559, .654].958 [.933, .973]
Against the alpha-beta bot.145 [.114, .183].300 [.257, .347]

A contested decision is one where the teacher's scores for the legal moves differ, so the choice matters by the teacher's own measure; in the rest, the teacher's tie-break decides. The planes-only network agreed with its teacher on 94.8% of held-out decisions and still lost three games in four to it. Its misses were rare and decisive. On the 2.1% of decisions where the legal moves leave different amounts of room, it agreed with the teacher only 81.9% of the time, and in 16.5% of those it chose a move with less room than the best one. With about three such decisions a game, it lost 157 of 400 games to the flood-fill bot, which does nothing but count room. Telling which side of a narrow passage holds more free cells takes a flood fill from each side. Convolutions pass information a fixed number of cells and global pooling averages the whole board, so the network could only approximate it. Doubling the width to 32 channels (41,506 weights) moved contested agreement only from 88.9% to 90.1%.[8]

The fix was two numbers per move, computed by ordinary code: the room left after the move (free cells still reachable) and the Voronoi margin after it (cells you reach first minus cells the opponent reaches first), each divided by 600. They are the two numbers the teacher ranks moves by, so the network can copy it exactly where it counts and learn the rest from the planes. With them the clone plays level with its teacher: .490, an interval that contains .5. KataGo makes the same trade in Go. Beside the stones, it feeds its network a few computed features, liberties and ladders among them, and Wu found that "adding some game-specific input features still significantly improves learning".[2] The author's lab feeds its own Tron Battle network computed chamber and parity features for the same reason.[9]

So the answer to the question: give the network the whole state, so your old bot's shortlist can't cap it; add the few numbers it can't work out for itself, such as counts over regions; and let the network make the decision. Agreement with a teacher can't tell you whether the inputs are right; play the network against the teacher and read the result.

09A masked policy network in code

These complete programs build the network from section 01 at full size, 81 inputs, 16 hidden units and 3 moves, with the mask from section 05. Its weights are untrained, drawn in the range PyTorch's nn.Linear uses by default from the same seeded generator as the logistic regression in When to Use Machine Learning, so all three versions hold identical numbers. Each program makes up 1,000 positions (every cell of the view blocked with chance 0.3), skips the 20 where no move is legal, and checks the mask on the other 980: no illegal pick, illegal moves at exactly 0, probabilities adding up to 1. The C++ and Rust programs also check the worked example from section 05 (0.622 and 0.378), and all three count how often this untrained network's favourite move would be illegal without the mask: 256 times.

The C++ and Rust versions compute bit-identical logits. The Python version builds the same network from PyTorch's layers and runs all 980 positions as one batch. Given a compiled program's path, it also runs that program and compares: 0 different moves and a largest logit difference of 1.2 ร— 10โˆ’7. PyTorch adds the products in a different order, so the last bits can differ, and the check would accept a different move only on a near-tie between the two best moves. The closest call in these positions is 9.2 ร— 10โˆ’4, far from a tie.

A masked policy network, complete programs
// A light-cycle policy network with a legality mask, complete. It reads the
// 9 x 9 square around the head, turned so the cycle faces up (81 inputs, 1 for a
// blocked cell), has 16 hidden units, and gives one logit per move. The weights
// are untrained, drawn from a seeded generator, so the C++, Rust and PyTorch
// versions hold exactly the same numbers.
//   ./policy            check the network on 1,000 positions and print a summary
//   ./policy --logits   also print every position's move and logits (parity.py)
#include <array>
#include <cassert>
#include <cmath>
#include <cstdint>
#include <cstdio>
#include <cstring>
#include <limits>
#include <vector>

constexpr int kInputs = 81, kHidden = 16, kMoves = 3;
// The view cells next to the head (cell 40): ahead, to the right, to the left.
constexpr std::array<int, kMoves> kMoveCells{31, 41, 39};

using View = std::array<float, kInputs>;
using Logits = std::array<float, kMoves>;
using Legal = std::array<bool, kMoves>;

// SplitMix64: a small seeded generator (the Rust and Python versions use the same).
struct SplitMix64 {
    std::uint64_t state;
    std::uint64_t next() {
        state += 0x9E3779B97F4A7C15ull;
        std::uint64_t mixed = state;
        mixed = (mixed ^ (mixed >> 30)) * 0xBF58476D1CE4E5B9ull;
        mixed = (mixed ^ (mixed >> 27)) * 0x94D049BB133111EBull;
        return mixed ^ (mixed >> 31);
    }
    double uniform() { return static_cast<double>(next() >> 11) * 0x1.0p-53; }  // [0, 1)
};

struct Network {
    std::vector<float> hiddenWeights, hiddenBiases;  // [kHidden][kInputs], [kHidden]
    std::vector<float> outputWeights, outputBiases;  // [kMoves][kHidden], [kMoves]
};

// Starting values in the range PyTorch's nn.Linear uses: uniform between
// -1/sqrt(inputs) and +1/sqrt(inputs), computed in double, stored as float32.
std::vector<float> fill(SplitMix64& random, int count, int inputs) {
    const double bound = 1.0 / std::sqrt(static_cast<double>(inputs));
    std::vector<float> values(count);
    for (float& value : values) value = static_cast<float>((random.uniform() * 2.0 - 1.0) * bound);
    return values;
}

Network makeNetwork(SplitMix64& random) {  // the order PyTorch stores them in
    Network network;
    network.hiddenWeights = fill(random, kHidden * kInputs, kInputs);
    network.hiddenBiases = fill(random, kHidden, kInputs);
    network.outputWeights = fill(random, kMoves * kHidden, kHidden);
    network.outputBiases = fill(random, kMoves, kHidden);
    return network;
}

// One weighted sum per hidden unit, kept only if positive (ReLU), then one
// weighted sum of the hidden values per move. Float32, like the bot that ships.
Logits forward(const Network& network, const View& view) {
    std::array<float, kHidden> hidden{};
    for (int unit = 0; unit < kHidden; ++unit) {
        float sum = network.hiddenBiases[unit];
        for (int i = 0; i < kInputs; ++i)
            sum += network.hiddenWeights[unit * kInputs + i] * view[i];
        hidden[unit] = sum > 0.0f ? sum : 0.0f;
    }
    Logits logits{};
    for (int choice = 0; choice < kMoves; ++choice) {
        float sum = network.outputBiases[choice];
        for (int unit = 0; unit < kHidden; ++unit)
            sum += network.outputWeights[choice * kHidden + unit] * hidden[unit];
        logits[choice] = sum;
    }
    return logits;
}

// Softmax over the legal moves only: an illegal move gets exactly zero and the
// legal ones keep their ratios. Subtracting the largest logit keeps exp() small.
Logits maskedSoftmax(const Logits& logits, const Legal& legal) {
    float largest = -std::numeric_limits<float>::infinity();
    for (int choice = 0; choice < kMoves; ++choice)
        if (legal[choice] && logits[choice] > largest) largest = logits[choice];
    Logits probabilities{};
    float total = 0.0f;
    for (int choice = 0; choice < kMoves; ++choice) {
        probabilities[choice] = legal[choice] ? std::exp(logits[choice] - largest) : 0.0f;
        total += probabilities[choice];
    }
    for (float& probability : probabilities) probability /= total;
    return probabilities;
}

// The most likely legal move (the first one on a tie), or -1 if none is legal.
int greedy(const Logits& logits, const Legal& legal) {
    int best = -1;
    for (int choice = 0; choice < kMoves; ++choice)
        if (legal[choice] && (best < 0 || logits[choice] > logits[best])) best = choice;
    return best;
}

int main(int argc, char** argv) {
    const bool printLogits = argc > 1 && std::strcmp(argv[1], "--logits") == 0;
    // The page's worked example: logits 2, 1 and 0.5 with straight blocked.
    const Logits example = maskedSoftmax({2.0f, 1.0f, 0.5f}, {false, true, true});
    assert(example[0] == 0.0f && std::fabs(example[1] - 0.62246f) < 1e-5f);
    assert(std::fabs(example[2] - 0.37754f) < 1e-5f);

    SplitMix64 random{2026};
    const Network network = makeNetwork(random);
    int positions = 0, noLegalMove = 0, unmaskedIllegal = 0;
    for (int index = 0; index < 1000; ++index) {
        // A made-up position: each cell blocked with chance 0.3; the head (40) and
        // the cell it came from (49, straight behind) are always trail.
        View view{};
        for (float& cell : view) cell = random.uniform() < 0.3 ? 1.0f : 0.0f;
        view[40] = view[49] = 1.0f;
        Legal legal{};
        for (int choice = 0; choice < kMoves; ++choice) legal[choice] = view[kMoveCells[choice]] == 0.0f;
        if (!legal[0] && !legal[1] && !legal[2]) { ++noLegalMove; continue; }  // crashes anyway
        const Logits logits = forward(network, view);
        const int chosen = greedy(logits, legal);
        assert(chosen >= 0 && legal[chosen]);  // with the mask, an illegal pick can't happen
        const Logits probabilities = maskedSoftmax(logits, legal);
        float total = 0.0f;
        for (int choice = 0; choice < kMoves; ++choice) {
            assert(legal[choice] || probabilities[choice] == 0.0f);
            total += probabilities[choice];
        }
        assert(std::fabs(total - 1.0f) < 1e-6f);
        // Without the mask the network's favourite is just its largest logit.
        if (!legal[greedy(logits, {true, true, true})]) ++unmaskedIllegal;
        ++positions;
        if (printLogits) std::printf("%d %.9g %.9g %.9g\n", chosen, logits[0], logits[1], logits[2]);
    }
    std::printf("# %d positions (%d with no legal move skipped); illegal picks 0; "
                "favourite illegal without the mask: %d\n", positions, noLegalMove, unmaskedIllegal);
}
// A light-cycle policy network with a legality mask, complete. It reads the
// 9 x 9 square around the head, turned so the cycle faces up (81 inputs, 1 for a
// blocked cell), has 16 hidden units, and gives one logit per move. The weights
// are untrained, drawn from a seeded generator, so the C++, Rust and PyTorch
// versions hold exactly the same numbers.
//   ./policy            check the network on 1,000 positions and print a summary
//   ./policy --logits   also print every position's move and logits (parity.py)
const INPUTS: usize = 81;
const HIDDEN: usize = 16;
const MOVES: usize = 3;
// The view cells next to the head (cell 40): ahead, to the right, to the left.
const MOVE_CELLS: [usize; MOVES] = [31, 41, 39];

type View = [f32; INPUTS];
type Logits = [f32; MOVES];
type Legal = [bool; MOVES];

// SplitMix64: a small seeded generator (the C++ and Python versions use the same).
struct SplitMix64 {
    state: u64,
}

impl SplitMix64 {
    fn next(&mut self) -> u64 {
        self.state = self.state.wrapping_add(0x9E37_79B9_7F4A_7C15);
        let mut mixed = self.state;
        mixed = (mixed ^ (mixed >> 30)).wrapping_mul(0xBF58_476D_1CE4_E5B9);
        mixed = (mixed ^ (mixed >> 27)).wrapping_mul(0x94D0_49BB_1331_11EB);
        mixed ^ (mixed >> 31)
    }
    fn uniform(&mut self) -> f64 {
        (self.next() >> 11) as f64 / (1u64 << 53) as f64 // [0, 1)
    }
}

struct Network {
    hidden_weights: Vec<f32>, // [HIDDEN][INPUTS]
    hidden_biases: Vec<f32>,  // [HIDDEN]
    output_weights: Vec<f32>, // [MOVES][HIDDEN]
    output_biases: Vec<f32>,  // [MOVES]
}

// Starting values in the range PyTorch's nn.Linear uses: uniform between
// -1/sqrt(inputs) and +1/sqrt(inputs), computed in f64, stored as f32.
fn fill(random: &mut SplitMix64, count: usize, inputs: usize) -> Vec<f32> {
    let bound = 1.0 / (inputs as f64).sqrt();
    (0..count).map(|_| ((random.uniform() * 2.0 - 1.0) * bound) as f32).collect()
}

fn make_network(random: &mut SplitMix64) -> Network {
    // The order PyTorch stores them in.
    Network {
        hidden_weights: fill(random, HIDDEN * INPUTS, INPUTS),
        hidden_biases: fill(random, HIDDEN, INPUTS),
        output_weights: fill(random, MOVES * HIDDEN, HIDDEN),
        output_biases: fill(random, MOVES, HIDDEN),
    }
}

// One weighted sum per hidden unit, kept only if positive (ReLU), then one
// weighted sum of the hidden values per move. Float32, like the bot that ships.
fn forward(network: &Network, view: &View) -> Logits {
    let mut hidden = [0f32; HIDDEN];
    for unit in 0..HIDDEN {
        let mut sum = network.hidden_biases[unit];
        for i in 0..INPUTS {
            sum += network.hidden_weights[unit * INPUTS + i] * view[i];
        }
        hidden[unit] = if sum > 0.0 { sum } else { 0.0 };
    }
    let mut logits = [0f32; MOVES];
    for choice in 0..MOVES {
        let mut sum = network.output_biases[choice];
        for unit in 0..HIDDEN {
            sum += network.output_weights[choice * HIDDEN + unit] * hidden[unit];
        }
        logits[choice] = sum;
    }
    logits
}

// Softmax over the legal moves only: an illegal move gets exactly zero and the
// legal ones keep their ratios. Subtracting the largest logit keeps exp() small.
fn masked_softmax(logits: &Logits, legal: &Legal) -> Logits {
    let mut largest = f32::NEG_INFINITY;
    for choice in 0..MOVES {
        if legal[choice] && logits[choice] > largest {
            largest = logits[choice];
        }
    }
    let mut probabilities = [0f32; MOVES];
    let mut total = 0f32;
    for choice in 0..MOVES {
        probabilities[choice] = if legal[choice] { (logits[choice] - largest).exp() } else { 0.0 };
        total += probabilities[choice];
    }
    for probability in probabilities.iter_mut() {
        *probability /= total;
    }
    probabilities
}

// The most likely legal move (the first one on a tie), or None if none is legal.
fn greedy(logits: &Logits, legal: &Legal) -> Option<usize> {
    let mut best: Option<usize> = None;
    for choice in 0..MOVES {
        if legal[choice] && best.map_or(true, |chosen| logits[choice] > logits[chosen]) {
            best = Some(choice);
        }
    }
    best
}

fn main() {
    let print_logits = std::env::args().nth(1).as_deref() == Some("--logits");
    // The page's worked example: logits 2, 1 and 0.5 with straight blocked.
    let example = masked_softmax(&[2.0, 1.0, 0.5], &[false, true, true]);
    assert!(example[0] == 0.0 && (example[1] - 0.62246).abs() < 1e-5);
    assert!((example[2] - 0.37754).abs() < 1e-5);

    let mut random = SplitMix64 { state: 2026 };
    let network = make_network(&mut random);
    let (mut positions, mut no_legal_move, mut unmasked_illegal) = (0, 0, 0);
    for _ in 0..1000 {
        // A made-up position: each cell blocked with chance 0.3; the head (40) and
        // the cell it came from (49, straight behind) are always trail.
        let mut view = [0f32; INPUTS];
        for cell in view.iter_mut() {
            *cell = if random.uniform() < 0.3 { 1.0 } else { 0.0 };
        }
        view[40] = 1.0;
        view[49] = 1.0;
        let legal: Legal = MOVE_CELLS.map(|cell| view[cell] == 0.0);
        if !legal.iter().any(|&open| open) {
            no_legal_move += 1; // crashes anyway
            continue;
        }
        let logits = forward(&network, &view);
        // With the mask, an illegal pick can't happen.
        let chosen = greedy(&logits, &legal).expect("a legal move exists");
        assert!(legal[chosen]);
        let probabilities = masked_softmax(&logits, &legal);
        for choice in 0..MOVES {
            assert!(legal[choice] || probabilities[choice] == 0.0);
        }
        assert!((probabilities.iter().sum::<f32>() - 1.0).abs() < 1e-6);
        // Without the mask the network's favourite is just its largest logit.
        let favourite = greedy(&logits, &[true; MOVES]).expect("three moves");
        if !legal[favourite] {
            unmasked_illegal += 1;
        }
        positions += 1;
        if print_logits {
            println!("{} {} {} {}", chosen, logits[0], logits[1], logits[2]);
        }
    }
    println!(
        "# {} positions ({} with no legal move skipped); illegal picks 0; \
         favourite illegal without the mask: {}",
        positions, no_legal_move, unmasked_illegal
    );
}
# The same masked policy network in PyTorch, with a parity check against the
# C++ or Rust program built from the other tabs:
#   python policy.py             check the network and print a summary
#   python policy.py ./policy    also compare every move and logit with ./policy
import math
import subprocess
import sys

import torch
from torch import nn

INPUTS, HIDDEN, MOVES = 81, 16, 3
MOVE_CELLS = [31, 41, 39]  # ahead, right and left of the head (cell 40)
MASK64 = (1 << 64) - 1


class SplitMix64:
    """The same seeded generator as the C++ and Rust versions, so the same numbers."""

    def __init__(self, seed):
        self.state = seed

    def next(self):
        self.state = (self.state + 0x9E3779B97F4A7C15) & MASK64
        mixed = self.state
        mixed = ((mixed ^ (mixed >> 30)) * 0xBF58476D1CE4E5B9) & MASK64
        mixed = ((mixed ^ (mixed >> 27)) * 0x94D049BB133111EB) & MASK64
        return mixed ^ (mixed >> 31)

    def uniform(self):
        return (self.next() >> 11) / float(1 << 53)  # [0, 1)


def fill(random, count, inputs):
    """Uniform between -1/sqrt(inputs) and +1/sqrt(inputs), as in the other tabs."""
    bound = 1.0 / math.sqrt(inputs)
    return [(random.uniform() * 2.0 - 1.0) * bound for _ in range(count)]


def make_network(random):
    network = nn.Sequential(nn.Linear(INPUTS, HIDDEN), nn.ReLU(), nn.Linear(HIDDEN, MOVES))
    with torch.no_grad():  # overwrite PyTorch's own random start with the shared one
        for layer in (network[0], network[2]):
            for tensor in (layer.weight, layer.bias):
                values = fill(random, tensor.numel(), layer.in_features)
                tensor.copy_(torch.tensor(values).view_as(tensor))  # rounded to float32
    return network


random = SplitMix64(2026)
network = make_network(random)
views, legal, no_legal_move = [], [], 0
for _ in range(1000):
    # The same made-up positions: cells blocked with chance 0.3, head and tail cell set.
    view = [1.0 if random.uniform() < 0.3 else 0.0 for _ in range(INPUTS)]
    view[40] = view[49] = 1.0
    open_moves = [view[cell] == 0.0 for cell in MOVE_CELLS]
    if not any(open_moves):
        no_legal_move += 1  # crashes anyway
        continue
    views.append(view)
    legal.append(open_moves)
views = torch.tensor(views)  # [positions, 81], float32
legal = torch.tensor(legal)  # [positions, 3], bool

with torch.no_grad():
    logits = network(views)  # every position in one batch
masked = logits.masked_fill(~legal, float("-inf"))  # illegal: probability exactly 0
probabilities = torch.softmax(masked, dim=1)
chosen = masked.argmax(dim=1)  # the first largest, like greedy() in the other tabs
rows = torch.arange(len(chosen))
assert legal[rows, chosen].all()
assert (probabilities[~legal] == 0).all()
assert torch.allclose(probabilities.sum(dim=1), torch.ones(len(chosen)), atol=1e-6)
unmasked_illegal = int((~legal[rows, logits.argmax(dim=1)]).sum())
print(f"# {len(chosen)} positions ({no_legal_move} with no legal move skipped); illegal picks 0; "
      f"favourite illegal without the mask: {unmasked_illegal}")

if len(sys.argv) > 1:
    # Parity: run the compiled program on the same positions and compare.
    output = subprocess.run([sys.argv[1], "--logits"], capture_output=True, text=True, check=True).stdout
    lines = [line.split() for line in output.splitlines() if not line.startswith("#")]
    their_chosen = torch.tensor([int(line[0]) for line in lines])
    their_logits = torch.tensor([[float(value) for value in line[1:]] for line in lines])
    assert their_logits.shape == logits.shape
    largest_difference = (their_logits - logits).abs().max().item()
    # PyTorch adds the products in a different order, so the last bits can differ.
    # A different move is acceptable only on a near-tie between the two best moves.
    best_two = masked.topk(2, dim=1).values
    gap = best_two[:, 0] - best_two[:, 1]  # infinite when only one move is legal
    different = their_chosen != chosen
    assert (gap[different] < 1e-5).all(), "a move differs without a tie"
    print(f"# parity with {sys.argv[1]}: {int(different.sum())} different moves, "
          f"largest logit difference {largest_difference:.1e}, closest call {gap.min().item():.1e}")
What's intentionally missing

Training (Behavior Cloning trains a network of this shape in the browser), real positions from games instead of made-up ones, weights loaded from a file, batching and SIMD for speed, a value head, and any handling of a position with no legal move beyond skipping it. The project's network in the next section adds the board planes, the convolutions and the value head.

10Project step 1: the light-cycle network

The Phase 5 project trains a bot for Tron Battle's rules, one against one. Step 1 is the network: what it sees, the model in PyTorch for training, the same forward pass in Rust for the bot, and checks that the mask works and that the two languages agree.

The project's networkEight boxes in two rows. Top row, left to right: 8 planes of 20 by 30 cells come in; a 3 by 3 convolution stem turns them into 16 channels with 1,168 weights; four residual 3 by 3 layers with dilations 1, 2, 4 and 8 use 9,280 weights; global pooling adds 16 board averages back to every cell with 272 weights. Bottom row: two numbers per move computed by code, room and Voronoi margin, feed the move head, which also reads the features at the cell each move enters and gives one logit per move with 321 weights; a mask and softmax turn the logits into probabilities; the value head, 577 weights, reads the board averages and is left out of the shipped bot. Planes in8 ร— 20 ร— 300s and 1sno weights Stem3ร—3 convolution8 โ†’ 16 channels1,168 weights 4 residual layers3ร—3, 16 โ†’ 16dilation 1, 2, 4, 89,280 weights Global pooling16 board averagesadded to every cell272 weights Per moveroom after itVoronoi margincomputed by code Move headcell it enters + 2โ†’ 16 โ†’ 1 logit321 weights Mask, softmaxillegal moves: โˆ’โˆžโ†’ probabilitiesthe policy Value headaverages โ†’ 32 โ†’ 1tanh, not shipped577 weights The project's networkBoxes from top to bottom: 8 planes of 20 by 30 cells come in; a 3 by 3 convolution stem turns them into 16 channels with 1,168 weights; four residual 3 by 3 layers with dilations 1, 2, 4 and 8 use 9,280 weights; global pooling adds 16 board averages to every cell with 272 weights. Then, side by side, two numbers per move computed by code and the value head, 577 weights, left out of the shipped bot. Below them the move head, 321 weights, reads the features at the cell each move enters plus the two numbers, and a mask and softmax turn its logits into probabilities. Planes in8 ร— 20 ร— 30 grid of 0s and 1sno weights Stem3ร—3 convolution, 8 โ†’ 16 channels1,168 weights 4 residual layers3ร—3, 16 โ†’ 16, dilation 1, 2, 4, 89,280 weights Global pooling16 board averages, added to every cell272 weights Per moveroom, Voronoimargin after itcomputed by code Value headaverages โ†’ 32 โ†’ 1tanh, not shipped577 weights Move headcell it enters + 2 numbers โ†’ 16 โ†’ 1321 weights Mask, softmaxillegal moves get โˆ’โˆž โ†’ probabilitiesthe policy
The project's network. The trunk turns 8 planes into 16 channels of features at every cell. The move head reads, for each legal move, the 16 features at the cell the move enters plus the two numbers computed by code (dashed box), and gives that move one logit. The value head reads the board averages. The shipped bot keeps 11,041 of the 11,618 weights: everything but the value head.

Project step 1 ยท The network

Build engine/src/features.rs (the eight planes, the cell each move enters, which moves are legal, and the room and margin after each move), train/model.py (the network in PyTorch) and engine/src/net.rs (the same forward pass in plain Rust, in float32).

Check 1: a random network makes only legal moves. With untrained weights (PyTorch seed 0), the network played 600 games on 100 seeds in both seats: against itself picking greedily, against itself drawing at random, and greedily against the Voronoi bot. In 30,289 decisions it chose 0 illegal moves with the mask on. Without the mask, its favourite move would have been illegal in 86%, 85% and 72% of those decisions.

Check 2: PyTorch and Rust choose the same move. On the same 30,289 recorded positions (27,183 of them with more than one legal move), the Rust forward pass and PyTorch on the CPU, both in float32: 0 different moves, a largest logit difference of 1.3 ร— 10โˆ’7, and a largest value difference of 2.7 ร— 10โˆ’7.

Measured on October 30, 2026 with PyTorch 2.14.1 and Rust 1.98.1, on an Intel i9-11900KF and an RTX 3080 Ti shared with other jobs.[8]

A difference of 10โˆ’7 can still flip a move if a position's two best logits are that close, and one was: the closest call was 7.5 ร— 10โˆ’8, and it held. That's why Shipping a Network gives its parity check a small tie tolerance. After the next page trains the network, the same check with PyTorch on the GPU found 0 different moves in 80,610 positions, with a largest logit difference of 3.8 ร— 10โˆ’5; the GPU adds in a different order. The closest call there was 1.6 ร— 10โˆ’4, so no move could flip.[8]

The project runs both forward passes on positions recorded from its games, calling the Rust code from Python through a small C interface (ffi.rs and tron_env.py, listed and explained in Building the Trainer). Until you have that interface, the method in section 09 works the same way: give both versions the same weights and positions and compare their logits and moves.

The listings are the project's files. features.rs uses the breadth-first distances from board.rs, the grid and search code that Evaluation Functions teaches. model.py keeps its layers in the order net.rs reads the weights, so one flat list of numbers loads into both, and its entropy function is the training-side fix for a pitfall in section 11.

Project step 1: the network's files
//! What the network sees: one 30ร—20 grid per fact ("planes"), from the point of view of the player
//! about to move. Every value is 0 or 1. The same code runs in the trainer (through the C ABI) and
//! inside the exported bot, so both see exactly the same numbers.
use crate::board::{distances, neighbor, Cells, CELLS, UNREACHED};

pub const PLANES: usize = 8;
pub const PLANE_NAMES: [&str; PLANES] = [
    "board",         // 1 on every cell: zero padding then marks the edge of the board
    "own trail",     // my cells, head included
    "enemy trail",   // every opponent's cells
    "own head",
    "enemy head",
    "own Voronoi",   // free cells I reach strictly before any opponent
    "enemy Voronoi", // free cells an opponent reaches strictly before me
    "own reachable", // free cells I can still reach at all
];

/// Writes the planes for player `me` into `out` (PLANES ร— CELLS bytes, plane after plane).
/// `heads[p]` is player p's head, or None once p has crashed (CodinGame removes its trail).
pub fn planes(cells: &Cells, me: usize, heads: &[Option<usize>], out: &mut [u8]) {
    assert_eq!(out.len(), PLANES * CELLS);
    out.fill(0);
    let my_head = heads[me].expect("the player to move is alive");
    let mut mine = [UNREACHED; CELLS];
    distances(cells, my_head, &mut mine);
    // Distance from the nearest opponent: the minimum over every living opponent.
    let mut theirs = [UNREACHED; CELLS];
    let mut scratch = [UNREACHED; CELLS];
    for (player, head) in heads.iter().enumerate() {
        if let (true, Some(head)) = (player != me, head) {
            distances(cells, *head, &mut scratch);
            for cell in 0..CELLS {
                theirs[cell] = theirs[cell].min(scratch[cell]);
            }
            out[4 * CELLS + head] = 1;
        }
    }
    out[3 * CELLS + my_head] = 1;
    for cell in 0..CELLS {
        out[cell] = 1;
        let owner = cells[cell];
        if owner == me as u8 + 1 {
            out[CELLS + cell] = 1;
        } else if owner != 0 {
            out[2 * CELLS + cell] = 1;
        } else {
            if mine[cell] < theirs[cell] {
                out[5 * CELLS + cell] = 1;
            } else if theirs[cell] < mine[cell] {
                out[6 * CELLS + cell] = 1;
            }
            if mine[cell] != UNREACHED {
                out[7 * CELLS + cell] = 1;
            }
        }
    }
}

/// For each direction (UP, RIGHT, DOWN, LEFT): the cell the move enters (-1 off the board), and
/// whether the move is legal (on the board and free). The policy reads its logits at these cells.
pub fn move_targets(cells: &Cells, head: usize) -> ([i32; 4], [bool; 4]) {
    let mut targets = [-1; 4];
    let mut legal = [false; 4];
    for direction in 0..4 {
        if let Some(cell) = neighbor(head, direction) {
            targets[direction] = cell as i32;
            legal[direction] = cells[cell] == 0;
        }
    }
    (targets, legal)
}

/// Numbers per move that a convolution cannot count for itself.
pub const MOVE_FEATURES: usize = 2;

/// For each direction (UP, RIGHT, DOWN, LEFT), the board after that move, summed up: the free cells I
/// could still reach ("room"), and my Voronoi cells minus the opponents', both divided by CELLS.
/// Zeros for illegal moves. These are the two numbers the Voronoi bot ranks moves by.
pub fn move_features(cells: &Cells, me: usize, heads: &[Option<usize>]) -> [[f32; MOVE_FEATURES]; 4] {
    let mut out = [[0f32; MOVE_FEATURES]; 4];
    let head = heads[me].expect("the player to move is alive");
    let mut mine = [UNREACHED; CELLS];
    let mut theirs = [UNREACHED; CELLS];
    let mut scratch = [UNREACHED; CELLS];
    for (direction, slot) in out.iter_mut().enumerate() {
        let Some(target) = neighbor(head, direction).filter(|&cell| cells[cell] == 0) else {
            continue;
        };
        let mut after = *cells;
        after[target] = me as u8 + 1;
        distances(&after, target, &mut mine);
        theirs.fill(UNREACHED);
        for (player, other) in heads.iter().enumerate() {
            if let (true, Some(other)) = (player != me, other) {
                distances(&after, *other, &mut scratch);
                for cell in 0..CELLS {
                    theirs[cell] = theirs[cell].min(scratch[cell]);
                }
            }
        }
        let (mut room, mut margin) = (0i32, 0i32);
        for cell in 0..CELLS {
            if after[cell] != 0 {
                continue;
            }
            room += i32::from(mine[cell] != UNREACHED);
            margin += i32::from(mine[cell] < theirs[cell]) - i32::from(theirs[cell] < mine[cell]);
        }
        *slot = [room as f32 / CELLS as f32, margin as f32 / CELLS as f32];
    }
    out
}
"""The light-cycle network: board planes and two numbers per move in, a probability for each move and a
predicted result out. engine/src/net.rs computes the same forward pass in Rust (float32)."""
import torch
from torch import nn
import torch.nn.functional as F

PLANES, HEIGHT, WIDTH = 8, 20, 30
MOVE_FEATURES = 2       # room and Voronoi margin after the move (engine/src/features.rs)
# Full float32 convolutions on the GPU (cuDNN would otherwise round inputs to TF32's 10-bit mantissa),
# so the GPU plays the same moves the float32 Rust bot computes. Training switches TF32 on for speed.
torch.backends.cudnn.conv.fp32_precision = 'ieee'


class tf32_convolutions:
    """Inside this block GPU convolutions use TF32: faster, with a 10-bit mantissa, which is fine for
    gradients. Matches and the export run outside it, in full float32."""
    def __enter__(self):
        torch.backends.cudnn.conv.fp32_precision = 'tf32'

    def __exit__(self, *exception):
        torch.backends.cudnn.conv.fp32_precision = 'ieee'


class TronNet(nn.Module):
    def __init__(self, channels=16, dilations=(1, 2, 4, 8), move_hidden=16, value_hidden=32):
        super().__init__()
        self.shape = dict(channels=channels, dilations=list(dilations), move_hidden=move_hidden,
                          value_hidden=value_hidden)
        # Attribute order is the weight order the Rust side reads.
        self.stem = nn.Conv2d(PLANES, channels, 3, padding=1)
        # Dilated 3x3 convolutions: taps 1, 2, 4, 8 cells apart, so four layers see 31 cells across.
        self.blocks = nn.ModuleList(nn.Conv2d(channels, channels, 3, padding=dilation, dilation=dilation)
                                    for dilation in dilations)
        self.pool = nn.Linear(channels, channels)
        self.move_hidden = nn.Linear(channels+MOVE_FEATURES, move_hidden)
        self.move_out = nn.Linear(move_hidden, 1)
        self.value_hidden = nn.Linear(channels, value_hidden)
        self.value_out = nn.Linear(value_hidden, 1)

    def forward(self, planes, features, targets, legal):
        """planes uint8 [batch, 8, 20, 30] (0/1); features float [batch, 4, 2]; targets [batch, 4], the
        cell each move enters (-1 off the board); legal bool [batch, 4].
        Returns the four move logits (-inf where illegal: the mask) and the value, the predicted
        result for the player to move from -1 (loss) to +1 (win)."""
        hidden = F.relu(self.stem(planes.float()))
        for block in self.blocks:
            hidden = F.relu(hidden+block(hidden))       # residual: each block adds a correction
        # Global pooling: every cell also gets a summary of the whole board.
        context = self.pool(hidden.mean((2, 3)))
        hidden = F.relu(hidden+context[:, :, None, None])
        # Each move: the features of the cell it enters, plus its room and margin -> one logit.
        cells = hidden.flatten(2)                                         # [batch, channels, 600]
        index = targets.clamp_min(0)[:, None, :].expand(-1, cells.shape[1], -1)
        move_inputs = torch.cat([cells.gather(2, index).transpose(1, 2), features], 2)
        logits = self.move_out(F.relu(self.move_hidden(move_inputs))).squeeze(2)
        logits = logits.masked_fill(~legal, float('-inf'))               # illegal: probability exactly 0
        value = torch.tanh(self.value_out(F.relu(self.value_hidden(hidden.mean((2, 3)))))).squeeze(1)
        return logits, value


def policy(model, planes, features, targets, legal):
    """Log-probabilities of the four moves (every position must have a legal move), and the value."""
    logits, value = model(planes, features, targets, legal)
    return torch.log_softmax(logits, 1), value


def entropy(log_probs, legal):
    """-sum p log p over legal moves. Illegal moves have p = 0 and log p = -inf; they are zeroed before
    multiplying, because 0 * -inf is NaN and its gradient would turn every weight into NaN."""
    log_probs = log_probs.masked_fill(~legal, 0.)
    return -(log_probs.exp()*log_probs*legal).sum(1)


def choose(logits, legal, greedy=False, generator=None):
    """One move per position: the most likely legal move, or one sampled from the masked softmax.
    A position with no legal move gets move 0 (UP): the player crashes whatever it does."""
    logits = logits.masked_fill(~legal.any(1, keepdim=True), 0.)
    if greedy:
        return logits.argmax(1)
    return torch.multinomial(torch.softmax(logits, 1), 1, generator=generator).squeeze(1)


def parameter_count(model, with_value=True):
    return sum(parameter.numel() for name, parameter in model.named_parameters()
               if with_value or not name.startswith('value'))
//! The network's forward pass in plain Rust, operation for operation the same as `train/model.py`
//! (float32). Weights come as one flat list in PyTorch's order: each layer's weight, then its bias.
use crate::board::{CELLS, HEIGHT, WIDTH};
use crate::features::{MOVE_FEATURES, PLANES};

/// The layer sizes; `train/model.py` builds the same network from the same numbers.
#[derive(Clone, Debug)]
pub struct Shape {
    pub channels: usize,
    /// One residual 3ร—3 convolution per entry, with its taps this many cells apart.
    pub dilations: Vec<usize>,
    pub move_hidden: usize,
    pub value_hidden: usize,
}

/// Takes the next `count` numbers off the front of `weights`.
fn take(weights: &mut &[f32], count: usize) -> Vec<f32> {
    let (head, rest) = weights.split_at(count);
    *weights = rest;
    head.to_vec()
}

/// A 3ร—3 convolution with zero padding (the output has the input's size). Weight layout
/// [outputs][inputs][3][3], as in PyTorch's `nn.Conv2d`.
struct Conv {
    inputs: usize,
    outputs: usize,
    dilation: usize,
    weight: Vec<f32>,
    bias: Vec<f32>,
}

impl Conv {
    fn read(weights: &mut &[f32], inputs: usize, outputs: usize, dilation: usize) -> Conv {
        let weight = take(weights, outputs * inputs * 9);
        let bias = take(weights, outputs);
        Conv { inputs, outputs, dilation, weight, bias }
    }

    fn forward(&self, input: &[f32]) -> Vec<f32> {
        let mut output = vec![0f32; self.outputs * CELLS];
        let spread = self.dilation as isize;
        let (width, height) = (WIDTH as isize, HEIGHT as isize);
        for out_channel in 0..self.outputs {
            let out_plane = &mut output[out_channel * CELLS..(out_channel + 1) * CELLS];
            for in_channel in 0..self.inputs {
                let in_plane = &input[in_channel * CELLS..(in_channel + 1) * CELLS];
                for tap in 0..9 {
                    let weight = self.weight[(out_channel * self.inputs + in_channel) * 9 + tap];
                    // This tap reads the cell (dy, dx) away from the output cell.
                    let dy = (tap as isize / 3 - 1) * spread;
                    let dx = (tap as isize % 3 - 1) * spread;
                    // Only output cells whose tap lands on the board; off-board taps read 0.
                    let (x_start, x_end) = ((-dx).max(0), width - dx.max(0));
                    if x_end <= x_start {
                        continue;
                    }
                    let run = (x_end - x_start) as usize;
                    for y in (-dy).max(0)..height - dy.max(0) {
                        // One row at a time as two slices, so the loop runs without bounds checks.
                        let out_start = (y * width + x_start) as usize;
                        let in_start = ((y + dy) * width + x_start + dx) as usize;
                        let out_row = &mut out_plane[out_start..out_start + run];
                        for (out, &value) in out_row.iter_mut().zip(&in_plane[in_start..in_start + run]) {
                            *out += weight * value;
                        }
                    }
                }
            }
            for value in out_plane.iter_mut() {
                *value += self.bias[out_channel];
            }
        }
        output
    }
}

/// A fully connected layer: weight [outputs][inputs], then bias [outputs].
struct Linear {
    inputs: usize,
    weight: Vec<f32>,
    bias: Vec<f32>,
}

impl Linear {
    fn read(weights: &mut &[f32], inputs: usize, outputs: usize) -> Linear {
        Linear { inputs, weight: take(weights, outputs * inputs), bias: take(weights, outputs) }
    }
    fn forward(&self, input: &[f32]) -> Vec<f32> {
        self.bias
            .iter()
            .enumerate()
            .map(|(output, &bias)| {
                let row = &self.weight[output * self.inputs..(output + 1) * self.inputs];
                row.iter().zip(input).fold(0f32, |sum, (weight, value)| sum + weight * value) + bias
            })
            .collect()
    }
}

fn relu(values: &mut [f32]) {
    for value in values {
        *value = value.max(0.);
    }
}

/// The mean of each channel over the board.
fn channel_means(hidden: &[f32], channels: usize) -> Vec<f32> {
    (0..channels)
        .map(|channel| hidden[channel * CELLS..(channel + 1) * CELLS].iter().sum::<f32>() / CELLS as f32)
        .collect()
}

pub struct Net {
    stem: Conv,
    blocks: Vec<Conv>,
    pool: Linear,
    move_hidden: Linear,
    move_out: Linear,
    value: Option<(Linear, Linear)>,
}

impl Net {
    /// Reads the weights in PyTorch's order. The exported bot leaves out the value head (it only
    /// needs moves), so `with_value` is false there.
    pub fn read(shape: &Shape, mut weights: &[f32], with_value: bool) -> Net {
        let channels = shape.channels;
        let stem = Conv::read(&mut weights, PLANES, channels, 1);
        let blocks = shape.dilations.iter().map(|&dilation| Conv::read(&mut weights, channels, channels, dilation)).collect();
        let pool = Linear::read(&mut weights, channels, channels);
        let move_hidden = Linear::read(&mut weights, channels + MOVE_FEATURES, shape.move_hidden);
        let move_out = Linear::read(&mut weights, shape.move_hidden, 1);
        let value = with_value.then(|| {
            let hidden = Linear::read(&mut weights, channels, shape.value_hidden);
            (hidden, Linear::read(&mut weights, shape.value_hidden, 1))
        });
        assert!(weights.is_empty(), "{} weights left over: wrong shape", weights.len());
        Net { stem, blocks, pool, move_hidden, move_out, value }
    }

    /// The four move logits (UP, RIGHT, DOWN, LEFT; -infinity for illegal moves) and, if loaded, the
    /// value: the predicted result for the player to move, from -1 (loss) to +1 (win).
    pub fn forward(&self, planes: &[u8], features: &[[f32; MOVE_FEATURES]; 4], targets: &[i32; 4], legal: &[bool; 4]) -> ([f32; 4], Option<f32>) {
        let input: Vec<f32> = planes.iter().map(|&bit| bit as f32).collect();
        let mut hidden = self.stem.forward(&input);
        relu(&mut hidden);
        for block in &self.blocks {
            // Residual: each block adds a correction to what came before.
            let correction = block.forward(&hidden);
            for (value, change) in hidden.iter_mut().zip(&correction) {
                *value += change;
            }
            relu(&mut hidden);
        }
        // Global pooling: the board-wide mean of each channel, mixed and added back to every cell,
        // so each cell's features also describe the whole board.
        let channels = self.stem.outputs;
        let context = self.pool.forward(&channel_means(&hidden, channels));
        for channel in 0..channels {
            for value in &mut hidden[channel * CELLS..(channel + 1) * CELLS] {
                *value += context[channel];
            }
        }
        relu(&mut hidden);
        // Each legal move: the features of the cell it enters, plus its room and margin, through a
        // small two-layer network that gives one logit.
        let mut logits = [f32::NEG_INFINITY; 4];
        for direction in 0..4 {
            if !legal[direction] {
                continue;
            }
            let cell = targets[direction] as usize;
            let mut move_input: Vec<f32> = (0..channels).map(|channel| hidden[channel * CELLS + cell]).collect();
            move_input.extend_from_slice(&features[direction]);
            let mut move_hidden = self.move_hidden.forward(&move_input);
            relu(&mut move_hidden);
            logits[direction] = self.move_out.forward(&move_hidden)[0];
        }
        let value = self.value.as_ref().map(|(value_hidden, value_out)| {
            let mut summary = value_hidden.forward(&channel_means(&hidden, channels));
            relu(&mut summary);
            value_out.forward(&summary)[0].tanh()
        });
        (logits, value)
    }
}

/// The first largest logit (greedy play). Illegal moves are -infinity, so a legal move wins whenever
/// there is one; with none, UP: the bot crashes either way.
pub fn greedy(logits: &[f32; 4]) -> usize {
    let mut best = 0;
    for direction in 1..4 {
        if logits[direction] > logits[best] {
            best = direction;
        }
    }
    best
}
What's intentionally missing

Batching and SIMD in the Rust forward pass; quantized, packed weights (Shipping a Network); training (Behavior Cloning, then Self-Play Reinforcement Learning); and games of 3 or 4 players. The planes already merge every opponent into the enemy planes and drop a crashed player's trail, but this network trains one against one only, while Tron Battle's battles have 2 to 4 cycles after the first league.[1] Light cycles have no hidden information, so there's no fog of war to encode.

11Where game networks go wrong

A trained network still needs its mask

The mask kept illegal moves out of every training loss, so nothing taught the network to avoid them. Drop the mask at play time and the project's trained clone would have picked an illegal move in 47% of its self-play decisions.

A NaN from the mask in an entropy bonus. Self-play training often adds an entropy bonus, the sum of โˆ’p log p over the moves, to keep the policy from settling too early. An illegal move has p = 0 and log p = โˆ’โˆž, and 0 ร— โˆ’โˆž is NaN. Masking that term afterwards isn't enough, because the backward pass through the product still computes 0 ร— โˆ’โˆž. In the project, the first training step turned every weight into NaN. Zero the masked log-probabilities before multiplying, as entropy() in model.py does.[8]

GPU float32 that isn't float32. On NVIDIA GPUs from the Ampere generation on (the RTX 30 series and later), PyTorch lets cuDNN, NVIDIA's library of neural-network routines, run float32 convolutions in TF32 by default, which rounds the inputs to a 10-bit mantissa.[12] A parity check against a float32 Rust bot then compares different arithmetic. The project's model.py turns TF32 off for matches and parity checks and back on only inside the training update.

A network that can't count. Convolutions read neighbourhoods, so a count over a whole region, such as how much room a move leaves, has to come in as an input. Without it the network drives into small pockets, and its agreement score barely shows it.

Units deciding alone. One pass with an output per unit lets units collide whenever their preferences overlap. Decide in order and feed each pick to the next decision.

Raw coordinates for units. Measure positions from the unit, in its own frame, so one lesson applies everywhere on the map.

Training for one opponent, playing against three. Tron Battle's battles gather 2 to 4 cycles, and only its first league always has one opponent. A network that has only seen one-against-one games knows nothing about the others; test it in the games it will play.

Sizing the network last. Count weights against the 100,000 characters and multiply-adds against the turn before you train, so the design starts inside both limits.

12What's next

Behavior Cloning trains this network to copy the Voronoi bot. It records the teacher's decisions through the engine, trains with a cross-entropy loss, checks agreement on held-out games, and plays the clone against its teacher over seeded games in both seats. The project so far:

project/
โ”œโ”€โ”€ engine/                 Rust: the game, the bots and the network's forward pass
โ”‚   โ”œโ”€โ”€ Cargo.toml
โ”‚   โ””โ”€โ”€ src/
โ”‚       โ”œโ”€โ”€ lib.rs          the module list
โ”‚       โ”œโ”€โ”€ board.rs        30 ร— 20 grid, BFS distances, room, Voronoi (Evaluation Functions)
โ”‚       โ”œโ”€โ”€ game.rs         rules, seeded starts, CodinGame's input lines (Referees and Simulators)
โ”‚       โ”œโ”€โ”€ bots.rs         flood-fill and Voronoi bots (Evaluation Functions)
โ”‚       โ”œโ”€โ”€ alphabeta.rs    the alpha-beta yardstick (Minimax and Alpha-Beta)
โ”‚       โ”œโ”€โ”€ features.rs     what the network sees (this page)
โ”‚       โ””โ”€โ”€ net.rs          the forward pass in plain Rust (this page)
โ””โ”€โ”€ train/
    โ”œโ”€โ”€ model.py            the network in PyTorch (this page)
    โ””โ”€โ”€ parity.py           Rust and PyTorch on the same positions (this page's check)

13Sources

Quotes are from each player's own write-up. Numbers marked as the author's lab, or as the project, are first-hand records from one machine and aren't general results. Widget readouts are computed in the page from seeded games.

  1. CodinGame, Tron Battle statement, read on 2026-10-30. A 30 by 20 grid, battles of 2 to 4 cycles with one opponent in the first league, players moving in turn, an answer in under 100 ms, and a crashed player's trail disappearing.
  2. David J. Wu, 2019. Accelerating Self-Play Learning in Go, arXiv:1902.10565 (v5, 2020; presented at the AAAI-20 Workshop on Reinforcement Learning in Games). Section 3.3 on global pooling, which Wu notes is not new (he cites Squeeze-and-Excitation as prior art); section 4.2 and Appendix A on the game-specific input features, including the "Location is on board" plane (the quote on input features is from the introduction).
  3. Pduhard, 2026. Summer Challenge 2026, Back Track King: 3rd, README. His global pooling: channel averages through a linear layer, added to every cell, "KataGo's global pooling, 1,056 weights".
  4. pb4 and Agade, 2019. Mastering a Pod Racing AI Competition with Nash-DQN, GitHub article. Their network at the top of the Coders Strike Back leaderboard, and the suggested input for a first runner network: the next two checkpoints relative to the pod, its speed and its angle.
  5. 5DN1L (forum moderator), 2021. Mad Pod Racing - Puzzle Discussion, post 538. "Coders Strike Back is the previous name of Mad Pod Racing".
  6. Oriol Vinyals, Meire Fortunato and Navdeep Jaitly, 2015. Pointer Networks, NeurIPS 2015 (arXiv:1506.03134). An output that "uses attention as a pointer to select a member of the input sequence", for problems where the number of choices depends on the input.
  7. delineate, 2026. Codingame Spring Challenge 2026 (gist, first place in Troll Farm). The 104 observation planes, the shared per-candidate head for troll types, the masks, the troll-by-troll decisions and the play-time beam search over them, and the size: about 101,000 parameters in 98,000 characters.
  8. Author's records for this series' Phase 5 project, measured 2026-10-30 (code on this page; records not yet published). The network's checks: RESULTS.md step 2, runs/logs/parity-random.log, runs/logs/parity-clone.log; the first design: RESULTS.md step 2 (its matches, and the room diagnosis from a one-off script), runs/bc-planes-only-c16/ and runs/bc-planes-only-c32/ (agreement); the clone: runs/logs/bc.log, runs/logs/eval-clone.log; the entropy NaN and the TF32 switch: RESULTS.md notes 4 and 5.
  9. Author's lab records, 2026 (private repository). The Back Track King re-ranking learners and the 17,700-parameter Q-learner: AGENTS.md, prompts/handoff-20261014.md; the whole-board network (62 planes, 12 scalars, a 104,820-weight policy, three placement slots and a disruption slot): docs/nn-training.md, docs/research-training.md, games/backtrackking/engine/src/features.rs; the Spring Challenge 2022 diagnosis (254 turns): experiments/spring2022-attacker-20261026.json; the Tron Battle network's computed features: games/tronbattle/README.md.
  10. CodinGame Help Center, Languages Versions, read on 2026-10-30. "The code size for any game is limited to 100k characters."
  11. reCurse, 2023. Codingame Fall Challenge 2023 Postmortem (gist, first place). 99,264 characters, 65,714 of them weights, at 8 bits per parameter and 16 for the first layer.
  12. PyTorch documentation, CUDA semantics: TensorFloat-32 (TF32) on Ampere (and later) devices, read on 2026-10-30 (version 2.15). TF32 rounds float32 inputs to a 10-bit mantissa; the cuDNN flag that allows it for convolutions defaults to on.