Nov 09 2025
The other day I wrote a solver for the LinkedIn Tango puzzle. This blog post explains a few key ideas behind the implementation. If you'd like to dig through the code, it's here.
For those unfamiliar with LinkedIn's games, the company publishes daily online games inspired by (I assume) Wordle and similar games, which first became very popular during the COVID-19 pandemic lockdowns.
Tango as a puzzle is very reminiscent of the classic one-player puzzle game Sudoku. For example:
1 through 9. In Tango, it's either a "sun" or a "moon".For a full list of rules, please refer to the Tango page.
I figured it would be fun to write a solver. My first thought was to write a solver that would apply one of several heuristics one after the other until all cells were filled up. Some examples of heuristics:
N. When you see a row or column with N / 2 "suns" in total, fill all other cells with "moons".I wrote up a few of these heuristics and was able to run them on several Tango grids. A couple times I had to add new, cleverer heuristics to account for harder puzzles that were otherwise unsolvable with the heuristics I'd written so far. Naturally, this "expert knowledge" approach was painful to write and required a lot of worrying about loop iterations and edge cases around array bounds.
Which made me take a step back and think: since Tango is quite reminiscent of Sudoku, and Sudoku is solvable by simple backtracking methods, why not do the same for Tango?
I went ahead and did that, and it was indeed a simpler approach to implement. Here's the code repository. There's lots of boilerplate code in there, but the heart of the code is in solver.rs. Specifically, this function:
fn helper(board: &mut Board) -> bool {
let i = board.next_unsolved();
if i == None {
return true;
}
let i = i.unwrap();
board.mark_solved();
for new in 0..2 {
// set a value only if it's safe to do so
if can_set(board, i, new) {
board.set_index(i, new);
if helper(board) {
return true;
}
board.set_index(i, 2);
}
}
board.mark_unsolved();
false
}
This is a classic backtracking formulation. Pick an empty cell and ask if we can set it to a particular value. If yes, recurse with that value set. At some point, if the search fails, abandon that entire search branch, and instead try the next value (in Tango, there are only two values: 0 representing "sun" and 1 representing "moon").
The "can we set this empty cell to a particular value" logic is a direct translation into code of the game's rules:
fn can_set(board: &Board, i: usize, new: u8) -> bool {
let (drs, dcs) = ([0usize, 1], [1usize, 0]);
let side = board.side();
let (r, c) = (board.row(i), board.col(i));
for v in 0..2 {
let (dr, dc) = (drs[v], dcs[v]);
let (mut _r, mut _c) = (r * dc, c * dr);
// `one` = the value two indices before `curr`
// `two` = the value one index before `curr`
let (mut one, mut two) = (255u8, 255u8);
// number of cells in the row/col that are already equal to `new`
let mut num_existing = 0;
for _ in 0..side {
let curr = if (_r, _c) == (r, c) { new } else { board.at(_r, _c) };
// three consecutive identical not allowed
if one == two && two == curr && curr != 2 {
return false;
}
one = two;
two = curr;
if curr == new { num_existing += 1; }
_r += dr;
_c += dc;
}
// no more than `side / 2` in a row or column
// for example, for a 8x8 board, no more than 4 in a row or column
if num_existing > side / 2 {
return false;
}
}
// abide by constraints
// north, east, south, west
let _drs = [-1i16, 0, 1, 0];
let _dcs = [0i16, 1, 0, -1];
let constraint = board.constraint_at(r, c);
for j in 0..8 {
if constraint & (1 << j) == 0 { continue; }
let _nr = (r as i16) + _drs[j % 4];
let _nc = (c as i16) + _dcs[j % 4];
if _nr < 0 || _nc < 0 { continue; }
let (nr, nc) = (_nr as usize, _nc as usize);
if !board.inside(nr, nc) { continue; }
let nbr_val = board.at(nr, nc);
if nbr_val == 2 { continue; }
if (j < 4 && nbr_val == new) || (j >= 4 && nbr_val != new) {
continue;
}
return false;
}
true
}
That's a long function, but all it's doing is verifying that:
N / 2 occurrences of the same value.I set up a simple test suite, some quick parsers to translate a string representation of a game into a more structured representation, and verified that the approach could handle every single game I threw at it, no matter how tough it was for a human to solve.
A natural follow-up is: how are these puzzles generated? Our basic requirement is: we want to generate games that a human can solve without guesswork. A prerequisite for not needing to guess is that a game should permit only one solution. Answering that question feels like an extension of the solver above. At a high level, it might look something like this:
There's a wrinkle here. Having a unique solution is a necessary condition for a game to be solvable without guesses. But is it a sufficient condition? I'm not sure. Something to think about.