Solitaire · Draw one

HTML for this example

Select the code and use your copy shortcut. Changes in this preview are temporary.

<!doctype html><html lang="en-US"><head><meta charset="utf-8"><meta name="viewport" content="width=device-width,initial-scale=1"><title>Solitaire · Draw one</title><style>*{box-sizing:border-box}body{margin:0;background:#f3f1eb;color:#202e35;font:16px/1.5 system-ui,sans-serif}main{max-width:1160px;margin:auto;padding:32px 28px 70px}h1{font-size:clamp(26px,4vw,42px);line-height:1.12;margin:8px 0 12px;letter-spacing:-.04em}h2{font-size:22px;margin:0 0 16px}h3{font-size:17px;margin:0 0 8px}p{margin:8px 0 16px}.eyebrow{text-transform:uppercase;letter-spacing:.15em;font-size:12px;font-weight:750;color:#45665e}.lede{color:#55636a;max-width:780px}.panel{background:#fff;border:1px solid #d9dfd9;border-radius:16px;padding:24px;margin:20px 0}.hero{background:#193f38;color:white}.hero .muted{color:#bfd6cb}.split{display:grid;grid-template-columns:1.1fr 1fr;gap:24px}.grid{display:grid;grid-template-columns:repeat(3,minmax(0,1fr));gap:14px}.row{display:flex;flex-wrap:wrap;gap:12px;align-items:center}.big{font-size:clamp(32px,5vw,58px);line-height:1.15;font-weight:750;letter-spacing:-.04em}.muted,small{color:#61716e}.badge{display:inline-block;padding:4px 9px;border-radius:20px;background:#e5efe8;color:#284e40;font-size:13px;font-weight:650}.warn{background:#fff2d2;color:#795213}.rule{border-top:1px solid #dce2dd;padding-top:16px;margin-top:16px}label{display:block;font-size:14px;font-weight:650;margin:12px 0 5px}input,select,textarea,button{font:inherit}input,select,textarea{width:100%;border:1px solid #aebbb5;border-radius:7px;background:white;color:#20332c;padding:10px;min-height:44px}textarea{min-height:96px;resize:vertical}button{min-height:44px;border:1px solid #80968c;border-radius:7px;padding:9px 14px;background:white;color:#1c4637;cursor:pointer}button.primary{background:#245d47;color:white;border-color:#245d47}button:disabled{opacity:.5;cursor:default}button[aria-pressed=true]{background:#d8eb90;color:#1c3d29;border-color:#536a2d}button:focus-visible,input:focus-visible,select:focus-visible,textarea:focus-visible,[tabindex]:focus-visible{outline:3px solid #d5782a;outline-offset:3px}.error{color:#913920;min-height:24px}pre,.record{white-space:pre-wrap;overflow-wrap:anywhere;font:14px/1.6 ui-monospace,monospace;background:#eef2ec;border-radius:8px;padding:16px}table{border-collapse:collapse;width:100%;font-size:14px}th,td{border-bottom:1px solid #dce2dd;text-align:left;padding:12px 8px;vertical-align:top}th{font-size:12px;text-transform:uppercase;letter-spacing:.06em}.scroll{overflow:auto}details{margin:24px 0}summary{cursor:pointer;min-height:44px;font-weight:650}svg{max-width:100%;height:auto}.small{font-size:13px}.stack>*+*{margin-top:12px}@media(max-width:720px){main{padding:22px 16px 48px}.split{grid-template-columns:1fr}.grid{grid-template-columns:repeat(2,minmax(0,1fr))}.panel{padding:18px;margin:16px 0}h1{font-size:32px}.desktop-table{min-width:680px}}@media(max-width:400px){.grid{grid-template-columns:1fr}main{padding:18px 14px}.panel{padding:16px}.row>button{flex:1}h1{font-size:29px}}.tabletop{background:#e2ede2;border-color:#b5c8b6}.tableau{display:grid;grid-template-columns:repeat(7,minmax(0,1fr));gap:10px}.tableau section{min-width:0}.tableau h3{text-align:center}.card{display:block;width:100%;text-align:left;background:#fffdf6;font-weight:750;border-color:#7e9481;min-height:48px;margin:5px 0;padding:8px}.red{color:#9b3531}.hiddenCards{font-size:11px;text-align:center;background:repeating-linear-gradient(45deg,#416257,#416257 3px,#537a68 3px,#537a68 6px);color:white;border-radius:6px;padding:6px 2px}.foundations{display:grid;grid-template-columns:repeat(4,1fr);gap:10px}.foundation{background:#f6f6e8;font-size:23px;font-weight:750}.foundation small{display:block;font-size:11px;font-weight:500}.destination{font-size:12px;padding:8px 3px;width:100%;border-style:dashed}.cardback{background:#375e4c;color:white}.tabletop .row>#waste{min-width:80px}.tabletop .row>#waste .card{min-width:70px}@media(max-width:720px){.tableau{grid-template-columns:repeat(4,minmax(0,1fr));gap:10px}.foundations{gap:7px}.foundation{padding:8px 4px}}@media(max-width:400px){.tableau{grid-template-columns:repeat(3,minmax(0,1fr))}.tabletop .row>button{flex:initial;font-size:13px}.foundations{gap:5px}.foundation small{font-size:10px}}</style></head><body><main><div class="eyebrow">Local JavaScript card table</div><h1>Solitaire, one move at a time</h1><p class="lede">Build each suit from Ace to King; arrange tableau runs in descending, alternating colors.</p><section class="tabletop panel"><div class="row"><span class="badge" id="progress">0 / 52 on foundations</span><button id="reset">Reset with a new deal</button></div><div class="row" style="margin:18px 0"><button id="draw" class="cardback">Draw one · 24</button><button id="redeal" disabled>Redeal waste</button><div id="waste"></div></div><div id="foundations" class="foundations"></div><p id="status" role="status">Select a face-up card or run, then choose a destination.</p><div id="tableau" class="tableau"></div></section><details><summary>This table's variant and controls</summary><p>Draw one card. Redeal the entire waste only when stock is empty, without reshuffling; redeals are unlimited. A tableau selection moves its whole face-up suffix. Only Kings may fill empty tableau piles. Only one exposed top card may move to its suit's foundation. Successful moves flip a newly exposed tableau top card automatically.</p><p>Select a card with a click, tap, Enter or Space, then activate “Move here” or the matching foundation. Invalid moves preserve cards and selection. Foundations have no return move. There is no Undo, automatic foundation move, loss inference or guarantee of solvability. Reset creates a shuffled 52-card deal. Progress lasts only in this page.</p></details></main><script>const $=id=>document.getElementById(id),put=(id,v)=>$(id).textContent=String(v),esc=s=>String(s).replace(/[&<>"']/g,c=>({'&':'&amp;','<':'&lt;','>':'&gt;','"':'&quot;',"'":'&#39;'}[c]));
const suits=['H','D','C','S'],symbols={H:'♥',D:'♦',C:'♣',S:'♠'},names={H:'Hearts',D:'Diamonds',C:'Clubs',S:'Spades'},rank=r=>({1:'A',11:'J',12:'Q',13:'K'}[r]||r),catalog=Object.freeze(Object.fromEntries(suits.flatMap(s=>Array.from({length:13},(_,i)=>[s+(i+1),Object.freeze({id:s+(i+1),suit:s,rank:i+1,red:s==='H'||s==='D'})]))));const label=id=>rank(catalog[id].rank)+symbols[catalog[id].suit],clone=s=>JSON.parse(JSON.stringify(s));
function validate(s){const ids=[...s.stock,...s.waste,...s.tableau.flat(),...s.foundations.flat()];if(ids.length!==52||new Set(ids).size!==52||ids.some(id=>!catalog[id]))throw Error('Deck invariant: exactly 52 unique known cards required.');if(s.tableau.length!==7||s.foundations.length!==4)throw Error('Pile invariant.');if(s.stock.some(id=>s.up[id])||s.waste.some(id=>!s.up[id])||s.foundations.flat().some(id=>!s.up[id]))throw Error('Face state invariant.');for(const p of s.tableau){if(p.length&&!s.up[p[p.length-1]])throw Error('Top must be face up.');let up=false;for(const id of p){if(s.up[id])up=true;else if(up)throw Error('Hidden card above a face-up card.')}}return true}
function deal(shuffle=true){const a=Object.keys(catalog);if(shuffle)for(let i=a.length-1;i>0;i--){const j=Math.floor(Math.random()*(i+1));[a[i],a[j]]=[a[j],a[i]]}const s={stock:[],waste:[],tableau:Array.from({length:7},()=>[]),foundations:Array.from({length:4},()=>[]),up:{},selection:null};for(let row=0;row<7;row++)for(let col=row;col<7;col++){const id=a.pop();s.tableau[col].push(id);s.up[id]=row===col}s.stock=a;for(const id of a)s.up[id]=false;validate(s);return s}
const won=s=>s.foundations.every((p,i)=>p.length===13&&p.every((id,j)=>id===suits[i]+(j+1)));function pile(s,from){return from.type==='waste'?s.waste:from.type==='tableau'&&Number.isInteger(from.pile)&&from.pile>=0&&from.pile<7?s.tableau[from.pile]:null}
function selected(s,from){if(!from||!['waste','tableau'].includes(from.type))throw Error('Choose a tableau card or waste top.');const p=pile(s,from);if(!p||!Number.isInteger(from.index)||from.index<0||from.index>=p.length)throw Error('Card is not available.');if(from.type==='waste'&&from.index!==p.length-1)throw Error('Only the waste top is available.');const run=p.slice(from.index);if(run.some(id=>!s.up[id]))throw Error('Choose a face-up run.');for(let i=1;i<run.length;i++){const a=catalog[run[i-1]],b=catalog[run[i]];if(a.rank!==b.rank+1||a.red===b.red)throw Error('Run must descend with alternating colors.')}return run}
function choose(s,from){if(won(s))throw Error('Game complete. Reset for a new deal.');selected(s,from);s.selection={...from};return s}
function move(s,dest){validate(s);if(won(s))throw Error('Game complete.');const from=s.selection,run=selected(s,from),first=catalog[run[0]],src=pile(s,from);let target;if(dest.type==='tableau'){if(!Number.isInteger(dest.pile)||dest.pile<0||dest.pile>6)throw Error('Unknown destination.');if(from.type==='tableau'&&from.pile===dest.pile)throw Error('Choose a different pile.');target=s.tableau[dest.pile];const top=target.at(-1);if(!top&&first.rank!==13)throw Error('Only a King may fill an empty pile.');if(top&&(!s.up[top]||catalog[top].rank!==first.rank+1||catalog[top].red===first.red))throw Error('Use descending rank and alternating colors.')}else if(dest.type==='foundation'){if(!Number.isInteger(dest.pile)||dest.pile<0||dest.pile>3)throw Error('Unknown foundation.');if(run.length!==1||from.index!==src.length-1)throw Error('Only one exposed top card may enter a foundation.');target=s.foundations[dest.pile];if(first.suit!==suits[dest.pile]||first.rank!==target.length+1)throw Error('Build the matching suit from Ace upward.')}else throw Error('Unknown destination.');const n=clone(s),source=pile(n,from),to=dest.type==='tableau'?n.tableau[dest.pile]:n.foundations[dest.pile];source.splice(from.index);to.push(...run);if(from.type==='tableau'&&source.length)n.up[source.at(-1)]=true;n.selection=null;validate(n);return n}
function draw(s){validate(s);if(won(s)||!s.stock.length)throw Error('No draw available.');const n=clone(s),id=n.stock.pop();n.up[id]=true;n.waste.push(id);n.selection=null;validate(n);return n}function redeal(s){validate(s);if(won(s)||s.stock.length||!s.waste.length)throw Error('Redeal requires empty stock and nonempty waste.');const n=clone(s);n.stock=n.waste.reverse();n.waste=[];for(const id of n.stock)n.up[id]=false;n.selection=null;validate(n);return n}
let state=deal();const cardButton=(id,from,active)=>'<button data-action="select" data-type="'+from.type+'" data-pile="'+(from.pile??0)+'" data-index="'+from.index+'" aria-pressed="'+active+'" aria-label="Select '+rank(catalog[id].rank)+' of '+names[catalog[id].suit]+(from.type==='tableau'?', plus the run below it':'')+'" class="card '+(catalog[id].red?'red':'')+'">'+label(id)+'</button>';
function render(message){const complete=won(state);put('progress',state.foundations.flat().length+' / 52 on foundations');put('draw','Draw one · '+state.stock.length);$('draw').disabled=complete||!state.stock.length;$('redeal').disabled=complete||!!state.stock.length||!state.waste.length;$('waste').innerHTML=state.waste.length?'<span class="small">Waste · '+state.waste.length+'</span> '+cardButton(state.waste.at(-1),{type:'waste',index:state.waste.length-1},state.selection?.type==='waste'):'<span class="small">Waste · empty</span>';$('foundations').innerHTML=suits.map((s,i)=>'<button id="foundation'+i+'" '+(complete?'disabled':'')+' data-action="move" data-type="foundation" data-pile="'+i+'" class="foundation '+(i<2?'red':'')+'" aria-label="Move selected card to '+names[s]+' foundation">'+symbols[s]+' '+(state.foundations[i].length?rank(state.foundations[i].length):'Ace')+'<small>'+names[s]+' · '+state.foundations[i].length+'/13</small></button>').join('');$('tableau').innerHTML=state.tableau.map((p,i)=>'<section><h3>T'+(i+1)+'</h3><div class="hiddenCards">'+p.filter(id=>!state.up[id]).length+' face down</div>'+p.map((id,j)=>state.up[id]?cardButton(id,{type:'tableau',pile:i,index:j},state.selection?.type==='tableau'&&state.selection.pile===i&&j>=state.selection.index):'').join('')+'<button '+(complete?'disabled':'')+' data-action="move" data-type="tableau" data-pile="'+i+'" class="destination" aria-label="Move selected run to tableau '+(i+1)+'">Move here</button></section>').join('');if(complete){put('status','All four suits complete. You won. Reset for another deal.');return}put('status',message||(state.selection?'Selected: '+selected(state,state.selection).map(label).join(' → ')+'. Choose a destination.':'Select a face-up card or run, then choose a destination.'))}
function act(fn,message){try{state=fn(state);render(message)}catch(e){put('status','Move rejected: '+e.message)}}for(const id of ['tableau','waste','foundations'])$(id).addEventListener('click',e=>{const b=e.target.closest?e.target.closest('[data-action]'):e.target;if(!b?.dataset.action||won(state))return;const d=b.dataset;if(d.action==='select'){act(s=>choose(s,{type:d.type,pile:Number(d.pile),index:Number(d.index)}));if(state.selection)$('foundation0').focus()}else{act(s=>move(s,{type:d.type,pile:Number(d.pile)}),'Move committed. Choose the next card.');if(!state.selection)$('reset').focus()}});$('draw').addEventListener('click',()=>act(draw,'One card drawn.'));$('redeal').addEventListener('click',()=>act(redeal,'Waste redealt without reshuffling.'));$('reset').addEventListener('click',()=>{state=deal();render('New shuffled deal.');$('draw').focus()});globalThis.Tool={catalog,deal,validate,choose,selected,move,draw,redeal,won,getState:()=>state};render();</script></body></html>