Card games
Solitaire
Four card games you play alone (Klondike, TriPeaks, Pyramid and Canfield), with endless deals and a check that every win was played fairly.
On this page
Solitaire is one box holding four card games you play alone: Klondike (drawing one or three), TriPeaks, Pyramid and Canfield. In each, you move cards by the game’s rules until the layout is cleared or every card is sorted up by suit.
Every deal is a six-character code. The browser runs the game, and the server only referees (ADR 0022): it checks a win by replaying its whole line of play. A loss counts against your rating only on a deal the solver can win. Solitaire uses the card room’s look (ADR 0009) but not the table service. Its daily deals ended on 2026-10-22 (ADR 0037). The shared pieces are in Solitaire: the replay is the proof and Solitaire in the ratings chapter.
At a glance#
| Part | Where | What it is |
|---|---|---|
| Engine | games/static/games/solitaire/engine/ |
solitaire.js (what the rest calls), one module per game (patience.js is Klondike and Canfield), stock.js, solver.js, cards.js, rng.js |
| Interface | games/static/games/solitaire/js/ |
app.js (routes), play.js (a deal in progress), table.js (cards and pointer), geometry.js (layout), finder.js and worker.js (winnable deals), rate.js (win rate), and the rest |
| Look | games/static/games/solitaire/solitaire.css |
The card room’s felt and faces, Fraunces for titles |
| Server | solitaire/ |
api.py (wins, losses), referee.py, models.py (DailyDeal, kept from the dailies, Solve, Loss, Winnable), views.py (boards, account), times.py |
| Referee | referee/solitaire.mjs |
The engine under Node: check, winnable |
| Checks | tools/solitaire/ |
check.mjs (npm run check:solitaire), rules.mjs (the rules rewritten from the design notes), fit.mjs (layout), rate.test.mjs |
| Tests | tests/test_solitaire.py |
Server, posting, boards, the real referee, cheating |
| Design notes | docs/solitaire.md |
The rules as played, variants not taken, David’s rulings, open questions |
The rules in code#
Cards, the shuffle, a state#
A card is a number, suit × 13 + rank − 1 (0 is the ace of spades, 51 the king of clubs); the interface names them as the card kit does ("QH"). A deal is a Fisher–Yates shuffle, the standard fair shuffle, drawing from a seeded stream keyed by the game and the code:
games/static/games/solitaire/engine/cards.js shuffled · lines 29–38/** A shuffled deck for this game and code: the same every time, everywhere. */
export function shuffled(game, code) {
const r = stream(`solitaire|${game}|${code}`);
const deck = Array.from({ length: 52 }, (_, i) => i);
for (let i = 51; i > 0; i--) {
const j = r.int(i + 1);
[deck[i], deck[j]] = [deck[j], deck[i]];
}
return deck;
}
A seeded stream gives the same numbers every time for the same seed. games/static/games/solitaire/engine/rng.js is the site’s sfc32 with integer math only, so every browser and Node deal the same cards (The seeded stream). It’s a copy on purpose, so a change elsewhere can’t change a Solitaire deal. Klondike’s two variants share a shuffle name, so a code deals the same cards drawing one or three. A state is plain JSON (arrays of card numbers), copied with structuredClone.
Boards#
A board is a game with its variant: what a leaderboard ranks and a post names.
games/static/games/solitaire/engine/solitaire.js BOARDS · lines 23–33// A board is a game with its variant: what a leaderboard ranks, and what a
// posted solve names. Klondike's two share their deals (a code shuffles the
// same cards for either), so one daily serves both. `proves`: the solver
// tries every legal move, so a search that runs dry proves no win.
export const BOARDS = {
'klondike-1': { game: 'klondike', label: 'Klondike · Draw 1', options: { draw: 1 }, nodes: 100000 },
'klondike-3': { game: 'klondike', label: 'Klondike · Draw 3', options: { draw: 3 }, nodes: 200000 },
tripeaks: { game: 'tripeaks', label: 'TriPeaks', options: {}, nodes: 200000, proves: true },
pyramid: { game: 'pyramid', label: 'Pyramid', options: {}, nodes: 200000, proves: true },
canfield: { game: 'canfield', label: 'Canfield', options: {}, nodes: 200000 },
};
nodes is the board’s long-look budget for the solver. proves marks the two games whose solver tries every legal move, so a search that runs dry proves the deal can’t be won.
The four games#
Each is a standard version; the design notes list the variants not taken and why.
| Klondike | TriPeaks | Pyramid | Canfield | |
|---|---|---|---|---|
| Deal | 7 piles of 1–7, top card up; 24 in the stock | 28 in three peaks over a bottom row of 10 (face up); 1 card starts the discard pile; 23 in the stock | 28 face up in 7 rows; 24 in the stock | Reserve of 13 (top up); the next card starts its suit’s foundation and sets every foundation’s base rank; 4 piles of 1; 34 in the stock |
| Play | Foundations up by suit A–K; piles down in alternating colors; any face-up run moves; only a king (or a run headed by one) to a space; a face-down card left on top turns up by itself | A free peak card onto the discard pile if one higher or lower, suit ignored, K–A wraps | Remove two free cards adding to 13 (A 1, J 11, Q 12); a king alone; the discard pile’s top card is free | Foundations up by suit from the base, turning the corner (J Q K A 2 …); piles down alternating, also turning the corner; a space fills from the reserve by itself, then any card or run |
| Stock | Draw 1 or 3; no limit on redeals | One at a time; no redeal | One at a time; three passes (two redeals) | Draw 3; no limit on redeals |
| Off the foundation | Allowed | — | — | Allowed |
| Won when | All 52 up | The peaks cleared (the stock needn’t be) | The pyramid cleared (stock and discard needn’t be) | All 52 up |
There’s no scoring: a win is a win, and the boards rank time and moves. Klondike and Canfield are one module, make() with four switches (piles, wrap, kingsOnly, reserve):
games/static/games/solitaire/engine/patience.js klondike · line 274export const klondike = make({ id: 'klondike', piles: 7, wrap: false, kingsOnly: true, reserve: false });
Moves#
Moves are short strings; piles count from 0. legal(state) lists them, and play(state, move) makes one in place (throwing if it’s illegal) and returns an undo token:
| Move | Means | Games |
|---|---|---|
d |
Turn the stock onto the discard pile | All |
n |
Turn the discard pile over into the stock (a new pass) | Klondike, Pyramid, Canfield |
w>f, w>t3 |
The discard pile’s top card to its foundation, or onto pile 3 | Klondike, Canfield |
r>f, r>t3 |
The reserve’s top card | Canfield |
t2>f |
Pile 2’s top card to its foundation | Klondike, Canfield |
t2:3>t5 |
Pile 2’s top 3 cards onto pile 5 | Klondike, Canfield |
fH>t5 |
The hearts foundation’s top card back down onto pile 5 | Klondike, Canfield |
p12 |
TriPeaks: peak card 12 onto the discard pile. Pyramid: remove king 12 | TriPeaks, Pyramid |
p21+p26, p21+w, w |
Pyramid: a pair from the pyramid, a pyramid card with the discard pile’s top, the discard pile’s king | Pyramid |
u3 |
Not a move: take back the last three moves (only in a posted line) | Posted lines |
What the rules do by themselves (a face-down card turning up, a Canfield space filling from the reserve) isn’t a move. A posted move must match this pattern:
games/static/games/solitaire/engine/solitaire.js MOVE_RE · lines 51–53// A move string, at its longest "t6:13>t2" or "p20+p27". "u3" isn't a move
// but an undo of the last three moves (see replay); a posted line holds both.
export const MOVE_RE = /^(?:[dnw]|[wr]>(?:f|t\d)|t\d>f|t\d:\d{1,2}>t\d|f[SHDC]>t\d|p\d{1,2}(?:\+(?:p\d{1,2}|w))?|u[1-9]\d{0,2})$/;
Assists the engine computes#
autoMove(“Move safe cards up”): a foundation move that can’t spoil anything, or a Pyramid king, which pairs with nothing. A Klondike card is safe when nothing left could want to sit on it. Aces and twos always are; otherwise the opposite color’s cards one lower must be up, and the same color’s two lower. In Canfield, turning the corner makes the base card a parent of the cards that finish the opposite colors’ suits, so it goes up by itself only once those suits are done:
games/static/games/solitaire/engine/patience.js make.// A card is safe to put up when nothing left could want to sit on it: the
// opposite color's cards a step lower are already up (and the same color's
// two steps lower, which those could want). Aces and twos always are, in
// Klondike. In Canfield the corner makes the card a suit starts with (level
// 0) a parent of the cards that finish the opposite colors' suits, so it
// goes up by itself only when those suits are done; the second (level 1)
// always can, as the cards that could sit on it can go up themselves.
function safe(s, c) {
const n = level(s, c), su = suit(c);
if (wrap && n === 0) return OPPOSITE[su].every(o => s.found[o] === 13);
if (n <= 1) return true;
const [a, b] = OPPOSITE[su];
return s.found[a] >= n && s.found[b] >= n && s.found[twin(su)] >= n - 1;
}
canFinish(Finish): nothing hidden, nothing in the stock, discard pile or reserve, and every card can go up in turn from where it lies. Canfield’s corner can bury a card under one that must wait for it, hence the last check.hint: the best-scored move from what the player can see, else a stock tap, else nothing. The move itself ignores the stock’s hidden order. It returns nothing only whenisStuckis true, and then the status already says “Out of moves”, so an empty Hint gives nothing away.isStuck(“Out of moves”): no move that could help, now or from any card the stock will show. This does read the stock’s order, as any “no more moves” notice must.
The solver#
games/static/games/solitaire/engine/solver.js is one depth-first search for all four games: it follows a line of play as far as it goes, then backs up.
games/static/games/solitaire/engine/solver.js search · lines 17–52/**
* Search from `state` (left as it was).
* @returns {{ won: boolean, line: string[], nodes: number, exhausted: boolean }}
* `line` is every move from `state` to the win, as move strings.
*/
export function search(game, state, { nodes: budget = 200000 } = {}) {
const seen = new Set();
const trail = [];
let nodes = 0, cut = false;
const go = depth => {
const mark = trail.length;
for (let m; (m = game.safeMove(state));) trail.push(game.apply(state, m));
if (game.won(state)) return true;
const k = game.key(state);
if (!seen.has(k)) {
seen.add(k);
if (++nodes > budget || depth > DEPTH) cut = true;
else {
for (const [, m] of game.candidates(state)) {
trail.push(game.apply(state, m));
if (go(depth + 1)) return true;
game.revert(state, trail.pop());
if (nodes > budget) break;
}
}
}
while (trail.length > mark) game.revert(state, trail.pop());
return false;
};
const won = go(0);
const line = trail.flatMap(t => spell(t.m));
while (trail.length) game.revert(state, trail.pop());
return { won, line, nodes: Math.min(nodes, budget), exhausted: !won && !cut };
}
- It knows every card, face-down ones too. That’s what “winnable” means for every solitaire that claims it.
- It makes safe moves without branching (
safeMove, the auto-move rule). - It tries each game’s best moves first (
candidates). In Klondike and Canfield, a safe card up scores 95, the reserve to a foundation 90, uncovering a face-down card 80 plus how many lie under it, and a foundation card back down 5; pointless moves (a king from space to space, a run split for nothing) are left out. TriPeaks plays before drawing, preferring plays that turn up more cards. Pyramid takes kings, then the pairs that free the most, then pairs with the discard pile, then the stock. - In Klondike and Canfield a stock card is one move, “tap until this card shows, then play it”.
stock.reachabletreats the stock and discard pile as one fixed cycle, which keeps the search shallow. The returned line spells out the taps. - It remembers every position (
key). Klondike’s and Canfield’s keys sort the interchangeable tableau piles and treat every stock position on the same cycle as one. TriPeaks’ key is exact; Pyramid’s is a 53-bit hash. - It stops after
nodespositions, or below a depth of 600.
solve(state, { nodes }) returns { won, line, nodes, proven }. A win is a real line of play, and the checks replay every one through the same referee as a player’s. Running out of budget proves nothing. For TriPeaks and Pyramid, whose candidates are every legal move, searching every position without a win (exhausted, proven) proves the deal can’t be won. Klondike’s and Canfield’s pruning means even an exhausted search proves nothing.
Two budgets#
games/static/games/solitaire/engine/solitaire.js CHECK_NODES · lines 40–46// How hard the solver looks at a candidate deal before moving on to the
// next, for a daily or a checked endless deal. Small on purpose: trying
// more deals briefly finds a winnable one sooner than trying each one long
// (about 0.2s on average for Klondike draw 3, the slowest; measured with
// tools/solitaire/check.mjs). Counted in positions, not time, so the
// referee picks the same daily on any machine.
export const CHECK_NODES = 30000;
The quick look, 30,000 positions, picks checked endless deals for Only winnable deals, as it once picked the dailies. Trying many deals briefly finds a winnable one sooner than trying each one long, and a budget in positions, not seconds, picks the same deal on any machine. The long look, the board’s own nodes (100,000 for Draw 1, 200,000 for the rest), decides whether a lost deal could have been won.
games/static/games/solitaire/engine/solitaire.js firstWinnable · lines 183–194/**
* The first of `codes` whose deal the solver wins within its budget, for a
* daily (the referee runs this with codes from the site's secret key) or a
* checked endless deal. Returns { code, tried, line } or null.
*/
export function firstWinnable(boardKey, codes, { nodes = CHECK_NODES } = {}) {
for (let k = 0; k < codes.length; k++) {
const out = solve(deal(boardKey, codes[k]), { nodes });
if (out.won) return { code: codes[k], tried: k + 1, line: out.line };
}
return null;
}
Measured#
On 2026-10-19, under Node 24 on David’s Mac: 200 fresh deals a board, solved at both budgets and every win replayed, then 40 runs of 64 codes through firstWinnable. node tools/solitaire/check.mjs 200 gives the same kind of numbers.
| Klondike · Draw 1 | Klondike · Draw 3 | TriPeaks | Pyramid | Canfield | |
|---|---|---|---|---|---|
| Won at the long look | 77% | 76% | 95% | 62% | 87% |
| Won at the quick look | 76% | 71% | 87% | 56% | 82% |
| Proved unwinnable | — | — | 0 | 20 of 200 | — |
| Long look: mean / slowest | 252 ms / 1.6 s | 245 ms / 2.1 s | 13 ms / 0.2 s | 79 ms / 0.3 s | 88 ms / 2.1 s |
| Codes a winnable pick tries, mean / most | (picked on Draw 3) | 1.30 / 4 | 1.32 / 3 | 2.15 / 7 | 1.18 / 3 |
“Won” is a floor, since the solver only certifies wins. The referee accepted every winning line. A solver’s line counts every stock tap, so lengths range from about 50 moves (TriPeaks) to over 200 (Canfield). A winnable pick took under a second at worst, and none needed more than seven of its 64 candidates.
A deal won drawing three can be won drawing one, but the solver’s Draw 1 long look doesn’t always find it: of 144 deals the Draw 3 quick look won, the Draw 1 long look won 138.
The dailies it had#
Until 2026-10-22 each game had one deal a day. Its 64 candidate codes came from the site’s secret key (solitaire|<date>|<game>|<n>), and the referee kept the first its solver won at the quick look on the game’s DAILY_BOARD (for Klondike, the harder Draw 3). David ended them (ADR 0037): four Solitaire dailies crowded the Dailies page, and Solitaire works as well endless.
Gone: the picking, the daily call, the #/daily/ routes (an old address quietly lands on the home), the home’s Today’s deal and streak, and the day’s board.
games/static/games/solitaire/js/app.js route · lines 52–71function route() {
closeAll();
leave?.();
leave = null;
const parts = location.hash.replace(/^#\/?/, '').split('/').filter(Boolean);
if (parts[0] === 'daily') {
// (an old daily's address: the home, in its place in the history)
location.replace('#/');
return;
}
if (parts[0] === 'd') {
const [, board, code] = parts;
if (isBoard(board) && CODE_RE.test((code || '').toUpperCase())) return open(board, code.toUpperCase());
toast('That deal address doesn’t lead anywhere.');
location.hash = '#/';
return;
}
home(root, { onPlay: (board, code, ok) => { if (ok) checked.add(`${board}/${code}`); location.hash = `#/d/${board}/${code}`; } });
window.scrollTo(0, 0);
}
Kept: the DailyDeal rows, each win and loss posted on its day (daily set), the all-time board’s best daily times and fewest daily moves, the fields already rated (a rebuild rates them again), and each player’s stats. DAILY_BOARD and firstWinnable stay in the engine for Only winnable deals.
Endless deals#
Deals are random by default, like a real deck (David’s ruling), so some can’t be won. The code comes from crypto.getRandomValues. With Only winnable deals on (Options, off by default), games/static/games/solitaire/js/finder.js has a worker run firstWinnable over fresh codes, six at a time, up to eight batches, and tags the deal “Checked”. If there’s no worker or it takes over twelve seconds, the deal comes unchecked and untagged. A shared #/d/<board>/<code> link opens that deal for anyone, with no claim of being checked.
What the browser holds#
Everything. The engine deals every card, so the browser knows the face-down cards and the stock’s order, and can run the solver. That’s fine for a game played alone. The server decides what counts: it replays a win’s whole line and does its own count.
Posting and the referee#
A win#
A win posts { board, code, engine, moves, seconds }. moves is the whole line of play: every move in order, and each undo as uN (one Undo press, or a second tap that took a move back, with the helper moves it took along). solitaire/api.py checks the shape (a known board, a code, one to 10,000 entries each matching the pattern), a believable time, the engine version, and at most 120 checks a player an hour. Then it replays the line:
games/static/games/solitaire/engine/solitaire.js replay · lines 196–231/**
* The referee: deal the board's code and play `moves` through. A solve
* counts only if every move is legal and the last one wins (nothing after
* the win). A line is the player's whole play, undos included: "u3" takes
* back the last three moves (an Undo press and the helpers' moves it
* took with it), and costs one. The count that ranks is the moves still
* standing plus one for every undo, so it's worked out here, from the
* line, and never taken from the player.
* Returns { ok: true, moves, kept, undos } or { ok: false, error, at }
* where `at` is the index of the entry that failed.
*/
export function replay(boardKey, code, moves) {
if (!BOARDS[boardKey]) return { ok: false, error: 'unknown board', at: -1 };
if (!CODE_RE.test(code)) return { ok: false, error: 'bad code', at: -1 };
if (!Array.isArray(moves)) return { ok: false, error: 'no moves', at: -1 };
const state = deal(boardKey, code);
const g = rules(state);
const done = [];
let undos = 0;
for (let k = 0; k < moves.length; k++) {
if (g.won(state)) return { ok: false, error: 'moves after the win', at: k };
const back = typeof moves[k] === 'string' && UNDO_RE.exec(moves[k]);
if (back) {
const n = Number(back[1]);
if (n > done.length) return { ok: false, error: 'nothing to undo', at: k };
for (let i = 0; i < n; i++) g.revert(state, done.pop());
undos++;
continue;
}
const m = typeof moves[k] === 'string' && g.moves(state).find(x => x.s === moves[k]);
if (!m) return { ok: false, error: 'illegal move', at: k };
done.push(g.apply(state, m));
}
if (!g.won(state)) return { ok: false, error: 'not won', at: moves.length };
return { ok: true, moves: done.length + undos, kept: done.length, undos };
}
Every move must be legal where it’s made. An undo can’t take back more than was played, nothing may follow the win, and the line must end won. The count for the fewest-moves board is moves still standing plus one per undo, worked out here, never taken from the player. So peeking under a card and taking it back costs a move. The first win per player per deal and board stands. The reply gives how many deals the player has won and the rating change.
solitaire/api.py _done · lines 99–108def _done(solve, already=False):
# Times and moves were ranked on the dailies only (docs/solitaire.md), and
# there are none now: a win counts toward how many the player has solved.
return JsonResponse({
"ok": True, "already": already, "time": clock(solve.seconds), "seconds": solve.seconds, "moves": solve.moves,
"solved": Solve.objects.filter(user=solve.user).count(),
"url": reverse("solitaire:board") + f"?board=alltime&game={solve.board}&by=solved",
# Its rating against par (docs/ratings.md).
"rating": _rating(solve),
})
| Board | Fastest believable (seconds) |
|---|---|
| klondike-1 | 30 |
| klondike-3 | 30 |
| tripeaks | 15 |
| pyramid | 15 |
| canfield | 30 |
solitaire/times.py, MIN_SECONDSA loss#
A signed-in player’s lost deal posts { board, code, engine, how } (gaveup or stuck), with no moves. A deal is lost when it’s given up, left half played for another deal on its board (Leaving a deal), out of moves with nothing to undo, or restarted while out of moves. A loss that can’t be sent (offline, or one of the statuses passing names) waits in the browser’s losses and is posted on the next load, before any held win, because the first result is the one rated. A refusal drops it.
solitaire/api.py lose · lines 116–152@require_POST
def lose(request):
"""A deal given up on or out of moves, posted by a signed-in player so it
can count against their rating (docs/ratings.md): only if the referee's
solver wins the deal. The first result for a deal stands; nothing here
touches the boards."""
if not request.user.is_authenticated:
return _fail(401, "Sign in to keep your rating.")
try:
body = json.loads(request.body)
board, code, how = str(body["board"]), str(body["code"]).upper(), str(body["how"])
engine = _number(body["engine"])
except (ValueError, KeyError, TypeError):
return _fail(400, "That submission was incomplete.")
if board not in BOARDS or not CODE_RE.fullmatch(code) or how not in (Loss.GAVE_UP, Loss.STUCK):
return _fail(400, "That submission didn’t describe a game.")
if engine != referee.ENGINE:
return _fail(422, "This deal came from a different version of the game, so it can’t be checked. Reload the page for the latest.")
deal = {"user": request.user, "board": board, "code": code, "engine": engine}
if Solve.objects.filter(**deal).exists():
return JsonResponse({"ok": True, "already": True, "winnable": True, "rating": None})
existing = Loss.objects.filter(**deal).first()
if existing:
return _lost(existing, already=True)
known = Winnable.objects.filter(board=board, code=code, engine=engine).first()
if known is None:
if not _may_check(request.user):
return _fail(429, "That’s a lot of deals in an hour. Try again later.")
try:
known, _ = Winnable.objects.get_or_create(board=board, code=code, engine=engine, defaults={"won": referee.winnable(board, code)})
except referee.RefereeDown as e:
return _fail(503, str(e), retry=True)
try:
loss = Loss.objects.create(**deal, how=how, winnable=known.won)
except IntegrityError:
return _lost(Loss.objects.get(**deal), already=True)
return _lost(loss)
The referee’s winnable (the long look) checks each deal once, and Winnable keeps the answer per board, code and engine, however many players lose it. Losses touch no board.
Rating#
There’s one rating for the whole box (four provisional ratings would be noise), but each board has its own par (Solitaire). Results now are all endless, rated as posted. Old daily fields keep their ratings, and a rebuild rates them the same way:
- A daily win waited for midnight and joined the day’s field for its board (Klondike’s daily was two fields, Draw 1 and Draw 3), ranked by time.
- A daily loss joined the same field at
LOST, tied below every winner. - An endless win is rated against its board’s par (the median of the last 99 wins there) at half weight, once the board has 5 earlier wins.
- An endless loss counts only if the solver won the deal: a loss against the board’s usual player, at half weight. Otherwise the result sheet says “Not rated: the solver couldn’t show this deal can be won.”
- The first result for a deal stands. A loss after a win isn’t taken. A win after a loss is kept for the boards but not rated.
Worked: a day’s Klondike · Draw 3, from when it had dailies (guide/worked/solitaire.py). Three wins and one give-up, rated together at midnight:
| Player | Before | Result | After | Change |
|---|---|---|---|---|
| Bea | 1560 ± 90 | Won in 6:52 | 1570 | +10.3 |
| Cal | 1500 ± 140 | Won in 4:58 | 1570 | +70.4 |
| Dee | 1640 ± 80 | Won in 10:55 | 1628 | −12.3 |
| Eli | 1520 ± 100 | Gave up | 1490 | −29.5 |
guide/worked/solitaire.py, DAILY.rowsCal, uncertain and fastest, gains most. Dee, the strongest, finished last of the winners and loses a little. Eli’s give-up ranks below everyone. The same week Dee wins an endless TriPeaks in 1:40 against a par of 2:30 (+0.9, at half weight), and gives up an endless Pyramid the solver won, costing 4.7.
The interface#
Screens and routes#
#/ is the home: a tile per game with your win rate, and the chosen board’s panel. #/d/<board>/<code> is any deal, shareable. All 52 cards stay elements while they move (table.js), so a move glides instead of redrawing. geometry.js lays out each game for any window without the DOM, and fit.mjs checks every card stays on the board at six window sizes.
Moving#
- Tap a card (tap to move, on by default) and it goes to its best place: a foundation, then a pile, then a space. Tap it again within four seconds to take that move back (an undo, counted) and try the next place. With tap to move off, double-tap.
- Drag a card or run (after 6 px) onto a legal place. Pyramid pairs by tapping or dragging one card onto its partner.
- Tap the stock to draw or redeal.
- Keys: arrows move a cursor; Space picks a card up and Enter puts it down (or taps), Escape lets go. Z undo, H hint (or Finish), F finish, P pause, ? How to play.
The helpers#
All are on by default except Only winnable deals, each with its own row in Options:
| Option | What it does |
|---|---|
Move safe cards up (safe) |
After each of your moves, autoMove runs until it returns null. The moves are yours and counted, and one Undo takes them back with your move |
Finish the deal (finish) |
Once canFinish, the rest go up on their own |
Hint button (hints) |
hint(): a move worth making from what’s face up, marked on the table |
Show where a card can go (targets) |
Lights the legal places while you drag |
Undo button (undo) |
Unlimited. Each press takes back your last move and the helper moves after it, and adds one to the count. Off: neither the button nor Z undoes |
Tap to move (tap) |
Off: double-tap to move |
Only winnable deals (winnable, off) |
Checks new endless deals in a worker before dealing them |
How a deal ends#
A deal is won (the cascade, cascade.js), out of moves (isStuck: said in the status line, then offered once on the result sheet after 900 ms, with Undo still available), or given up (a quiet button that asks “Give up?” in place; the question clears after six seconds or on any change). Each ends on the house result sheet. Share result gives the board and the deal’s number, then the result: “🏆 Cleared · ⏱️ 4:58 · 131 moves”, or for a deal not cleared how far it got, “🃏 23 of 52 home” with each suit’s count (TriPeaks and Pyramid, which have no foundations, count the 28 cards cleared from gone) and “Can you clear it?”. The link opens the deal.
The clock and saving#
The clock runs only while the deal is on screen, not paused, with no sheet open and the tab visible, from first sight of the cards to the winning move. A deal in progress is saved under uo:solitaire:g:<board>/<code>: the groups of moves made (a move and its helper moves are one group, one Undo), the log of everything done including undos, the time and the engine version. A save from another version is ignored. On load, the deal is dealt and the moves replayed.
The win rate per board (rate.js) counts a deal as played from its first move (or when it’s given up), once however often it’s restarted. Giving up, or leaving a deal half played for another, breaks the run of wins. Wins post as soon as they’re won; a guest’s wait until they sign in.
Leaving a deal#
One deal is open per board. Leaving one any way but for another deal on its board (going back, closing the tab, reloading) keeps it saved under “Pick up where you left off” on the home. Playing another deal on its board gives it up. The dock’s New deal gives up the deal on screen at once. Any other deal’s first move gives up every deal still open on its board (the home’s New deal, a shared link, an older deal picked up):
games/static/games/solitaire/js/play.js leaveOthers · lines 404–419/**
* An endless deal's first move here gives up any other endless deal on
* this board still open, however this one was dealt (the dock's New deal,
* the home's, a link, picking up an older one) and however that one was
* left (the way up, closing the tab): played and lost, the run broken and,
* signed in, a loss posted. One endless deal is open per board at a time,
* so every way of leaving one for another counts the same.
*/
function leaveOthers() {
g.abandoned = false;
const posting = HUB && !!user();
for (const left of leaveOpen(board, key, posting ? { lossPosted: true } : {})) {
record.lose(left.key);
if (posting && !left.game.lossPosted) lose({ key: left.key, board, code: left.code, engine: ENGINE_VERSION, how: 'gaveup' });
}
}
games/static/games/solitaire/js/store.js leaveOpen · lines 73–95/**
* Leave every other deal on `board` that was started here and is
* still open (moves standing, not won, given up or already left) for the
* one being played now: each is marked `abandoned` (with `extra`, if
* given), and returned, [{ key, code, game }] (`game` as it was). One
* deal is open per board at a time: leaving a deal some other way
* (the way up, closing the tab) keeps it open to pick up again,
* and only playing another deal on its board gives it up, whatever button
* dealt that one (docs/solitaire.md, "Losses"). A deal with no moves
* standing was never started, so it's never given up; nor is a save
* without this code's stamp (`stamped`).
*/
export function leaveOpen(board, except, extra = {}) {
const out = [];
for (const key of read('recent', [])) {
if (key === except || !key.startsWith(`${board}/`)) continue;
const game = loadGame(key);
if (!stamped(game) || game.solved || game.gaveUp || game.abandoned || !game.groups?.length) continue;
write(`g:${key}`, { ...game, abandoned: true, ...extra });
out.push({ key, code: key.slice(board.length + 1), game });
}
return out;
}
A deal given up this way is lost on the win rate and, for a signed-in player, posted once as a loss (gaveup, lossPosted). So abandoning a deal counts the same whichever way you do it; closing the tab only delays it. Nothing is posted as a page closes, since sendBeacon can’t be relied on and the deal may be picked up again. A deal with no moves standing (never moved, restarted, or all undone) is never given up.
Only a deal this code opened is ever given up or posted. A new deal is stamped when opened (opened), and leaveOpen and the loss post check stamped():
games/static/games/solitaire/js/store.js stamped · lines 47–48/** Whether a saved deal may be given up and posted as a loss. */
export const stamped = game => !!game && game.opened >= OPENED && !game.daily;
A save from before the stamp, especially a daily saved before they ended, plays on and can be picked up, but is never given up for another or posted as a loss. Otherwise the first move after the update would have turned every open save on the board, up to the 120 kept, into a run of rated losses. A deal given up and then picked up again is open again, and its loss still stands.
Accessibility#
The table is one focusable application region. The cursor names each card (“seven of hearts”), picking up and putting down are spoken, refusals are said (“That card has nowhere to go.”), and every move updates a live status line. Under reduced motion cards land at once and the cascade doesn’t run.
Leaderboards and stats#
solitaire/views.py serves, per board:
- All time, opening on most solved. It can also show each player’s best time and fewest moves among their old daily wins, which no longer grow. Ties go to whoever got there first. An old
?board=dailylink lands here. - Ratings: the one Solitaire rating.
solitaire/views.py BY · lines 11–15# How the board ranks: how many deals each player has solved, or their best
# time or fewest moves (undos included, as the referee counts them) on the
# daily deals Solitaire had until 2026-10-22 (docs/adr/0037): those stay
# where they were won, and no endless deal is ranked on either.
BY = {"solved": "Most solved", "time": "Fastest daily", "moves": "Fewest daily moves"}
Endless wins are checked and count toward solved and the rating, but are never ranked on time or moves, since hunting codes for a short line would be a contest of its own. The account page counts every deal won. The Stats tab (dailies/stats.py) shows every deal played, old dailies included, with the win rate and longest run of wins instead of a streak.
Its look#
Solitaire’s personality, in the design system’s personality sheet.
| Family | Cards, played alone: the card room’s table styles and faces, with no table service. Unlike the other card games it’s phone-first. Everything else is the card room’s (Card games). |
| Palette | The table style’s. Its colors keep their jobs: the turn color only ever means “look here” (a hint, a place a card may land, the card you chose), good marks a won deal, and the one fill is the table style’s button. |
| Faces | Fraunces Bold for titles, headings and the win; everything else the table style’s (Retro PC keeps its own) |
| Corners and edges | The table style’s. The empty places are engraved: a dashed rounded outline with a faint suit, a K or ↻. |
| Board | Four layouts (Klondike, Canfield, TriPeaks, Pyramid), each plain numbers from the window and the deal. Cards scale to fit the window and nothing scrolls; a card’s index sits on one row (“10♠”) so a strip of it still says what it is. |
| Emblem and art | Its box on the shelf |
| Voice | A deal, the stock and the discard pile, redeals (“2 redeals left”); “You won” |
| Payoff | The cascade of the classic, kings first, each card leaving its place, bouncing and leaving its trail; then the result sheet rises over the lower table, leaving the cascade in view |
| The glance | Now: the one-line status in the dock. The countdown: the clock and the count of moves. |
| Exceptions | Its seven columns’ cards under 44px (they share the width), and the deal’s name giving way first in the bar at 320px |
Testing#
| Check | What it proves |
|---|---|
npm run check:galaxies:play (tools/galaxies/play.mjs) |
Among Galaxies’ steps: Solitaire’s old #/daily/klondike-1 landing on the home with no daily, in Chrome and WebKit. |
npm run check:solitaire (tools/solitaire/check.mjs, fit.mjs, rate.test.mjs, leave.test.mjs) |
Deals are stable (FIXED fingerprints for ABC234 catch a missed ENGINE_VERSION bump); random play stays legal and undo exact; tools/solitaire/rules.mjs, the rules rewritten from the design notes, agrees on every legal move; assists are sound; the solver holds its rates at both budgets and replay refuses tampered lines; layouts fit; the win rate counts; leaving a deal gives up only what it should, and held losses go first. |
tests/test_solitaire.py |
No daily; posting and its refusals (bad input, wrong lines, fake times, another engine, the hourly limit); boards and the account line; the real referee end to end; undo counting; cheating with short, padded, tampered or foreign lines. |
Changing it#
- Anything that changes which cards a code deals or which moves are legal: bump
ENGINE_VERSIONingames/static/games/solitaire/engine/solitaire.js, updateFIXEDin the check, and runnpm run check:solitaire. Wins posted under the old version can no longer be checked, and games in progress are dropped. - The solver alone (move order, safe moves, budgets): no bump. It decides future
Winnablechecks and which endless deals Only winnable deals offers. Changing a board’snodeschanges which endless losses are rated. - The rules as played: also update
docs/solitaire.md, whichrules.mjsreads. - The interface:
js/; runnpm run check:solitaire(fit) andnpm run check:details.
Known limitations#
- The solver certifies wins, not losses, except in TriPeaks and Pyramid. An endless Klondike or Canfield deal the long look can’t win isn’t rated, though it may be winnable.
- Pyramid’s proof uses a hashed position. A 53-bit collision could in principle prune a win and call a deal unwinnable. TriPeaks’ key is exact.
- The clock is the browser’s, checked only for plausibility (Times).
- A line without its undos is a line like any other, so a player who replays their own win cleanly from a record can post fewer moves.
Known issues#
None open. The five found while writing this chapter were fixed in October 2026.