You played it clean. No reckless foundation grab, no card stranded somewhere stupid — and the deal still died on you. Most Solitaire apps will let you sit with that, quietly, like maybe it was you. We didn't want to ship the version of the game that gets to blame you for a deal that was never beatable in the first place.

So before any board reaches you, we make it prove itself.

Here's the uncomfortable fact underneath all of this: a big chunk of randomly shuffled Klondike deals are flat-out impossible. No legal sequence of moves wins them, ever — somewhere around one in five to one in six, by the commonly cited figure. Shuffle blind and you're dealing players a coin flip with a smile on it. We decided the coin flip wasn't going to be our problem to pass along. This post is how we actually got rid of it: the solver we wrote, the numbers it spat out, and the one rule we refused to bend.

Klondike Solitaire on PlayEye showing a dealt board with seven tableau columns, the stock and waste piles, and four empty foundations
Every board you see in PlayEye's Klondike Solitaire has already been beaten by our solver before it reaches you.

The core decision: don't build winnable, screen for it

There are two honest ways to guarantee a deal can be won, and they are not equally honest.

You can construct a solvable game backwards from a finished one — start from a won board and un-play it into a shuffle. Fast, foolproof, and slightly fake. Deals built that way carry a faint fingerprint; experienced players feel the artificiality even when they can't name it. Or you can generate ordinary random shuffles — the exact same deals a player would get anywhere else — and throw out the ones that can't be won. Slower. More expensive. Real.

We chose real. The deals on PlayEye are statistically identical to what you'd be dealt on any other table; the only difference is that the unwinnable ones never make it to you. That choice is the whole post — everything below is just what it costs to keep it.

And what it costs, first, is a solver. Not a hint engine that nudges you toward the next good move. Something that takes a fully dealt board and answers one brutal yes-or-no question: is there any line of play that wins this?

"Thoughtful" solitaire: a player with X-ray vision

The solver we wrote plays what's called thoughtful solitaire. It's allowed to see every card — including the ones lying face-down, the ones you'd normally have to earn. Picture a player reading the buried tableau and the entire stock order at a glance, and ask whether that player can win. If the deal beats even them, it's genuinely impossible, and no human was ever getting through it. That's the standard academic definition of a winnable Klondike deal, and it's the bar we measured against.

The design we settled on is right there in the header comment of our solver:

 - Compact, hashable state representation (string key) for memoization.
 - Depth-first search with move ordering driven by heuristics
   (foundation promotions first, then moves that flip a face-down card,
   then other tableau/waste moves, then stock draws last).
 - "Safe automatic foundation" pruning: a card that can never again be
   needed to receive an opposite-color card on the tableau is auto-played.
 - Time / node budget. On timeout we return `false` (unknown == not adopted),
   so there are NO false positives — only deals proven winnable are kept.

Two of those lines do almost all the work. Worth slowing down on both.

The first is safe-foundation autoplay. Klondike's branching factor is a monster — every position forks into a small forest of legal moves, and a naive search drowns in its own choices before it ever finds a win. But a lot of those choices are fake choices. If both black foundations are already up to the 6, a red 7 can never again be needed on the tableau to host a black 6 — so you just bank it. No branching, no regret. Greedily playing every provably safe card before the search splits collapses an enormous slice of the tree, and it never once costs you a winnable line. It's the difference between a search that finishes and a search that's still thinking when the sun comes up.

The second is the canonical state key. The seven tableau columns can be shuffled around without changing whether a deal is winnable — column order is cosmetic, not structural. So before we hash a position to remember it, we sort the columns. Now two arrangements that are really the same position hash to the same key, and the search never wastes time re-exploring a board it already cracked under a different seating chart:

function stateKey(s: SState): string {
  const colKeys = s.cols.map((c) => `${c.down.length}:${c.up.join(',')}`)
  colKeys.sort()
  return s.found.join(',') + '|' + colKeys.join(';') + '|' +
    s.stock.join(',') + '/' + s.waste.join(',')
}

Small function. It's the reason the solver doesn't solve the same deal forty times before noticing.

