Readable application source
rules.js
// Board topology, sliding, flipping and row-resolution adapted from temhelk/yinsh (MIT).
export const dirs=[[1,0],[0,1],[1,-1],[-1,0],[0,-1],[-1,1]];
const lo=[6,4,3,2,1,1,0,0,0,0,1],hi=[9,10,10,10,10,9,9,8,7,6,4];
export const cells=[];for(let x=0;x<11;x++)for(let y=lo[x];y<=hi[x];y++)cells.push(`${x},${y}`);
export const xy=k=>k.split(',').map(Number),step=(k,d,n=1)=>{let [x,y]=xy(k);return `${x+d[0]*n},${y+d[1]*n}`};
export const clone=s=>JSON.parse(JSON.stringify(s));
export function fresh(goal=1,mode='ai'){return {b:Object.fromEntries(cells.map(k=>[k,0])),p:1,phase:'place',placed:0,score:[0,0,0],goal,mode,mover:1,turn:0,log:[],winner:0};}
export function destinations(s,k){if(s.b[k]!==s.p*2)return [];let out=[];for(let d of dirs){let t=k,jumped=false;while(true){t=step(t,d);let v=s.b[t];if(v===undefined||Math.abs(v)===2)break;if(v!==0){jumped=true;continue;}out.push(t);if(jumped)break;}}return out;}
export function rows(s,p){let out=[];for(let k of cells)if(s.b[k]===p)for(let d of dirs.slice(0,3)){let a=Array.from({length:5},(_,i)=>step(k,d,i));if(a.every(t=>s.b[t]===p))out.push(a);}return out;}
export function moves(s){if(s.phase==='place')return cells.filter(k=>!s.b[k]).map(k=>({type:'place',k}));if(s.phase==='row')return rows(s,s.p).map(a=>({type:'row',a}));if(s.phase==='remove')return cells.filter(k=>s.b[k]===s.p*2).map(k=>({type:'remove',k}));if(s.phase==='move'){let m=cells.flatMap(k=>destinations(s,k).map(t=>({type:'move',k,t})));return m.length?m:[{type:'pass'}];}return [];}
function resolve(s){for(let p of [s.mover,-s.mover])if(rows(s,p).length){s.p=p;s.phase='row';return;}s.p=-s.mover;s.phase='move';let markers=cells.filter(k=>Math.abs(s.b[k])===1).length;if(markers>=51){s.phase='over';s.winner=s.score[1]===s.score[2]?0:s.score[1]>s.score[2]?1:-1;}}
export function apply(s,m){let n=clone(s);if(!moves(s).some(x=>JSON.stringify(x)===JSON.stringify(m)))throw Error('Illegal move');const ix=p=>p===1?1:2;let who=s.p===1?'Ivory':'Jade';if(m.type==='place'){n.b[m.k]=s.p*2;n.placed++;n.p=-s.p;n.log.push(`${who} placed ${m.k}`);if(n.placed===10)n.phase='move';}
if(m.type==='move'){n.mover=s.p;n.b[m.k]=s.p;n.b[m.t]=s.p*2;let a=xy(m.k),b=xy(m.t),d=[Math.sign(b[0]-a[0]),Math.sign(b[1]-a[1])];for(let t=step(m.k,d);t!==m.t;t=step(t,d))if(n.b[t])n.b[t]*=-1;n.turn++;n.log.push(`${who}: ${m.k} → ${m.t}`);resolve(n);}
if(m.type==='row'){m.a.forEach(k=>n.b[k]=0);n.phase='remove';n.log.push(`${who} cleared five markers`);}
if(m.type==='remove'){n.b[m.k]=0;n.score[ix(s.p)]++;n.log.push(`${who} retired a ring`);if(n.score[ix(s.p)]>=n.goal){n.phase='over';n.winner=s.p;}else resolve(n);}
if(m.type==='pass'){n.mover=s.p;n.p=-s.p;n.log.push(`${who} passed (no legal slide)`);if(!cells.some(k=>destinations(n,k).length)){n.phase='over';n.winner=n.score[1]===n.score[2]?0:n.score[1]>n.score[2]?1:-1;}}
n.log=n.log.slice(-60);return n;}
function value(s,p){let v=(s.score[p===1?1:2]-s.score[p===1?2:1])*1000;for(let k of cells){if(s.b[k]===2*p){let [x,y]=xy(k);v+=4-(Math.abs(x-5)+Math.abs(y-5))/3;}if(s.b[k]===p)v+=.5;for(let d of dirs.slice(0,3)){let a=Array.from({length:5},(_,i)=>s.b[step(k,d,i)]);if(a.every(x=>x===0||x===p))v+=Math.pow(a.filter(x=>x===p).length,3);if(a.every(x=>x===0||x===-p))v-=Math.pow(a.filter(x=>x===-p).length,3)*1.1;}}return v;}
export function best(s){let list=moves(s),p=s.p;return list.map(m=>({m,v:value(apply(s,m),p)+(m.type==='move'?rows(apply(s,m),p).length*500:0)})).sort((a,b)=>b.v-a.v)[0]?.m;}
export function lesson(id){let s=fresh(1,'local');s.phase='move';s.placed=10;let white=['2,5','3,6','4,6','6,4','7,3'],black=['1,5','3,7','5,6','7,2','8,1'];white.forEach(k=>s.b[k]=2);black.forEach(k=>s.b[k]=-2);
if(id==='row'){for(let x=2;x<=5;x++)s.b[`${x},4`]=1;s.b['6,4']=2;s.score[1]=0;}
if(id==='flip'){s.b['4,4']=2;s.b['5,4']=-1;s.b['6,4']=1;s.b['7,4']=0;}
if(id==='both'){s.b['2,5']=0;s.b['6,4']=0;s.b['1,4']=2;s.b['6,3']=2;for(let x=2;x<=6;x++)s.b[`${x},4`]=1;for(let x=2;x<=6;x++)s.b[`${x},5`]=-1;s.mover=1;s.p=1;s.phase='row';s.goal=3;}
return s;}
app.js
import {cells,xy,step,dirs,fresh,clone,moves,apply,destinations,rows,best,lesson} from './rules.js';
const $=s=>document.querySelector(s),key='stillwater-yinsh-v1';let state=fresh(),history=[],selected=null,suggestion=null,busy=false,timer,epoch=0;
try{let a=JSON.parse(localStorage.getItem(key));if(a?.state&&cells.every(k=>k in a.state.b)){state=a.state;history=a.history||[];}}catch{}
const pos=k=>{let [x,y]=xy(k);return [50+(x+y/2-7.5)*8.7,50+(y-5)*8.44]};
function save(){try{localStorage.setItem(key,JSON.stringify({state,history:history.slice(-100)}));}catch{}}
function say(t){$('#note').textContent=t;}
function act(m){state=apply(state,m);selected=null;suggestion=null;save();render();schedule();}
function human(){return !busy&&state.phase!=='over'&&(state.mode==='local'||state.p===1)}
function schedule(){clearTimeout(timer);if(state.phase==='over'||state.mode!=='ai'||state.p===1){busy=false;return;}busy=true;let e=epoch;timer=setTimeout(()=>{if(e!==epoch)return;let m=best(state);busy=false;if(m)act(m);},420);}
function pick(k){if(!human())return;let m;if(state.phase==='place')m={type:'place',k};if(state.phase==='remove')m={type:'remove',k};if(state.phase==='move'){if(state.b[k]===state.p*2){selected=selected===k?null:k;suggestion=null;render();return;}if(selected)m={type:'move',k:selected,t:k};}if(m&&moves(state).some(a=>JSON.stringify(a)===JSON.stringify(m))){history.push(clone(state));act(m);}}
function render(){let who=state.p===1?'Ivory':'Jade';$('#mode').value=state.mode;$('#goal').value=String(state.goal);$('#white').textContent=`${state.score[1]} / ${state.goal}`;$('#black').textContent=`${state.score[2]} / ${state.goal}`;$('#turn').textContent=state.phase==='place'?`${state.placed}/10 rings placed`:`${state.turn} slides`;
$('#status').textContent=state.phase==='over'?(state.winner?`${state.winner===1?'Ivory':'Jade'} wins · ${state.goal===1?'Blitz':'Classic'} complete`:'Table complete · draw'):state.phase==='place'?`${who}: place ring ${Math.floor(state.placed/2)+1} of 5.`:state.phase==='row'?`${who}: choose five markers to clear.`:state.phase==='remove'?`${who}: retire any one of your rings.`:`${who}: ${selected?'choose a green destination.':'select a hollow ring to slide.'}`;
let legal=selected?destinations(state,selected):[],suggestKeys=suggestion?.a||[suggestion?.k,suggestion?.t];let board=$('#board');board.replaceChildren();let ns='http://www.w3.org/2000/svg',svg=document.createElementNS(ns,'svg');svg.setAttribute('viewBox','0 0 100 100');svg.setAttribute('preserveAspectRatio','none');for(let k of cells)for(let d of dirs.slice(0,3)){let t=step(k,d);if(t in state.b){let l=document.createElementNS(ns,'line'),a=pos(k),b=pos(t);for(let [attr,v]of Object.entries({x1:a[0],y1:a[1],x2:b[0],y2:b[1],stroke:'#a4b6a1','stroke-width':.24}))l.setAttribute(attr,v);svg.append(l);}}board.append(svg);
for(let k of cells){let v=state.b[k],b=document.createElement('button'),p=pos(k);b.style.left=p[0]+'%';b.style.top=p[1]+'%';b.className=[Math.abs(v)===2?'ring':Math.abs(v)===1?'marker':'',v<0?'jade':'',selected===k?'selected':'',legal.includes(k)?'legal':'',suggestKeys.includes(k)?'suggest':''].join(' ');b.setAttribute('aria-label',`${k} ${v===0?'empty':v>0?'Ivory':'Jade'} ${Math.abs(v)===2?'ring':Math.abs(v)===1?'marker':''}`);b.dataset.k=k;b.onclick=()=>pick(k);board.append(b);}
let choices=$('#choices');choices.replaceChildren();if(state.phase==='row')rows(state,state.p).forEach((a,i)=>{let b=document.createElement('button');b.textContent=`Clear row ${i+1}: ${a[0]} → ${a[4]}`;b.disabled=!human();b.onclick=()=>{history.push(clone(state));act({type:'row',a});};choices.append(b);});if(state.phase==='move'&&moves(state)[0]?.type==='pass'){let b=document.createElement('button');b.textContent='No legal slide · Pass';b.onclick=()=>{history.push(clone(state));act({type:'pass'});};choices.append(b);}
$('#undo').disabled=!history.length;$('#hint').disabled=!human();$('#journal').replaceChildren(...state.log.slice(-8).reverse().map(t=>{let l=document.createElement('li');l.textContent=t;return l;}));
}
function reset(s){epoch++;clearTimeout(timer);busy=false;state=s;history=[];selected=suggestion=null;save();render();schedule();}
$('#new').onclick=()=>{reset(fresh(Number($('#goal').value),$('#mode').value));say('A fresh table. Ivory places the first ring.');};
$('#undo').onclick=()=>{epoch++;clearTimeout(timer);busy=false;let s;do{s=history.pop();}while(s&&s.mode==='ai'&&s.p!==1&&history.length);if(s)state=s;selected=suggestion=null;save();render();schedule();say('Returned to your previous decision.');};
$('#hint').onclick=()=>{suggestion=best(state);if(suggestion?.type==='move')selected=suggestion.k;render();say(suggestion?.type==='row'?'Choose the marked row using its Clear row button.':suggestion?.type==='remove'?'Retire the amber-marked ring.':suggestion?.type==='move'?`Suggested slide: ${suggestion.k} → ${suggestion.t}.`:'Try the amber-marked point.');};
document.querySelectorAll('[data-lesson]').forEach(b=>b.onclick=()=>{reset(lesson(b.dataset.lesson));say(b.dataset.lesson==='row'?'Slide Ivory 6,4 to 7,4 to leave a fifth marker. Clear the row, then retire any Ivory ring to win.':b.dataset.lesson==='flip'?'Select Ivory 4,4. Cross Jade 5,4 and Ivory 6,4; land at 7,4. Both markers flip.':'Both colors have five. Clear an Ivory row and retire a ring; then Jade resolves its row.');});
document.addEventListener('keydown',e=>{if(e.key==='Escape'){selected=suggestion=null;render();}});
render();schedule();
style.css
:root{color-scheme:light;--ink:#243e38;--jade:#226c5e;--cream:#f6f2e7}*{box-sizing:border-box}body{margin:0;background:var(--cream);color:var(--ink);font:16px/1.55 system-ui,sans-serif}main{max-width:1240px;margin:auto;padding:30px}header{padding:10px 0 25px;border-bottom:1px solid #c8cfc2}h1{font:52px/1.1 Georgia,serif;margin:12px 0}h1 span{font-size:28px;font-style:italic;color:#617c6c}header>p:last-child{margin:12px 0 0}.eyebrow{font-size:11px;letter-spacing:3px;font-weight:750}h2{font:32px/1.2 Georgia,serif}h3{font:21px Georgia,serif}.toolbar{display:flex;align-items:end;gap:10px;flex-wrap:wrap;margin:22px 0}label{font-size:12px;display:grid;gap:5px}button,select{font:inherit;border:1px solid #aabbb0;color:var(--ink);background:#fffdf5;border-radius:8px;padding:9px 13px;cursor:pointer}button:hover{background:#dce8dc}button:focus-visible,select:focus-visible{outline:3px solid #be8d35;outline-offset:3px}button:disabled{opacity:.4;cursor:default}.layout{display:grid;grid-template-columns:minmax(0,1fr) 295px;gap:34px}.table{background:#e6ecdf;border:1px solid #c3cfbd;border-radius:22px;padding:20px;align-self:start;box-shadow:0 20px 45px #304a3410}.score{display:flex;justify-content:space-between;align-items:center;border-bottom:1px solid #bdcbb9;padding-bottom:12px;font-size:14px}.score b{font-size:23px;padding-left:6px}#turn{font-size:11px;text-transform:uppercase;letter-spacing:1px}#status{min-height:48px;margin-bottom:0;font-weight:650}#board{position:relative;width:100%;aspect-ratio:1.12;max-width:700px;margin:auto}#board svg{position:absolute;inset:0;width:100%;height:100%;pointer-events:none}#board button{position:absolute;transform:translate(-50%,-50%);padding:0;width:4.6%;aspect-ratio:1;border-radius:50%;border:0;background:transparent;display:grid;place-items:center}#board button:before{content:'';width:5px;height:5px;background:#829785;border-radius:50%}#board button.ring:before{width:85%;height:85%;border:6px solid #fefcf2;background:#688c6a;border-radius:50%;box-shadow:1px 3px 3px #193c3140}#board button.jade.ring:before{border-color:#226c5e;background:#b9ccba}#board button.marker:before{width:56%;height:56%;background:#fffdf2;box-shadow:0 2px 2px #243e3850}#board button.jade.marker:before{background:#226c5e}#board button.legal{background:#b8d9a9;box-shadow:0 0 0 2px #609459}#board button.selected{outline:3px solid #c69130;outline-offset:2px}#board button.suggest{outline:4px dashed #ba7c22}#board button:hover{box-shadow:0 0 0 3px #cfb56e}#note{font-size:13px;color:#526c5c;margin-bottom:0;min-height:40px}#choices{display:flex;gap:8px;flex-wrap:wrap}aside{font-size:14px}aside ol{padding-left:22px}aside li{margin-bottom:10px}aside button{display:block;width:100%;text-align:left;margin:7px 0}summary{cursor:pointer;font-weight:700;padding:12px 0}a{color:#286656}#journal{font-size:12px;max-height:175px;overflow:auto}footer{font-size:12px;text-align:center;margin-top:35px;color:#6b7c6c}@media(max-width:800px){main{padding:18px}.layout{grid-template-columns:1fr}h1{font-size:39px}h1 span{display:block;font-size:24px}.table{padding:12px}.score{font-size:12px}#board button.ring:before{border-width:4px}.toolbar button,.toolbar select{font-size:14px}#status{font-size:14px}aside{max-width:650px}#board button{width:5.4%}}
index.html
<!doctype html><html lang="en"><meta charset="utf-8"><meta name="viewport" content="width=device-width,initial-scale=1"><title>Stillwater · Ring & Return</title><link rel="stylesheet" href="style.css"><body><main><header><p class="eyebrow">A QUIET TABLE · A CHANGING BOARD</p><h1>Stillwater <span>Ring & Return</span></h1><p>Make five. Leave a ring behind.</p></header><section class="toolbar"><label>Table <select id="mode"><option value="ai">You vs local computer</option><option value="local">Two people, one device</option></select></label><label>Match <select id="goal"><option value="1">Blitz · retire 1 ring</option><option value="3">Classic · retire 3 rings</option></select></label><button id="new">New table</button><button id="undo">Undo</button><button id="hint">Suggest</button></section><div class="layout"><section class="table"><div class="score"><span>○ Ivory <b id="white">0</b></span><span id="turn">Placement</span><span>Jade <b id="black">0</b> ○</span></div><p id="status" role="status" aria-live="polite"></p><div id="board" aria-label="YINSH playing board"></div><div id="choices"></div><p id="note">Select a point to begin. Hollow rings move; solid markers stay and flip.</p></section><aside><p class="eyebrow">THE ART OF LETTING GO</p><h2>Fewer rings.<br>Closer to winning.</h2><ol><li>Take turns placing five rings each.</li><li>Slide a ring along a straight line, leaving a marker behind. Rings block your path.</li><li>Cross any consecutive markers to flip them. Land on the first empty point after them.</li><li>Make five of your markers in a straight line. Clear those five, then retire one of your rings.</li></ol><p>If both colors form rows, the mover resolves first. Resolve all remaining rows before the other player's next slide.</p><details><summary>Rules, controls & credits</summary><p>Ivory begins. Before crossing markers, you may stop at any empty point. After crossing markers you must stop immediately. Rows longer than five offer a choice of five to clear. With 51 markers and no rows, most retired rings wins; equal scores draw. A player unable to slide passes.</p><p>Click a ring, then a highlighted point. Tab and Enter work on every point. Escape cancels selection. Suggest marks a legal action; Undo returns to your previous decision. Progress saves on this device.</p><p>A static rules port of <a href="https://github.com/temhelk/yinsh">temhelk/yinsh</a> (MIT, 6 stars observed, updated June 2026). Original board topology, slides, flips and row resolution retained. New interface and simple heuristic opponent; this is not upstream's MCTS engine. Independent YINSH rules adaptation, no affiliation with its creators.</p><p><a href="LICENSE.txt">MIT license</a> · <a href="source.html">Readable application source</a></p></details><h3>Practice at the table</h3><button data-lesson="flip">01 · Cross & flip</button><button data-lesson="row">02 · Retire your first ring</button><button data-lesson="both">03 · Two colors make five</button><h3>Table journal</h3><ol id="journal"></ol></aside></div><footer>Locally played. Locally saved. No accounts or network opponents.</footer></main><script type="module" src="app.js"></script></body></html>