A Tron duel in one 2-D array: collision is one line, and the CPU flood-fills the open space to avoid trapping itself

# javascript# gamedev# algorithms# beginners
A Tron duel in one 2-D array: collision is one line, and the CPU flood-fills the open space to avoid trapping itselfDevanshu Biswas

A light-cycle duel looks like it needs a physics engine and a sprite sheet. It needs neither. I built...

A light-cycle duel looks like it needs a physics engine and a sprite sheet. It needs neither. I built a full Tron game — two riders racing around a walled arena laying solid walls of light behind them, first to five wins — and the whole thing is a 2-D array of cells, two moving points, and one collision test. About 130 lines of vanilla JavaScript on a single canvas. Here is how it fits together.

The arena is just a grid of numbers

There are no sprites and no bodies. The entire game state is one ROWS × COLS array called occ, where every cell holds 0 (empty), 1 (your trail) or 2 (the CPU's). The canvas is only a picture of this array — the truth lives in the numbers. I size the canvas so one array cell maps to one square of CELL pixels, and I wipe the array to all-zero at the start of every round so nothing lingers from the last one.

A cycle, then, is almost nothing: a position (r, c) and a direction step vector (dr, dc). East is (0, 1), north is (-1, 0). Every tick, a rider's next cell is simply pos + dir. The two riders spawn on opposite sides facing each other, and I mark each spawn cell occupied immediately — a cycle's very first square is already a wall the other rider can die against.

The tick is the heartbeat

A defining rule of Tron is that a cycle can never stop. That makes the game clock-driven, not input-driven: a setInterval fires every TICK_MS, and on each fire the whole world advances exactly one cell.

Steering does not move you directly — it buffers a direction that the next tick applies. Buffering matters: without it two fast key presses inside one tick could fold you onto your own trail. The one illegal move is a straight reverse, because flipping 180° drives you instantly into the trail cell right behind you, so I reject any direction that is the exact negative of the current heading:

function setDir(dir){
  const cr = player.dr, cc = player.dc;
  if (dir[0] === -cr && dir[1] === -cc) return;   // no reverse into own trail
  player.next = dir;                              // applied at next tick
}
Enter fullscreen mode Exit fullscreen mode

Collision detection is the entire rulebook

This one test is the whole game. A candidate cell kills its rider if it falls off the grid or if the array already holds a non-zero value there — meaning any wall at all: the arena edge, your own trail, or the enemy's. Because both trails live in the same array with the same "solid" meaning, one check covers every possible way to die.

function crashes(cell){
  return cell.r < 0 || cell.r >= ROWS ||
         cell.c < 0 || cell.c >= COLS ||
         occ[cell.r][cell.c] !== 0;     // any trail = wall
}
Enter fullscreen mode Exit fullscreen mode

The tick judges both riders' next cells before anyone moves. If both aim at the same cell it's a head-on tie; if both would crash it's a tie; otherwise whoever crashes loses. Only when both cells are proven safe do I stamp the trails and advance the heads. Checking first and moving second is what makes a simultaneous crash resolve fairly instead of favouring whoever happened to be processed first.

The CPU's "intelligence" is a flood-fill

The part I'm happiest with is the opponent, because it isn't scripted to follow any path. From its current heading the CPU has exactly three legal moves — straight, left, right — and a 90° turn is a cheap vector trick: left = (-dc, dr), right = (dc, -dr).

A naive bot that just dodges the next wall will happily drive into a dead-end pocket and bury itself. The fix is to look further. From each candidate cell I run a bounded flood-fill — a breadth-first sweep that counts how many empty cells are reachable — and that count is "how much room is on this side":

function freeSpace(r, c, cap){
  if (crashes({r, c})) return 0;
  const seen = new Set([r * COLS + c]);
  const queue = [[r, c]];
  let count = 0;
  while (queue.length && count < cap){
    const [cr, cc] = queue.shift(); count++;
    for (const [dr, dc] of [[1,0],[-1,0],[0,1],[0,-1]]){
      const nr = cr+dr, nc = cc+dc, key = nr*COLS+nc;
      if (nr<0||nr>=ROWS||nc<0||nc>=COLS) continue;
      if (occ[nr][nc] !== 0 || seen.has(key)) continue;
      seen.add(key); queue.push([nr, nc]);
    }
  }
  return count;   // reachable room, capped so it stays cheap
}
Enter fullscreen mode Exit fullscreen mode

The decision then writes itself: score each of the three turns by the room it leads into, nudge "straight" up a touch so the bot doesn't jitter, add a whisker of randomness to break ties, and steer toward the best. If every turn is instant death it just holds course and crashes — which is exactly the moment you've trapped it. Capping the sweep keeps each decision cheap, because I only need to compare options, not map the whole board.

That greedy "drive toward the most space" rule, in a dozen lines, makes a genuinely tough opponent — and beating it means baiting it into a corner and cutting off its escape before its own greed does.

Play it (arrow keys or WASD; first to five wins):
https://dev48v.infy.uk/game/day43-tron-duel.html