I Gave My URL Shortener's Puzzle Games an AI That Takes Over When You Walk Away

A URL shortener is a strange place to put puzzle games. But we have a handful of little utility pages under /u/ — the kind of tools people land on, use for thirty seconds, and leave — and somewhere along the way they grew games. There's already a 1024 game. This round added two more: Ball Sort at /u/balls and Sudoku at /u/sudoku.

The fun constraint I set myself: every game should be able to finish itself. Walk away, and after a short idle timeout a countdown appears; ignore that too, and an AI takes the wheel and solves the board in front of you. It turns a dead tab into a tiny screensaver. Here's how the shared convention works, and the two solvers behind it.

The shared idle-to-autoplay convention

All three games follow the same three-state life cycle, with the same timing constants so they feel consistent:

 playing ──(idle N secs)──▶ countdown ──(30 secs, no input)──▶ autoplay
    ▲                            │                                 │
    └──────────── any user input resets to playing ◀──────────────┘

In React it's a couple of refs and timers — deliberately ref-based, not state, so the timers don't churn re-renders while they tick:

const IDLE_TIMEOUT_SECS = 10;
const AUTOPLAY_COUNTDOWN_SECS = 30;
const AUTOPLAY_MOVE_MS = 1000;

const idleTimerRef = useRef();
const autoplayTimerRef = useRef();
const autoPlayingRef = useRef(false);

// Any interaction resets the whole thing.
function onUserActivity() {
  autoPlayingRef.current = false;
  clearInterval(autoplayTimerRef.current);
  restartIdleTimer();
}

The important detail is that any real input cancels autoplay instantly — a mouse move, a tap, a key. The AI is a fallback for an abandoned tab, never something that fights a present player. And each "AI move" is spaced by AUTOPLAY_MOVE_MS so you can actually watch it think, rather than the board snapping to solved.

The pattern generalized cleanly: the game supplies a nextMove() function, the convention supplies the "when to start driving" machinery. Ball Sort and Sudoku differ only in what nextMove() computes.

Ball Sort: a search over pour states

Ball Sort is deceptively a graph problem. A state is the arrangement of colored balls across the tubes; a move is pouring the top run of one tube onto another (legal only onto an empty tube or a matching color with room). The solved state is "every tube is empty or a single color."

So the solver is just a search over states:

  • Nodes: board configurations (serialize each to a canonical string for a visited-set).
  • Edges: legal pours.
  • Goal: the sorted configuration.

A breadth-first search finds the shortest solution but blows up on deep boards; a depth-first search with a visited set and a little pruning (never undo the move you just made; prefer pours that complete a tube) finds a solution fast enough to animate. For autoplay you don't need optimal — you need "a legal path to solved, one move at a time," which is exactly what nextMove() pops off the computed plan.

function planSolution(tubes) {
  const start = serialize(tubes);
  const stack = [{ state: tubes, path: [] }];
  const seen = new Set([start]);
  while (stack.length) {
    const { state, path } = stack.pop();
    if (isSolved(state)) return path;           // list of pours
    for (const move of legalPours(state)) {
      const next = applyPour(state, move);
      const key = serialize(next);
      if (!seen.has(key)) {
        seen.add(key);
        stack.push({ state: next, path: [...path, move] });
      }
    }
  }
  return null; // unsolvable from here
}

Autoplay calls planSolution once, then feeds one pour per tick.

Sudoku: constraint backtracking

Sudoku is the classic backtracking search — and honestly the more satisfying one to watch, because a good solver mimics how a human plays:

  1. Find the empty cell with the fewest legal candidates (most-constrained-variable heuristic).
  2. Try each candidate; recurse.
  3. If a branch dead-ends, back up and try the next candidate.
function solve(grid) {
  const cell = mostConstrainedEmptyCell(grid);   // MRV heuristic
  if (!cell) return true;                         // no empties → solved
  for (const n of candidates(grid, cell)) {
    grid[cell.r][cell.c] = n;
    if (solve(grid)) return true;
    grid[cell.r][cell.c] = 0;                     // backtrack
  }
  return false;
}

Picking the most-constrained cell first is what keeps it from flailing — it prunes the search tree hard, so even a nasty puzzle solves in milliseconds. For autoplay we solve the full grid up front, then reveal cells one per tick in solve-order, so it looks like the AI is reasoning through the board rather than dumping the answer.

Why bother?

Honestly? It's delightful, and it costs nothing to run — it's all client-side. A utility page that idly solves its own puzzle is the kind of small, human touch that makes a tool feel alive instead of transactional. And factoring the idle-to-autoplay convention out once meant the third game was mostly "write a nextMove() and plug it in."

Lessons learned

  • Refs over state for timers. Countdown ticks shouldn't re-render your game grid.
  • The AI must yield to humans instantly. Any input cancels autoplay — it's a fallback, not a rival.
  • Separate "when to drive" from "what to do." The convention is game-agnostic; each game only supplies its next move.
  • Autoplay is a great forcing function for a real solver — you can't fake "watch it solve" without actually solving it.

What's the most over-engineered small touch you've added to a side project? The kind nobody asked for but everyone smiles at. Share it.

Building jo4.io — a modern URL shortener with analytics, bio pages, and, apparently, self-solving puzzles.