Building SumTrails: A Daily Puzzle Game Where the Generator Was the Real Puzzle
I built a daily number-path puzzle game because I wanted one to play. Turns out, generating good puzzles is exponentially harder than solving them.
I just wanted a daily puzzle
I’ve been hooked on the Wordle-style daily puzzle format for a while. One game a day, everyone gets the same one, compare with friends. There’s something about the constraint that makes it satisfying. You can’t binge. You just get your one shot and move on.
I wanted something in that space but more spatial. Draw paths through a grid of numbers where each path sums to exactly 18. Use every cell. Minimum three cells per line. No diagonal moves. Simple rules, but the grid forces you to think several moves ahead because a greedy line now can strand cells later.
I couldn’t find a game that scratched that exact itch. So I built SumTrails. The full source is on GitHub.
What the game does
You get a 7x7 grid filled with numbers 1 through 9. Your job is to draw connected paths (up, down, left, right) where each path’s numbers add up to 18. Every cell on the grid needs to be part of a completed line. Three cells minimum per line.
There’s a daily puzzle that resets at 8am Eastern, and a practice mode for when you’ve already finished today’s. The daily puzzle is seeded, so everyone worldwide plays the same grid.
The stack
React Native + Expo SDK 54 for cross-platform from one codebase. iOS, Android, and web. I’d already built a few Expo apps and knew the workflow.
TypeScript in strict mode. For a game with this much state (grid, paths, undo stacks, hints, solution validation), types caught real bugs during development.
Zustand for state management. Lightweight and lets me keep the game logic as pure functions separate from React.
React Native Reanimated for gesture handling. Drawing paths needs to feel instant. More on that below.
seedrandom for deterministic puzzle generation. This is how everyone gets the same daily puzzle.
The hard part: generating puzzles that are actually fun
The gameplay was straightforward to build. The puzzle generator nearly broke me.
My first approach was random: fill the grid with numbers, then verify that a valid solution exists. This sounds reasonable until you realize that checking solvability on a 7x7 grid is a combinatorial explosion. Most random grids aren’t solvable at all, and the ones that are tend to be boring, with long straight lines running across the whole board.
So I flipped it. Instead of generating a grid and hoping it’s solvable, I generate the solution first and derive the grid from it. The algorithm starts from the center of the grid with a seed shape (L, S, Z, spiral, staircase) and tiles outward with random walks. Then it assigns values to each path so they sum to exactly 18:
function assignValuesToPath(
pathLength: number,
targetSum: number,
valueRange: { min: number; max: number },
rng: () => number
): number[] | null {
const { min, max } = valueRange;
const minPossible = pathLength * min;
const maxPossible = pathLength * max;
if (targetSum < minPossible || targetSum > maxPossible) {
return null;
}
const values: number[] = [];
let remaining = targetSum;
for (let i = 0; i < pathLength - 1; i++) {
const cellsLeft = pathLength - i;
const minVal = Math.max(min, remaining - (cellsLeft - 1) * max);
const maxVal = Math.min(max, remaining - (cellsLeft - 1) * min);
if (minVal > maxVal) {
return null;
}
const value = Math.floor(rng() * (maxVal - minVal + 1)) + minVal;
values.push(value);
remaining -= value;
}
values.push(remaining);
return shuffleArray(values, rng);
}
Each cell’s value is constrained so the remaining cells can still hit the target. The last cell gets the exact remainder. Shuffle at the end so values don’t cluster predictably.
But that alone produced boring puzzles. Paths were too straight. The secret sauce was direction-aware scoring during path generation. The algorithm tracks how many horizontal vs. vertical steps it’s taken globally and biases new paths toward the underrepresented direction. It also scores turns higher than straight runs:
const scoredNeighbors = neighbors.map(pos => {
let score = futureNeighbors.length * 0.8;
if (lastDirection) {
const isTurn = !(
direction.row === lastDirection.row &&
direction.col === lastDirection.col
);
if (isTurn) score += 2.0;
if (consecutiveStraight >= 2 && isTurn) score += 1.5;
if (consecutiveStraight >= 2 && !isTurn) score -= 0.5;
}
// Boost the underrepresented direction globally
if (isHorizontalStep && bias < 0) {
score += Math.abs(bias) * 2.0;
} else if (isVerticalStep && bias > 0) {
score += Math.abs(bias) * 2.0;
}
return { pos, score };
});
This produces paths that wind and turn, which makes them harder to spot and more satisfying to find.
The daily puzzle seed
Every player gets the same daily puzzle because the generation is deterministic. The puzzle ID is a date string, and it becomes the seed for a pseudo-random number generator:
export function generateDailyPuzzle(date: Date = new Date()): GameState {
const puzzleId = getDailyPuzzleId(date);
const seed = `sumtrails-${puzzleId}`;
return generatePuzzleWithSeed(seed, puzzleId, 'daily');
}
The reset happens at 8am Eastern, not midnight. This was deliberate: I didn’t want the puzzle changing while people are still up playing. The timezone math handles DST manually by calculating the 2nd Sunday of March and 1st Sunday of November. If it’s before 8am Eastern, you get yesterday’s puzzle.
What went wrong
Early puzzle generation was bad. Not “slightly off” bad. Fundamentally broken.
The first few versions would produce puzzles where hints would lead you into dead ends. The hint system found a valid line (cells that sum to 18), suggested it, and the player would complete it only to discover the remaining cells couldn’t form any more valid lines. The puzzle was now unsolvable despite the hint being technically correct.
The fix was to never suggest a line unless the remaining board passes a solvability check:
for (let i = 0; i < maxLinesToCheck; i++) {
const line = validLines[i];
const newGrid = markCellsAsSpent(grid, line);
const remainingCells = countAvailableCells(newGrid);
if (remainingCells === 0) {
return line;
}
if (hasReasonableSolvability(newGrid, targetSum, minLineLength)) {
return line;
}
}
// IMPORTANT: Do NOT fall back to validLines[0] - that was the bug!
return null;
That comment in the code is there for a reason. I removed the unsafe fallback three separate times before I stopped re-adding it. The temptation to “just return something” when no safe hint exists was strong. But returning an unsafe hint is worse than returning nothing, because it actively misleads the player.
Where it is now
SumTrails is live on the web, iOS, and Android. The daily puzzle works. The generator produces interesting grids consistently. The hint system no longer lies to you.
It’s a small game. I built it because I wanted a daily puzzle to solve, and now I have one. Some days the 7x7 grid takes me two minutes, some days it takes ten. That variance is what keeps it interesting.
What I’d do differently
Write tests for the generator from day one. I wrote the core game logic tests early, but I treated the puzzle generator as a “get it working and move on” thing. That was a mistake. The hint solvability bug would have been caught immediately by a test that plays through a hint sequence and verifies the board stays solvable. Instead it was a bug report from real usage.
Invest in puzzle quality metrics sooner. I eventually built scoring that tracks direction balance, path variety, and difficulty. But for the first couple weeks, “does it produce a valid grid” was my only bar. Valid and fun are very different things.
Separate the generator into its own package. The puzzle generation code is pure TypeScript with zero React dependencies. It could be its own library, tested and iterated independently. Keeping it in the app repo meant I’d sometimes skip generator improvements because I was focused on UI work.
Share this post
Stay in the loop
Get notified when I publish new posts. No spam, unsubscribe anytime.