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:
- Find the empty cell with the fewest legal candidates (most-constrained-variable heuristic).
- Try each candidate; recurse.
- 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.