Here is a miniature FreeCell deal: a few cards in two cascades, two free cells, and one foundation that must fill A, 2, 3, … in order. Click a card to pick it up, then click a destination to drop it. Send every card to the foundation to win.
<p class="hint">A miniature FreeCell deal. The <b>foundation</b> must fill A, 2, 3, 4, 5
in order. You have <b>2 free cells</b> (park one card each) and <b>2 cascades</b>
(only the bottom card moves). Click a card to pick it up, then click where it goes.
Clear every card to win.</p>
<div class="zones">
<div class="row">
<div class="slot free" data-z="F0"></div>
<div class="slot free" data-z="F1"></div>
<div class="gap"></div>
<div class="slot found" data-z="H"></div>
</div>
<div class="row casc">
<div class="col" data-z="C0"></div>
<div class="col" data-z="C1"></div>
</div>
</div>
<div class="status" id="status">Pick a card to move.</div>
<div class="btns">
<button id="solve" type="button">Auto-solve</button>
<button id="reset" type="button" class="ghost">Reset</button>
</div>
<div class="tree-wrap">
<div class="tree-title">Search tree the solver explores</div>
<div id="tree" class="tree"></div>
</div>
* { box-sizing: border-box; }
body { font-family: system-ui, sans-serif; color: #222; margin: 0; }
.hint { font-size: .9rem; color: #444; margin: 0 0 .7rem; line-height: 1.45; }
.zones { background: #11572b; padding: 10px; border-radius: 10px; }
.row { display: flex; gap: 8px; align-items: flex-start; margin-bottom: 10px; }
.row:last-child { margin-bottom: 0; }
.gap { flex: 1; }
.slot, .col { border-radius: 7px; min-width: 44px; }
.slot { width: 44px; height: 60px; border: 2px dashed #2f7d4c; display: flex;
align-items: center; justify-content: center; }
.found { border-color: #ffd76a; }
.col { min-height: 60px; padding: 3px; border: 2px dashed #2f7d4c;
display: flex; flex-direction: column; gap: 3px; }
.card { width: 40px; height: 54px; border-radius: 6px; background: #fff;
border: 1px solid #888; font: 700 16px ui-monospace, monospace;
display: flex; align-items: center; justify-content: center; cursor: pointer;
user-select: none; }
.card.red { color: #c92f3c; }
.card.black { color: #1d2733; }
.col .card { width: 38px; }
.card.sel { outline: 3px solid #ffd76a; outline-offset: 1px; }
.slot.drop, .col.drop { border-style: solid; border-color: #ffd76a; }
.status { font-size: 1rem; font-weight: 600; margin: .6rem 0; min-height: 1.4em; }
.status.ok { color: #0a7d33; }
.status.bad { color: #c92f3c; }
.btns { display: flex; gap: .5rem; flex-wrap: wrap; }
button { font: 600 14px system-ui, sans-serif; padding: .45rem .9rem; border: 1px solid #11572b;
background: #11572b; color: #fff; border-radius: 8px; cursor: pointer; }
button.ghost { background: #fff; color: #11572b; }
.tree-wrap { margin-top: 1rem; }
.tree-title { font-size: .8rem; color: #555; margin-bottom: .3rem; font-weight: 600; }
.tree { font: 12px/1.5 ui-monospace, monospace; white-space: pre; overflow-x: auto;
background: #f5f7f9; border: 1px solid #dde3e8; border-radius: 8px; padding: 8px;
max-height: 220px; min-height: 40px; color: #2b3b4b; }
.tree .win { color: #0a7d33; font-weight: 700; }
.tree .dead { color: #b14; }
const fmt = (s, v) => s.replace(/%(\w+)%/g, (_, k) => String(v[k] ?? ''));
// Miniature FreeCell. Foundation builds A(1)..5 in order. Two free cells, two
// cascades; only the bottom card of a cascade is movable. Solving FreeCell in
// general (n cards) is NP-complete (Helmert, 2003): the auto-solver below does a
// depth-first search with backtracking over the tree of move sequences.
const GOAL = 5; // need ranks 1..5 on the foundation
const RED = new Set(['h','d']);
// Deal: each card = {r: rank, s: suit}. Only ranks 1..5, suit 's' (one suit ⇒ foundation needs 1..5).
// A solvable layout that needs free cells + a cascade-to-cascade move.
function initState(){
return {
free: [null, null],
cols: [ [{r:3,s:'s'},{r:1,s:'s'},{r:4,s:'s'}], [{r:5,s:'s'},{r:2,s:'s'}] ],
found: 0 // highest rank placed (0 = empty, then 1..5)
};
}
let S = initState();
let sel = null; // {z:'C0'|'C1'|'F0'|'F1'}
const label = c => c ? (c.r===1?'A':c.r) + c.s : '';
function topOf(zone){
if (zone[0]==='C'){ const col=S.cols[+zone[1]]; return col.length? col[col.length-1] : null; }
if (zone[0]==='F'){ return S.free[+zone[1]]; }
return null;
}
// Can we move the card currently at `from` onto zone `to`?
function canMove(from, to){
const card = topOf(from);
if (!card) return false;
if (to[0]==='H') return card.r === S.found + 1; // foundation: next rank up
if (to[0]==='F') return S.free[+to[1]] === null; // free cell: must be empty
if (to[0]==='C') return S.cols[+to[1]].length === 0; // cascade: only onto empty col here (simplified)
return false;
}
function applyMove(from, to){
let card;
if (from[0]==='C') card = S.cols[+from[1]].pop();
else { card = S.free[+from[1]]; S.free[+from[1]] = null; }
if (to[0]==='H') S.found = card.r;
else if (to[0]==='F') S.free[+to[1]] = card;
else S.cols[+to[1]].push(card);
}
const won = () => S.found === GOAL;
function render(){
document.querySelectorAll('.slot,.col').forEach(el=>{
const z = el.dataset.z;
el.classList.remove('drop');
if (z[0]==='F'){
el.innerHTML=''; const c=S.free[+z[1]];
if (c) el.appendChild(cardEl(c, z));
} else if (z==='H'){
el.innerHTML = S.found? '<div class="card '+(false?'':'black')+'">'+(S.found===1?'A':S.found)+'s</div>' : '';
} else {
el.innerHTML=''; S.cols[+z[1]].forEach((c,i,arr)=>{
const e=cardEl(c, z); if (i!==arr.length-1) e.style.cursor='default'; el.appendChild(e);
});
}
if (sel && canMove(sel.z, z)) el.classList.add('drop');
});
}
function cardEl(c, zone){
const d=document.createElement('div');
d.className='card '+(RED.has(c.s)?'red':'black');
d.textContent=label(c);
if (sel && sel.z===zone && topOf(zone)===c) d.classList.add('sel');
return d;
}
function setStatus(t,cls){ const s=document.getElementById('status'); s.textContent=t; s.className='status '+(cls||''); }
function onZoneClick(z){
if (won()) return;
if (!sel){
if (topOf(z)){ sel={z}; render(); setStatus('Now click a destination.'); }
return;
}
if (sel.z===z){ sel=null; render(); setStatus('Pick a card to move.'); return; }
if (canMove(sel.z, z)){
applyMove(sel.z, z); sel=null;
if (won()){ render(); setStatus('You cleared the deal! ✓','ok'); }
else { render(); setStatus('Keep going.'); }
} else {
setStatus('That move is not legal.','bad');
}
}
document.querySelectorAll('.slot,.col').forEach(el=>{
el.addEventListener('click', ()=> onZoneClick(el.dataset.z));
});
// ── Auto-solver: depth-first search with backtracking; logs the tree ──────────
const ZONES = ['H','F0','F1','C0','C1'];
function clone(s){ return { free:[s.free[0],s.free[1]], cols:[s.cols[0].slice(),s.cols[1].slice()], found:s.found }; }
function legalMoves(s){
const cur=S; S=s; const out=[];
for (const from of ['C0','C1','F0','F1']) for (const to of ZONES){
if (from===to) continue;
if (canMove(from,to)){
// avoid pointless free<->free shuffles by only allowing free->free never
if (from[0]==='F' && to[0]==='F') continue;
out.push([from,to]);
}
}
S=cur; return out;
}
function doMove(s, mv){ const cur=S; S=s; applyMove(mv[0],mv[1]); S=cur; }
function isWin(s){ return s.found===GOAL; }
function solve(start){
const lines=[]; let nodes=0; const seen=new Set();
const key=s=>JSON.stringify(s);
let solution=null;
function dfs(s, depth, prefix){
nodes++;
const k=key(s);
if (seen.has(k)){ lines.push(fmt('%prefix%· (repeat)', { prefix: prefix })); return false; }
seen.add(k);
if (isWin(s)){ lines.push(fmt('<span class="win">%prefix%✔ WIN</span>', { prefix: prefix })); solution=true; return true; }
if (depth>14){ lines.push(fmt('<span class="dead">%prefix%✗ too deep</span>', { prefix: prefix })); return false; }
const moves=legalMoves(s);
if (moves.length===0){ lines.push(fmt('<span class="dead">%prefix%✗ dead end</span>', { prefix: prefix })); return false; }
for (const mv of moves){
const ns=clone(s); doMove(ns,mv);
lines.push(prefix+mv[0]+'→'+mv[1]);
if (dfs(ns, depth+1, prefix+' ')) return true;
}
return false;
}
dfs(clone(start), 0, '');
return { ok:!!solution, nodes, lines };
}
function autoSolve(){
S = initState(); sel=null; render();
const res = solve(S);
const tree=document.getElementById('tree');
tree.innerHTML = res.lines.join('\n');
if (res.ok){ setStatus(fmt('Solver found a winning line after exploring %nodes% positions.', { nodes: res.nodes }),'ok');
console.log('FreeCell solved — explored', res.nodes, 'positions'); }
else setStatus('No solution found.','bad');
}
document.getElementById('solve').addEventListener('click', autoSolve);
document.getElementById('reset').addEventListener('click', ()=>{ S=initState(); sel=null; render(); document.getElementById('tree').innerHTML=''; setStatus('Pick a card to move.'); });
render();
<p class="hint">A miniature FreeCell deal. The <b>foundation</b> must fill A, 2, 3, 4, 5
in order. You have <b>2 free cells</b> (park one card each) and <b>2 cascades</b>
(only the bottom card moves). Click a card to pick it up, then click where it goes.
Clear every card to win.</p>
<div class="zones">
<div class="row">
<div class="slot free" data-z="F0"></div>
<div class="slot free" data-z="F1"></div>
<div class="gap"></div>
<div class="slot found" data-z="H"></div>
</div>
<div class="row casc">
<div class="col" data-z="C0"></div>
<div class="col" data-z="C1"></div>
</div>
</div>
<div class="status" id="status">Pick a card to move.</div>
<div class="btns">
<button id="solve" type="button">Auto-solve</button>
<button id="reset" type="button" class="ghost">Reset</button>
</div>
<div class="tree-wrap">
<div class="tree-title">Search tree the solver explores</div>
<div id="tree" class="tree"></div>
</div>
* { box-sizing: border-box; }
body { font-family: system-ui, sans-serif; color: #222; margin: 0; }
.hint { font-size: .9rem; color: #444; margin: 0 0 .7rem; line-height: 1.45; }
.zones { background: #11572b; padding: 10px; border-radius: 10px; }
.row { display: flex; gap: 8px; align-items: flex-start; margin-bottom: 10px; }
.row:last-child { margin-bottom: 0; }
.gap { flex: 1; }
.slot, .col { border-radius: 7px; min-width: 44px; }
.slot { width: 44px; height: 60px; border: 2px dashed #2f7d4c; display: flex;
align-items: center; justify-content: center; }
.found { border-color: #ffd76a; }
.col { min-height: 60px; padding: 3px; border: 2px dashed #2f7d4c;
display: flex; flex-direction: column; gap: 3px; }
.card { width: 40px; height: 54px; border-radius: 6px; background: #fff;
border: 1px solid #888; font: 700 16px ui-monospace, monospace;
display: flex; align-items: center; justify-content: center; cursor: pointer;
user-select: none; }
.card.red { color: #c92f3c; }
.card.black { color: #1d2733; }
.col .card { width: 38px; }
.card.sel { outline: 3px solid #ffd76a; outline-offset: 1px; }
.slot.drop, .col.drop { border-style: solid; border-color: #ffd76a; }
.status { font-size: 1rem; font-weight: 600; margin: .6rem 0; min-height: 1.4em; }
.status.ok { color: #0a7d33; }
.status.bad { color: #c92f3c; }
.btns { display: flex; gap: .5rem; flex-wrap: wrap; }
button { font: 600 14px system-ui, sans-serif; padding: .45rem .9rem; border: 1px solid #11572b;
background: #11572b; color: #fff; border-radius: 8px; cursor: pointer; }
button.ghost { background: #fff; color: #11572b; }
.tree-wrap { margin-top: 1rem; }
.tree-title { font-size: .8rem; color: #555; margin-bottom: .3rem; font-weight: 600; }
.tree { font: 12px/1.5 ui-monospace, monospace; white-space: pre; overflow-x: auto;
background: #f5f7f9; border: 1px solid #dde3e8; border-radius: 8px; padding: 8px;
max-height: 220px; min-height: 40px; color: #2b3b4b; }
.tree .win { color: #0a7d33; font-weight: 700; }
.tree .dead { color: #b14; }
const fmt = (s, v) => s.replace(/%(\w+)%/g, (_, k) => String(v[k] ?? ''));
// Miniature FreeCell. Foundation builds A(1)..5 in order. Two free cells, two
// cascades; only the bottom card of a cascade is movable. Solving FreeCell in
// general (n cards) is NP-complete (Helmert, 2003): the auto-solver below does a
// depth-first search with backtracking over the tree of move sequences.
const GOAL = 5; // need ranks 1..5 on the foundation
const RED = new Set(['h','d']);
// Deal: each card = {r: rank, s: suit}. Only ranks 1..5, suit 's' (one suit ⇒ foundation needs 1..5).
// A solvable layout that needs free cells + a cascade-to-cascade move.
function initState(){
return {
free: [null, null],
cols: [ [{r:3,s:'s'},{r:1,s:'s'},{r:4,s:'s'}], [{r:5,s:'s'},{r:2,s:'s'}] ],
found: 0 // highest rank placed (0 = empty, then 1..5)
};
}
let S = initState();
let sel = null; // {z:'C0'|'C1'|'F0'|'F1'}
const label = c => c ? (c.r===1?'A':c.r) + c.s : '';
function topOf(zone){
if (zone[0]==='C'){ const col=S.cols[+zone[1]]; return col.length? col[col.length-1] : null; }
if (zone[0]==='F'){ return S.free[+zone[1]]; }
return null;
}
// Can we move the card currently at `from` onto zone `to`?
function canMove(from, to){
const card = topOf(from);
if (!card) return false;
if (to[0]==='H') return card.r === S.found + 1; // foundation: next rank up
if (to[0]==='F') return S.free[+to[1]] === null; // free cell: must be empty
if (to[0]==='C') return S.cols[+to[1]].length === 0; // cascade: only onto empty col here (simplified)
return false;
}
function applyMove(from, to){
let card;
if (from[0]==='C') card = S.cols[+from[1]].pop();
else { card = S.free[+from[1]]; S.free[+from[1]] = null; }
if (to[0]==='H') S.found = card.r;
else if (to[0]==='F') S.free[+to[1]] = card;
else S.cols[+to[1]].push(card);
}
const won = () => S.found === GOAL;
function render(){
document.querySelectorAll('.slot,.col').forEach(el=>{
const z = el.dataset.z;
el.classList.remove('drop');
if (z[0]==='F'){
el.innerHTML=''; const c=S.free[+z[1]];
if (c) el.appendChild(cardEl(c, z));
} else if (z==='H'){
el.innerHTML = S.found? '<div class="card '+(false?'':'black')+'">'+(S.found===1?'A':S.found)+'s</div>' : '';
} else {
el.innerHTML=''; S.cols[+z[1]].forEach((c,i,arr)=>{
const e=cardEl(c, z); if (i!==arr.length-1) e.style.cursor='default'; el.appendChild(e);
});
}
if (sel && canMove(sel.z, z)) el.classList.add('drop');
});
}
function cardEl(c, zone){
const d=document.createElement('div');
d.className='card '+(RED.has(c.s)?'red':'black');
d.textContent=label(c);
if (sel && sel.z===zone && topOf(zone)===c) d.classList.add('sel');
return d;
}
function setStatus(t,cls){ const s=document.getElementById('status'); s.textContent=t; s.className='status '+(cls||''); }
function onZoneClick(z){
if (won()) return;
if (!sel){
if (topOf(z)){ sel={z}; render(); setStatus('Now click a destination.'); }
return;
}
if (sel.z===z){ sel=null; render(); setStatus('Pick a card to move.'); return; }
if (canMove(sel.z, z)){
applyMove(sel.z, z); sel=null;
if (won()){ render(); setStatus('You cleared the deal! ✓','ok'); }
else { render(); setStatus('Keep going.'); }
} else {
setStatus('That move is not legal.','bad');
}
}
document.querySelectorAll('.slot,.col').forEach(el=>{
el.addEventListener('click', ()=> onZoneClick(el.dataset.z));
});
// ── Auto-solver: depth-first search with backtracking; logs the tree ──────────
const ZONES = ['H','F0','F1','C0','C1'];
function clone(s){ return { free:[s.free[0],s.free[1]], cols:[s.cols[0].slice(),s.cols[1].slice()], found:s.found }; }
function legalMoves(s){
const cur=S; S=s; const out=[];
for (const from of ['C0','C1','F0','F1']) for (const to of ZONES){
if (from===to) continue;
if (canMove(from,to)){
// avoid pointless free<->free shuffles by only allowing free->free never
if (from[0]==='F' && to[0]==='F') continue;
out.push([from,to]);
}
}
S=cur; return out;
}
function doMove(s, mv){ const cur=S; S=s; applyMove(mv[0],mv[1]); S=cur; }
function isWin(s){ return s.found===GOAL; }
function solve(start){
const lines=[]; let nodes=0; const seen=new Set();
const key=s=>JSON.stringify(s);
let solution=null;
function dfs(s, depth, prefix){
nodes++;
const k=key(s);
if (seen.has(k)){ lines.push(fmt('%prefix%· (repeat)', { prefix: prefix })); return false; }
seen.add(k);
if (isWin(s)){ lines.push(fmt('<span class="win">%prefix%✔ WIN</span>', { prefix: prefix })); solution=true; return true; }
if (depth>14){ lines.push(fmt('<span class="dead">%prefix%✗ too deep</span>', { prefix: prefix })); return false; }
const moves=legalMoves(s);
if (moves.length===0){ lines.push(fmt('<span class="dead">%prefix%✗ dead end</span>', { prefix: prefix })); return false; }
for (const mv of moves){
const ns=clone(s); doMove(ns,mv);
lines.push(prefix+mv[0]+'→'+mv[1]);
if (dfs(ns, depth+1, prefix+' ')) return true;
}
return false;
}
dfs(clone(start), 0, '');
return { ok:!!solution, nodes, lines };
}
function autoSolve(){
S = initState(); sel=null; render();
const res = solve(S);
const tree=document.getElementById('tree');
tree.innerHTML = res.lines.join('\n');
if (res.ok){ setStatus(fmt('Solver found a winning line after exploring %nodes% positions.', { nodes: res.nodes }),'ok');
console.log('FreeCell solved — explored', res.nodes, 'positions'); }
else setStatus('No solution found.','bad');
}
document.getElementById('solve').addEventListener('click', autoSolve);
document.getElementById('reset').addEventListener('click', ()=>{ S=initState(); sel=null; render(); document.getElementById('tree').innerHTML=''; setStatus('Pick a card to move.'); });
render();
Notice the asymmetry. Checking a finished game of moves is effortless: replay the moves and confirm each one is legal. Finding the right sequence is the hard part — press Auto-solve and the computer searches a branching tree of move sequences, backtracking whenever it hits a dead end. Each extra card can multiply the number of paths it must explore.
Comments
Loading comments...