The one rule we refused to break: no false positives

This is the line in the whole system that matters most, so here it is plainly. The solver runs on a strict budget — five seconds per deal. If it can't find a win in that window, it does not guess, does not round up, does not give the deal the benefit of the doubt. It returns "not solvable," and we throw the seed away.

That's conservative on purpose, and it costs us. Every so often we discard a deal that genuinely was winnable and just happened to be a slow solve — a board that needed six seconds, not five, and got the axe at the bell. We are completely fine with that. Because the opposite mistake — adopting a deal we couldn't prove, then handing it to you with a promise it's beatable — is the one sin this whole project exists to avoid. Underselling a few good deals is a rounding error. Lying to a player about a board is the thing we built all of this to never do.

Every seed in the pool is a deal the solver beat, cards face-up, inside five seconds. No exceptions, no asterisks.

What the generation run actually banked

The seed pool is built at compile time. A script hammers the solver with random shuffles and keeps only the ones it can prove — and it keeps Draw 1 and Draw 3 separately, because their winnability genuinely differs and pretending otherwise would be sloppy. When the dust settled, here's exactly what we banked:

ModeVerified winnable dealsApproval rateAvg search nodes / solve
Draw 1468
Draw 359969.5%~85,220
Total1,067

1,067 verified deals — 468 for Draw 1, 599 for Draw 3. Every single one proven, not estimated, not "probably fine." A board you load isn't likely winnable. It's a board we already beat and wrote down.

69.5% of the Draw-3 shuffles we tried passed. Roughly three in ten random deals showed up, failed to prove themselves in five seconds, and got quietly dropped on the floor. You'll never meet them. That's the point.

~85,220 search nodes per solved Draw-3 deal, on average. That's how deep into the game tree the solver had to dig, per board, just to be sure a winning line existed instead of merely hoping. Every "yes" in that pool cost about eighty-five thousand moves of certainty. Nobody who plays the game will ever see one of them. They paid for your loss being fair.

That ~70% pass rate is a result in its own right — it says random Klondike is winnable far more often than its reputation suggests. But "70% of deals are winnable" and "you should never lose to the deal itself" are not the same promise, and the distance between them is exactly what these 1,067 hand-verified seeds were built to erase.

Determinism: the seed you get is the seed we tested

There's a subtle trap sitting right here, and it would quietly undo everything above. It's worthless to prove seed #3782016403 is winnable in an offline script if the live game deals that same seed into a different board. Prove one thing, ship another — the guarantee evaporates and nobody notices until a player loses an "unlosable" deal.

So the generator and the live game import the exact same deal() function. We don't fork the shuffle, don't keep a "test version" and a "real version" that drift apart over a few commits. And the script doesn't take our word for it — on every run it re-deals a handful of sample seeds and checks the boards come out byte-for-byte identical:

[determinism] OK — deal() reproducible across 6 sample seeds

So when you load a game and the engine pulls a seed from the winnable pool, it's rebuilding the precise board our solver already walked through — same stock order, same tableau, same face-down cards in the same places. The board in front of you is the board we beat. Literally that one.

What this actually means when you play

It means that when you lose, it's on you now — and weirdly, that's the gift.

Lose a deal in our Klondike Solitaire and you can stop wondering whether the shuffle was rigged, because it wasn't. There was a path. You just didn't find it this time. That's a categorically better loss than a dead end someone handed you on purpose — it's a puzzle you can come back to and beat, not a wall you were quietly walked into. Losing with a way out is the only kind of losing worth offering a player.

This was the engineering story. If you want the statistics underneath it — exactly how many random deals are winnable, why our 70% is a floor rather than a ceiling, and how it stacks against the academic ~82% estimate — we pulled the whole run apart in what percentage of Solitaire games are winnable.

We think it's a meaningfully better Solitaire, and it's the kind of thing you only get from a studio that wrote its own solver instead of dropping in someone else's engine and hoping. If you want a calmer puzzle next, our Sudoku runs on the same stubborn philosophy — every puzzle guaranteed to have exactly one solution — but that's a story for another post.