47:51Ranking major U.S. Open-Weights Models. (Where does Ripple 65B fit in?)
Nov 6, 2026 · YouTube
10:57"Crouton Alpha": the latest super-efficient coding model by Meta?
Nov 3, 2026 · YouTube
38:58Testing Particle-1: a post-trained DeepSeek V4.1 Flash specializing in coding and efficiency
Oct 28, 2026 · YouTube
29:55Kimi K3.1 Pro Looked Incredible. Then I Kept Testing.
Oct 26, 2026 · YouTube
22:47Mistral Large 4 Is Cheap. But Is It Good Enough to Compete?
Oct 6, 2026 · YouTube
16:54Stealth Union Alpha: Is this another cheap GPT model? (better than Luna, cheaper than Terra?)
Sep 16, 2026 · YouTube
16:45DeepSeek V4.1 Flash Is Shockingly Good for the Price
Sep 10, 2026 · YouTube
17:37Tencent Hy4 Preview Tested: Is It Really Better Than Kimi K3 and GLM 5.3?
Aug 28, 2026 · YouTube
23:58Qwen 3.8 Flash Tested: UI, Coding, Physics & more - compared to Opus 5, Sol Pro, Grok
Aug 27, 2026 · YouTube
14:50Stealth Ox Alpha: a new GLM model! Full test (coding & writing) against Grok, Gemini, GPT & others
Aug 21, 2026 · YouTube
My buddy runs a bar. I told him I'd build him something for Thursdays. He's the one talking here — I just write down what he says and hand it to you.
It's not real pool. Three balls on the table, you shoot until all three are down, and we count how many shots it took you. Low is good. Everybody takes a turn, then we go around again — that's a round.
Twenty bucks to play. You start with five chips. Take more than four shots to clear the table and it costs you a chip. Out of chips and you're out, go back to drinking. Last one with chips left takes the pot.
There's a clock on your turn — a minute, minute and a half, we've never really settled it. Clock runs out and you're not at the table, that's a chip.
It's a Thursday. Nine o'clock, everybody's had a few, the jukebox is loud and half the room is outside smoking. Twelve, sixteen people usually, however many show up. It runs two, three hours and nobody's in a hurry.
Last year I ran the whole thing on a legal pad and it got away from me around round four. Two guys arguing with me about what they shot, me trying to remember whose turn it was, somebody's beer on the pad. Never again.
I'm running this off my phone, one-handed, standing at the table with a beer in the other. There's a TV over the bar and that's the only screen anybody in the room can see — from across the room, in the dark, so it's got to be big. People want to know where they stand without walking over and asking me.
My phone's on 15% by ten. The bar's wifi is the bar's wifi.
Twenty a head, all of it in the pot. Winner takes it — mostly. We kick a little to second. And there's a side thing we do for whoever's shooting best early on, I never wrote that part down properly.
The app doesn't hold the money. I do. But at the end of the night it has to tell me what I owe who, and I'm reading that off my phone with sixteen people standing around the table.
Two pages.
The console — the thing I run it from, and the thing that goes up on the TV. Open it and there's already a game going. I'm not typing in twelve names to find out whether it works.
The playback — a separate page that plays a whole night through, start to finish, so I can watch it work before Thursday. One step at a time, a step being one thing happening, with the scorecard filling in as it goes and a slider for how fast it runs. I want to slow it down, back it up, and read what just happened. Show me the way a night actually goes.
The playback runs the same rules the console runs. If the two of them drift apart, the playback is a cartoon and it's no use to me.
data: URIs are fine.localStorage, sessionStorage, cookies or indexedDB — these run in a sandboxed frame where those throw.html blocks: the console first, the playback second. Leave yourself room for the second one.I know I wasn't clear about everything. Everything you had to decide for me, one line each — what you decided and why. Short.
The first fenced html block.
The second fenced html block.
It's Thursday, it's nine o'clock, and I'm running this for real. What comes up that this doesn't handle? Be straight with me. I'd much rather hear it now than find out with sixteen people around a pool table and three hundred dollars sitting on the bar.
<!doctype html>
<html lang="en">
<head>
<meta charset="utf-8">
<meta name="viewport" content="width=device-width,initial-scale=1,viewport-fit=cover">
<title>Thursday Three-Ball Console</title>
<style>
:root{
color-scheme:dark;
--bg:#08110e;--panel:#101d18;--panel2:#16271f;--line:#315042;
--text:#f5f1df;--muted:#aab9af;--green:#42d17c;--gold:#ffd166;
--red:#ff6b6b;--blue:#65b5ff;
}
*{box-sizing:border-box}
html,body{margin:0;min-height:100%;background:var(--bg);color:var(--text);
font-family:system-ui,-apple-system,Segoe UI,sans-serif}
button,input,textarea{font:inherit}
button{border:1px solid var(--line);border-radius:12px;background:#20382d;color:var(--text);
min-height:46px;padding:.65rem .9rem;font-weight:800;cursor:pointer}
button:active{transform:translateY(1px)}
button.primary{background:var(--green);color:#06110a;border-color:var(--green)}
button.warn{background:#6b2528;border-color:#a94449}
button.gold{background:var(--gold);color:#171105;border-color:var(--gold)}
button:disabled{opacity:.4;cursor:not-allowed}
header{position:sticky;top:0;z-index:5;background:#08110eee;backdrop-filter:blur(8px);
border-bottom:1px solid var(--line);padding:.7rem}
.top{display:flex;align-items:center;gap:.55rem;flex-wrap:wrap;max-width:1450px;margin:auto}
.brand{font-size:1.15rem;font-weight:1000;margin-right:auto}
.badge{background:var(--panel2);border:1px solid var(--line);border-radius:999px;
padding:.45rem .7rem;font-weight:800}
main{max-width:1450px;margin:auto;padding:.8rem;display:grid;grid-template-columns:minmax(300px,430px) 1fr;gap:.8rem}
.card{background:var(--panel);border:1px solid var(--line);border-radius:16px;padding:.85rem}
h1,h2,h3,p{margin-top:0}
h2{font-size:1rem;text-transform:uppercase;letter-spacing:.08em;color:var(--muted)}
.current{text-align:center;background:linear-gradient(145deg,#163c28,#101d18)}
.current-name{font-size:clamp(2rem,8vw,4.2rem);line-height:1;font-weight:1000;margin:.3rem 0}
.clock{font-size:clamp(3.3rem,15vw,7rem);font-variant-numeric:tabular-nums;
line-height:1;font-weight:1000;color:var(--gold)}
.clock.expired{color:var(--red);animation:pulse .8s infinite alternate}
@keyframes pulse{to{opacity:.55}}
.row{display:flex;gap:.55rem;align-items:center;flex-wrap:wrap}
.row>*{flex:1}
.shots{display:grid;grid-template-columns:repeat(4,1fr);gap:.5rem;margin-top:.75rem}
.shots button{font-size:1.45rem;min-height:62px}
.shots .bad{background:#57252a}
.shots .timeout{grid-column:span 2;background:#702b30}
.note{color:var(--muted);font-size:.86rem}
.callout{padding:.65rem;border-radius:10px;background:#0a1511;border-left:4px solid var(--gold)}
.side{display:grid;gap:.8rem;align-content:start}
.metrics{display:grid;grid-template-columns:repeat(4,1fr);gap:.55rem}
.metric{background:#0b1712;border-radius:12px;padding:.7rem;text-align:center}
.metric b{display:block;font-size:1.45rem}
.metric span{font-size:.75rem;color:var(--muted);text-transform:uppercase}
.table-wrap{overflow:auto;max-height:63vh;border:1px solid var(--line);border-radius:12px}
table{border-collapse:separate;border-spacing:0;width:100%;min-width:650px}
th,td{padding:.55rem .6rem;text-align:center;border-right:1px solid #263d32;
border-bottom:1px solid #263d32;white-space:nowrap}
th{position:sticky;top:0;background:#1c3027;z-index:2;color:#cbd8d0}
th:first-child,td:first-child{position:sticky;left:0;text-align:left;background:#122119;z-index:1}
th:first-child{z-index:3;background:#1c3027}
tr.out td{opacity:.55}
.cell-bad{color:var(--red);font-weight:900}
.cell-good{color:var(--green);font-weight:900}
.chips{letter-spacing:.08em;color:var(--gold)}
.leader{outline:2px solid var(--green);outline-offset:-2px}
.standings{display:grid;grid-template-columns:repeat(auto-fit,minmax(170px,1fr));gap:.45rem}
.person{background:#0b1712;border:1px solid #263d32;border-radius:11px;padding:.55rem}
.person b{font-size:1.05rem}
.person .rank{color:var(--gold);font-weight:1000;margin-right:.35rem}
.log{max-height:220px;overflow:auto;font-size:.9rem}
.log div{padding:.38rem 0;border-bottom:1px solid #263d32}
dialog{width:min(94vw,620px);border:1px solid var(--line);border-radius:18px;
background:#102018;color:var(--text);padding:1rem}
dialog::backdrop{background:#000b}
label{display:block;font-weight:800;margin:.7rem 0 .25rem}
input,textarea{width:100%;background:#07110d;color:var(--text);border:1px solid var(--line);
border-radius:9px;padding:.65rem}
textarea{min-height:190px;resize:vertical}
.form-grid{display:grid;grid-template-columns:repeat(2,1fr);gap:.7rem}
#exitBoard{display:none;position:fixed;right:12px;top:12px;z-index:30}
body.board header,body.board .operator,body.board .log-card{display:none}
body.board main{display:block;max-width:none;padding:1rem}
body.board #exitBoard{display:block}
body.board .side{display:grid}
body.board .metrics{grid-template-columns:repeat(4,1fr)}
body.board .metric b{font-size:clamp(2rem,5vw,5rem)}
body.board .metric span{font-size:clamp(.8rem,1.5vw,1.4rem)}
body.board .standings{grid-template-columns:repeat(4,1fr)}
body.board .person{font-size:clamp(1rem,2.2vw,2rem);padding:1rem}
body.board table{font-size:clamp(1rem,1.7vw,1.7rem)}
body.board .table-wrap{max-height:none}
.end-banner{font-size:1.35rem;font-weight:1000;color:var(--gold);padding:.8rem;
border:2px solid var(--gold);border-radius:12px;text-align:center}
.hidden{display:none!important}
@media(max-width:850px){
main{grid-template-columns:1fr}.metrics{grid-template-columns:repeat(2,1fr)}
header button{min-height:40px;padding:.5rem .65rem}
}
</style>
</head>
<body>
<button id="exitBoard" class="warn">Exit TV mode</button>
<header>
<div class="top">
<div class="brand">🎱 Thursday Three-Ball</div>
<span class="badge" id="headRound"></span>
<span class="badge" id="headPot"></span>
<button id="newBtn">New night</button>
<button id="undoBtn">Undo</button>
<button id="backupBtn">Backup</button>
<button id="restoreBtn">Restore</button>
<button id="tvBtn">Open TV</button>
<button id="boardBtn">TV mode</button>
</div>
</header>
<main>
<section class="operator">
<div class="card current">
<h2 id="turnLabel">Current shooter</h2>
<div class="current-name" id="currentName">—</div>
<div class="clock" id="clock">1:15</div>
<div class="row" style="margin-top:.7rem">
<button id="startClock" class="gold">Call player / start clock</button>
<button id="pauseClock">Pause</button>
</div>
<div class="shots" id="shots">
<button data-shot="1">1</button><button data-shot="2">2</button>
<button data-shot="3">3</button><button data-shot="4">4</button>
<button class="bad" data-shot="5">5</button><button class="bad" data-shot="6">6</button>
<button class="bad" id="otherShot">Other</button>
<button class="timeout" id="timeoutBtn">No-show / timeout</button>
</div>
<p class="note" style="margin:.65rem 0 0">Scores over 4 and timeouts cost one chip. Recording a result stops the clock.</p>
</div>
<div class="card" style="margin-top:.8rem">
<h2>Last action</h2>
<div class="callout" id="lastAction">Demo night loaded.</div>
</div>
<div class="card log-card" style="margin-top:.8rem">
<h2>Recent calls</h2>
<div class="log" id="log"></div>
</div>
</section>
<section class="side">
<div class="card">
<div id="endBanner" class="end-banner hidden"></div>
<div class="metrics">
<div class="metric"><b id="mPot">$0</b><span>Pot</span></div>
<div class="metric"><b id="mRound">1</b><span>Round</span></div>
<div class="metric"><b id="mAlive">0</b><span>Still in</span></div>
<div class="metric"><b id="mEarly">—</b><span>Early leader</span></div>
</div>
</div>
<div class="card">
<h2>Standings</h2>
<div class="standings" id="standings"></div>
</div>
<div class="card">
<h2>Scorecard</h2>
<div class="table-wrap" id="tableWrap"><table id="scorecard"></table></div>
</div>
<div class="card">
<h2>Money</h2>
<div id="money"></div>
</div>
</section>
</main>
<dialog id="setup">
<form method="dialog" id="setupForm">
<h1>Start a new night</h1>
<p class="note">One player per line, in shooting order. This erases the current game after confirmation.</p>
<label for="names">Players</label>
<textarea id="names"></textarea>
<div class="form-grid">
<div><label for="buyin">Buy-in ($)</label><input id="buyin" type="number" min="0" step=".01"></div>
<div><label for="initialChips">Starting chips</label><input id="initialChips" type="number" min="1" max="20"></div>
<div><label for="clockSeconds">Turn clock (seconds)</label><input id="clockSeconds" type="number" min="10" max="600"></div>
<div><label for="earlyRounds">Early-prize rounds</label><input id="earlyRounds" type="number" min="1" max="20"></div>
<div><label for="firstPct">First place %</label><input id="firstPct" type="number" min="0" max="100"></div>
<div><label for="secondPct">Second place %</label><input id="secondPct" type="number" min="0" max="100"></div>
</div>
<p class="note">The percentage left after first and second goes to the early prize.</p>
<div class="row">
<button value="cancel">Cancel</button>
<button value="default" id="loadDemo">Reload demo</button>
<button value="start" class="primary" id="startNight">Start night</button>
</div>
</form>
</dialog>
<input id="restoreFile" type="file" accept="application/json" hidden>
<script>
"use strict";
const RULESET="three-ball-1.0";
const demoNames=["Maya","Dre","Luis","Erin","Cal","Nina","Bo","Tasha","Mick","Jo","Ray","Sam"];
const defaultConfig={buyin:20,initialChips:5,clockSeconds:75,earlyRounds:3,firstPct:80,secondPct:15};
let game;
const $=s=>document.querySelector(s);
const money=c=>new Intl.NumberFormat("en-US",{style:"currency",currency:"USD"}).format(c/100);
const esc=s=>String(s).replace(/[&<>"']/g,c=>({"&":"&","<":"<",">":">","\"":""","'":"'"}[c]));
const playerName=(g,id)=>g.players.find(p=>p.id===id)?.name||"Unknown";
function fresh(names,config=defaultConfig){
return {
ruleset:RULESET,
config:{...defaultConfig,...config},
players:names.map((name,i)=>({id:"p"+(i+1),name:name.trim()})),
events:[],
created:new Date().toISOString()
};
}
/* This pure reducer is the rules engine. The playback uses the same state transitions. */
function derive(g,limit=g.events.length){
const chips=Object.fromEntries(g.players.map(p=>[p.id,g.config.initialChips]));
const active=Object.fromEntries(g.players.map(p=>[p.id,true]));
const records=[],eliminated=[];
let round=1,cursor=0,ended=g.players.length<=1,last=null;
for(let n=0;n<Math.min(limit,g.events.length)&&!ended;n++){
while(!active[g.players[cursor].id]) cursor=(cursor+1)%g.players.length;
const p=g.players[cursor],ev=g.events[n];
if(ev.playerId!==p.id) break;
const penalty=ev.kind==="timeout"||ev.shots>4;
if(penalty) chips[p.id]--;
records.push({...ev,round,penalty,index:n});
if(chips[p.id]<=0){chips[p.id]=0;active[p.id]=false;eliminated.push(p.id)}
last=records[records.length-1];
const alive=g.players.filter(x=>active[x.id]);
if(alive.length<=1){ended=true;break}
const old=cursor;
do cursor=(cursor+1)%g.players.length; while(!active[g.players[cursor].id]);
if(cursor<=old) round++;
}
const alive=g.players.filter(p=>active[p.id]);
return {
chips,active,records,eliminated,round,ended,last,
current:ended?null:g.players[cursor],
winner:ended?alive[0]||null:null,
runner:ended&&eliminated.length?g.players.find(p=>p.id===eliminated.at(-1)):null
};
}
function earlyLeaders(g,s){
const values=[];
for(const p of g.players){
const rs=s.records.filter(r=>r.playerId===p.id&&r.round<=g.config.earlyRounds);
if(rs.length<g.config.earlyRounds) continue;
const avg=rs.reduce((a,r)=>a+(r.kind==="timeout"?8:r.shots),0)/rs.length;
values.push({p,avg});
}
if(!values.length) return [];
const low=Math.min(...values.map(x=>x.avg));
return values.filter(x=>Math.abs(x.avg-low)<1e-9);
}
function liveEarly(g,s){
const values=g.players.map(p=>{
const rs=s.records.filter(r=>r.playerId===p.id&&r.round<=g.config.earlyRounds);
return rs.length?{p,avg:rs.reduce((a,r)=>a+(r.kind==="timeout"?8:r.shots),0)/rs.length,n:rs.length}:null;
}).filter(Boolean);
if(!values.length)return[];
const bestN=Math.max(...values.map(x=>x.n));
const eligible=values.filter(x=>x.n===bestN);
const low=Math.min(...eligible.map(x=>x.avg));
return eligible.filter(x=>x.avg===low);
}
function payouts(g,s){
const pot=Math.round(g.players.length*g.config.buyin*100);
let first=Math.round(pot*g.config.firstPct/100);
const second=Math.round(pot*g.config.secondPct/100);
let early=pot-first-second;
const leaders=earlyLeaders(g,s);
if(!leaders.length){first+=early;early=0}
const lines=[];
if(s.winner)lines.push({name:s.winner.name,label:"First place",cents:first});
if(s.runner)lines.push({name:s.runner.name,label:"Second place",cents:second});
if(leaders.length){
const base=Math.floor(early/leaders.length),rem=early-base*leaders.length;
leaders.forEach((x,i)=>lines.push({name:x.p.name,label:"Early prize",cents:base+(i<rem?1:0)}));
}
return {pot,first,second,early,leaders,lines};
}
function demo(){
game=fresh(demoNames);
const values=[3,5,4,6,2,4,"T",3,5,4,2,6,4,5,3,6];
for(const v of values){
const s=derive(game);
if(s.ended)break;
game.events.push(v==="T"?{playerId:s.current.id,kind:"timeout"}:
{playerId:s.current.id,kind:"score",shots:v});
}
}
let clockLeft=75,clockRunning=false,clockDeadline=0,tvWindow=null;
function resetClock(){
clockRunning=false;
clockLeft=game.config.clockSeconds;
showClock();
}
function showClock(){
const n=Math.max(0,Math.ceil(clockLeft));
$("#clock").textContent=Math.floor(n/60)+":"+String(n%60).padStart(2,"0");
$("#clock").classList.toggle("expired",n<=0);
}
setInterval(()=>{
if(!clockRunning)return;
clockLeft=(clockDeadline-Date.now())/1000;
if(clockLeft<=0){
clockLeft=0;clockRunning=false;showClock();
const s=derive(game);
if(!s.ended) record({playerId:s.current.id,kind:"timeout",automatic:true});
}else showClock();
},200);
function record(ev){
const s=derive(game);
if(s.ended||!s.current||ev.playerId!==s.current.id)return;
game.events.push({...ev,at:new Date().toISOString()});
resetClock();render();
}
function outcomeText(r){
if(!r)return"Night ready.";
const name=playerName(game,r.playerId);
if(r.kind==="timeout")return `${name} timed out and lost a chip.`;
return `${name} cleared in ${r.shots} shot${r.shots===1?"":"s"}${r.penalty?" and lost a chip.":"."}`;
}
function averageFor(id,s){
const rs=s.records.filter(r=>r.playerId===id);
return rs.length?rs.reduce((a,r)=>a+(r.kind==="timeout"?8:r.shots),0)/rs.length:99;
}
function sortedStandings(s){
return [...game.players].sort((a,b)=>
Number(s.active[b.id])-Number(s.active[a.id])||
s.chips[b.id]-s.chips[a.id]||
averageFor(a.id,s)-averageFor(b.id,s)||
game.players.indexOf(a)-game.players.indexOf(b)
);
}
function render(){
const s=derive(game),pot=Math.round(game.players.length*game.config.buyin*100);
$("#headRound").textContent=s.ended?"FINAL":`Round ${s.round}`;
$("#headPot").textContent=`Pot ${money(pot)}`;
$("#mPot").textContent=money(pot);
$("#mRound").textContent=s.ended?"Final":s.round;
$("#mAlive").textContent=game.players.filter(p=>s.active[p.id]).length;
const live=liveEarly(game,s);
$("#mEarly").textContent=live.length?live.map(x=>x.p.name).join(" / "):"—";
$("#currentName").textContent=s.ended?(s.winner?.name||"Finished"):(s.current?.name||"—");
$("#turnLabel").textContent=s.ended?"Winner":`Round ${s.round} · current shooter`;
$("#lastAction").textContent=outcomeText(s.last);
$("#undoBtn").disabled=!game.events.length;
$("#startClock").disabled=s.ended||clockRunning;
$("#pauseClock").disabled=!clockRunning;
$("#shots").querySelectorAll("button").forEach(b=>b.disabled=s.ended);
$("#endBanner").classList.toggle("hidden",!s.ended);
if(s.ended)$("#endBanner").textContent=`🏆 ${s.winner?.name||"No winner"} wins ${money(payouts(game,s).first)}`;
const order=sortedStandings(s);
$("#standings").innerHTML=order.map((p,i)=>`
<div class="person ${s.current?.id===p.id?"leader":""}">
<span class="rank">${i+1}</span><b>${esc(p.name)}</b><br>
<span class="chips">${s.active[p.id]?"●".repeat(s.chips[p.id]):"OUT"}</span>
<span class="note">${s.active[p.id]?` ${s.chips[p.id]} chips`:""}</span>
</div>`).join("");
const maxRound=Math.max(s.round,...s.records.map(r=>r.round),1);
let html="<thead><tr><th>Player</th><th>Chips</th>";
for(let r=1;r<=maxRound;r++)html+=`<th>R${r}</th>`;
html+="</tr></thead><tbody>";
for(const p of game.players){
html+=`<tr class="${s.active[p.id]?"":"out"}"><td>${esc(p.name)}</td>
<td class="chips">${s.active[p.id]?"●".repeat(s.chips[p.id]):"OUT"}</td>`;
for(let r=1;r<=maxRound;r++){
const x=s.records.find(v=>v.playerId===p.id&&v.round===r);
const val=x?(x.kind==="timeout"?"TO":x.shots):"";
html+=`<td class="${x?(x.penalty?"cell-bad":"cell-good"):""}">${val}</td>`;
}
html+="</tr>";
}
$("#scorecard").innerHTML=html+"</tbody>";
$("#log").innerHTML=[...s.records].reverse().slice(0,16).map(r=>
`<div><b>R${r.round}</b> ${esc(outcomeText(r))}</div>`).join("")||"<div>No turns yet.</div>";
const pay=payouts(game,s);
if(s.ended){
$("#money").innerHTML=`<p><b>Total pot: ${money(pay.pot)}</b></p>`+
pay.lines.map(x=>`<div class="callout" style="margin:.4rem 0"><b>${esc(x.name)}</b> — ${x.label}: <b>${money(x.cents)}</b></div>`).join("");
}else{
const earlyPct=100-game.config.firstPct-game.config.secondPct;
$("#money").innerHTML=`<p><b>${money(pay.pot)} collected by the operator.</b></p>
<p class="note">When the game ends: first ${game.config.firstPct}%,
second ${game.config.secondPct}%, early prize ${earlyPct}%.</p>
<p>Live early leader: <b>${live.length?live.map(x=>esc(x.p.name)).join(" / "):"not enough scores yet"}</b></p>`;
}
updateTV(s);
}
function tvMarkup(s){
const pot=Math.round(game.players.length*game.config.buyin*100);
const live=liveEarly(game,s),order=sortedStandings(s);
return `<div class="tvhead"><div>THURSDAY THREE-BALL</div><div>${s.ended?"FINAL":"ROUND "+s.round}</div></div>
<div class="hero">${s.ended?`🏆 ${esc(s.winner?.name||"Finished")}`:`UP NOW: ${esc(s.current?.name||"—")}`}</div>
<div class="stats"><div><b>${money(pot)}</b><small>POT</small></div>
<div><b>${game.players.filter(p=>s.active[p.id]).length}</b><small>STILL IN</small></div>
<div><b>${live.length?live.map(x=>esc(x.p.name)).join(" / "):"—"}</b><small>EARLY LEADER</small></div></div>
<div class="grid">${order.map((p,i)=>`<div class="p ${s.current?.id===p.id?"now":""}">
<strong>${i+1}. ${esc(p.name)}</strong><span>${s.active[p.id]?"●".repeat(s.chips[p.id]):"OUT"}</span></div>`).join("")}</div>
<div class="last">${esc(outcomeText(s.last))}</div>`;
}
function openTV(){
try{
tvWindow=window.open("","threeBallTV","width=1280,height=720");
if(!tvWindow)throw Error();
tvWindow.document.open();
tvWindow.document.write(`<!doctype html><html><head><title>Three-Ball TV</title><style>
*{box-sizing:border-box}body{margin:0;padding:2vw;background:#07110d;color:#f5f1df;
font-family:system-ui,sans-serif;font-weight:800}.tvhead{display:flex;justify-content:space-between;
color:#42d17c;font-size:3vw}.hero{text-align:center;font-size:6vw;color:#ffd166;margin:2vh 0}
.stats{display:grid;grid-template-columns:repeat(3,1fr);gap:1vw}.stats div,.p{
background:#122119;border:2px solid #315042;border-radius:16px;padding:1vw;text-align:center}
.stats b{display:block;font-size:3vw}.stats small{color:#aab9af;font-size:1.3vw}
.grid{display:grid;grid-template-columns:repeat(4,1fr);gap:.7vw;margin-top:1.2vw}
.p{display:flex;justify-content:space-between;font-size:2vw}.p span{color:#ffd166}.p.now{border-color:#42d17c;background:#173d29}
.last{text-align:center;font-size:2.2vw;margin-top:2vh;color:#cbd8d0}</style></head><body><main id="screen"></main></body></html>`);
tvWindow.document.close();updateTV(derive(game));
}catch(e){
alert("The TV popup was blocked. Use TV mode instead, then mirror or cast this screen.");
}
}
function updateTV(s){
if(tvWindow&&!tvWindow.closed){
const node=tvWindow.document.getElementById("screen");
if(node)node.innerHTML=tvMarkup(s);
}
}
$("#shots").addEventListener("click",e=>{
const n=Number(e.target.dataset.shot);
if(n){const s=derive(game);record({playerId:s.current.id,kind:"score",shots:n})}
});
$("#otherShot").onclick=()=>{
const s=derive(game);if(s.ended)return;
const n=Number(prompt("Exact number of shots:","7"));
if(Number.isInteger(n)&&n>0)record({playerId:s.current.id,kind:"score",shots:n});
};
$("#timeoutBtn").onclick=()=>{const s=derive(game);if(!s.ended&&confirm(`Charge ${s.current.name} a timeout?`))record({playerId:s.current.id,kind:"timeout"})};
$("#startClock").onclick=()=>{if(!derive(game).ended){clockRunning=true;clockDeadline=Date.now()+clockLeft*1000;render()}};
$("#pauseClock").onclick=()=>{if(clockRunning){clockLeft=(clockDeadline-Date.now())/1000;clockRunning=false;showClock();render()}};
$("#undoBtn").onclick=()=>{
if(game.events.length&&confirm("Undo the most recently recorded turn?")){game.events.pop();resetClock();render()}
};
$("#tvBtn").onclick=openTV;
$("#boardBtn").onclick=()=>{document.body.classList.add("board");document.documentElement.requestFullscreen?.().catch(()=>{})};
$("#exitBoard").onclick=()=>{document.body.classList.remove("board");document.exitFullscreen?.().catch(()=>{})};
function fillSetup(){
$("#names").value=game.players.map(p=>p.name).join("\n");
$("#buyin").value=game.config.buyin;$("#initialChips").value=game.config.initialChips;
$("#clockSeconds").value=game.config.clockSeconds;$("#earlyRounds").value=game.config.earlyRounds;
$("#firstPct").value=game.config.firstPct;$("#secondPct").value=game.config.secondPct;
}
$("#newBtn").onclick=()=>{fillSetup();$("#setup").showModal()};
$("#startNight").onclick=e=>{
e.preventDefault();
const names=$("#names").value.split(/\n/).map(x=>x.trim()).filter(Boolean);
const cfg={buyin:Number($("#buyin").value),initialChips:Number($("#initialChips").value),
clockSeconds:Number($("#clockSeconds").value),earlyRounds:Number($("#earlyRounds").value),
firstPct:Number($("#firstPct").value),secondPct:Number($("#secondPct").value)};
if(names.length<2)return alert("Enter at least two players.");
if(!Object.values(cfg).every(Number.isFinite)||cfg.firstPct+cfg.secondPct>100)
return alert("Check the settings. First and second cannot total more than 100%.");
if(!confirm(`Start over with ${names.length} players?`))return;
game=fresh(names,cfg);$("#setup").close();resetClock();render();
};
$("#loadDemo").onclick=e=>{e.preventDefault();if(confirm("Replace the current game with the demo?")){demo();$("#setup").close();resetClock();render()}};
$("#backupBtn").onclick=()=>{
const blob=new Blob([JSON.stringify(game,null,2)],{type:"application/json"});
const a=document.createElement("a");a.href=URL.createObjectURL(blob);
a.download=`three-ball-${new Date().toISOString().slice(0,10)}.json`;a.click();
setTimeout(()=>URL.revokeObjectURL(a.href),1000);
};
$("#restoreBtn").onclick=()=>$("#restoreFile").click();
$("#restoreFile").onchange=async e=>{
try{
const data=JSON.parse(await e.target.files[0].text());
if(data.ruleset!==RULESET||!Array.isArray(data.players)||!Array.isArray(data.events))throw Error();
if(confirm("Replace the current game with this backup?")){game=data;resetClock();render()}
}catch(err){alert("That is not a valid Three-Ball backup.")}
e.target.value="";
};
demo();resetClock();render();
</script>
</body>
</html>
<!doctype html>
<html lang="en">
<head>
<meta charset="utf-8">
<meta name="viewport" content="width=device-width,initial-scale=1">
<title>Thursday Three-Ball Playback</title>
<style>
:root{color-scheme:dark;--bg:#08110e;--panel:#112019;--line:#315042;--text:#f5f1df;
--muted:#aab9af;--green:#42d17c;--gold:#ffd166;--red:#ff6b6b}
*{box-sizing:border-box}
body{margin:0;background:var(--bg);color:var(--text);font-family:system-ui,-apple-system,Segoe UI,sans-serif}
button,input{font:inherit}
header{position:sticky;top:0;z-index:4;background:#08110ef2;border-bottom:1px solid var(--line);padding:.8rem}
.top{max-width:1450px;margin:auto;display:flex;gap:.7rem;align-items:center;flex-wrap:wrap}
h1{font-size:1.15rem;margin:0 auto 0 0}
button{min-height:46px;padding:.55rem .9rem;border:1px solid var(--line);border-radius:11px;
background:#20382d;color:var(--text);font-weight:900}
button.primary{background:var(--green);color:#06110a}
button:disabled{opacity:.4}
.speed{display:flex;align-items:center;gap:.5rem;background:#122119;padding:.4rem .7rem;border-radius:11px}
.speed input{width:150px}
main{max-width:1450px;margin:auto;padding:.8rem;display:grid;grid-template-columns:minmax(300px,430px) 1fr;gap:.8rem}
.card{background:var(--panel);border:1px solid var(--line);border-radius:16px;padding:.9rem}
.left{display:grid;gap:.8rem;align-content:start}
.label{text-transform:uppercase;letter-spacing:.1em;color:var(--muted);font-size:.8rem;font-weight:900}
.now{text-align:center;background:linear-gradient(145deg,#163c28,#101d18)}
.name{font-size:clamp(2.4rem,8vw,5rem);font-weight:1000;line-height:1;color:var(--gold);margin:.5rem 0}
.event{font-size:1.25rem;line-height:1.35;padding:1rem;background:#09140f;border-left:5px solid var(--gold);border-radius:11px}
.progress{font-variant-numeric:tabular-nums;color:var(--muted)}
.metrics{display:grid;grid-template-columns:repeat(4,1fr);gap:.55rem}
.metric{text-align:center;background:#09140f;border-radius:11px;padding:.7rem}
.metric b{display:block;font-size:1.45rem}.metric small{color:var(--muted)}
.standings{display:grid;grid-template-columns:repeat(auto-fit,minmax(150px,1fr));gap:.45rem}
.person{display:flex;justify-content:space-between;background:#09140f;border:1px solid #263d32;
border-radius:10px;padding:.55rem;font-weight:800}.person span{color:var(--gold)}.person.out{opacity:.45}
.table-wrap{overflow:auto;max-height:68vh;border:1px solid var(--line);border-radius:12px}
table{border-collapse:separate;border-spacing:0;width:100%;min-width:760px}
th,td{padding:.52rem;text-align:center;border-right:1px solid #263d32;border-bottom:1px solid #263d32}
th{position:sticky;top:0;background:#1c3027;z-index:2}
th:first-child,td:first-child{position:sticky;left:0;text-align:left;background:#122119;z-index:1}
th:first-child{z-index:3;background:#1c3027}
.good{color:var(--green);font-weight:1000}.bad{color:var(--red);font-weight:1000}
.finished{border:2px solid var(--gold);color:var(--gold);font-size:1.25rem;font-weight:1000;
text-align:center;border-radius:12px;padding:1rem}
.money div{padding:.5rem 0;border-bottom:1px solid #263d32}
@media(max-width:850px){main{grid-template-columns:1fr}.metrics{grid-template-columns:repeat(2,1fr)}}
</style>
</head>
<body>
<header>
<div class="top">
<h1>🎬 Thursday Three-Ball Playback</h1>
<button id="back">◀ Back</button>
<button id="play" class="primary">▶ Play</button>
<button id="next">Next ▶</button>
<button id="restart">Restart</button>
<label class="speed">Speed
<input id="speed" type="range" min=".25" max="4" value="1" step=".25">
<b id="speedText">1×</b>
</label>
</div>
</header>
<main>
<section class="left">
<div class="card now">
<div class="label" id="phase">Before the first break</div>
<div class="name" id="current">Ready</div>
<div class="progress" id="progress">Step 0</div>
</div>
<div class="card">
<div class="label">What just happened</div>
<div class="event" id="event">Twelve players paid in. Maya is first in the order.</div>
</div>
<div class="card">
<div class="metrics">
<div class="metric"><b id="pot">$240</b><small>Pot</small></div>
<div class="metric"><b id="round">1</b><small>Round</small></div>
<div class="metric"><b id="alive">12</b><small>Still in</small></div>
<div class="metric"><b id="early">—</b><small>Early leader</small></div>
</div>
</div>
<div class="card">
<div class="label">Standings</div>
<div class="standings" id="standings"></div>
</div>
<div class="card money">
<div class="label">Money at this point</div>
<div id="money"></div>
</div>
</section>
<section class="card">
<div class="label">Scorecard filling in</div>
<div class="table-wrap" id="tableWrap"><table id="scorecard"></table></div>
</section>
</main>
<script>
"use strict";
const RULESET="three-ball-1.0";
const names=["Maya","Dre","Luis","Erin","Cal","Nina","Bo","Tasha","Mick","Jo","Ray","Sam"];
const config={buyin:20,initialChips:5,clockSeconds:75,earlyRounds:3,firstPct:80,secondPct:15};
const game={ruleset:RULESET,config,players:names.map((name,i)=>({id:"p"+(i+1),name})),events:[]};
const $=s=>document.querySelector(s);
const esc=s=>String(s).replace(/[&<>"']/g,c=>({"&":"&","<":"<",">":">","\"":""","'":"'"}[c]));
const cash=c=>new Intl.NumberFormat("en-US",{style:"currency",currency:"USD"}).format(c/100);
const pname=id=>game.players.find(p=>p.id===id)?.name||"Unknown";
/* Identical pure rules reducer to the console. */
function derive(g,limit=g.events.length){
const chips=Object.fromEntries(g.players.map(p=>[p.id,g.config.initialChips]));
const active=Object.fromEntries(g.players.map(p=>[p.id,true]));
const records=[],eliminated=[];
let round=1,cursor=0,ended=g.players.length<=1,last=null;
for(let n=0;n<Math.min(limit,g.events.length)&&!ended;n++){
while(!active[g.players[cursor].id])cursor=(cursor+1)%g.players.length;
const p=g.players[cursor],ev=g.events[n];
if(ev.playerId!==p.id)break;
const penalty=ev.kind==="timeout"||ev.shots>4;
if(penalty)chips[p.id]--;
records.push({...ev,round,penalty,index:n});
if(chips[p.id]<=0){chips[p.id]=0;active[p.id]=false;eliminated.push(p.id)}
last=records.at(-1);
const alive=g.players.filter(x=>active[x.id]);
if(alive.length<=1){ended=true;break}
const old=cursor;
do cursor=(cursor+1)%g.players.length;while(!active[g.players[cursor].id]);
if(cursor<=old)round++;
}
const alive=g.players.filter(p=>active[p.id]);
return{chips,active,records,eliminated,round,ended,last,
current:ended?null:g.players[cursor],
winner:ended?alive[0]||null:null,
runner:ended&&eliminated.length?g.players.find(p=>p.id===eliminated.at(-1)):null};
}
function earlyLeaders(g,s){
const vals=[];
for(const p of g.players){
const rs=s.records.filter(r=>r.playerId===p.id&&r.round<=g.config.earlyRounds);
if(rs.length<g.config.earlyRounds)continue;
vals.push({p,avg:rs.reduce((a,r)=>a+(r.kind==="timeout"?8:r.shots),0)/rs.length});
}
if(!vals.length)return[];
const low=Math.min(...vals.map(x=>x.avg));
return vals.filter(x=>Math.abs(x.avg-low)<1e-9);
}
function liveEarly(s){
const vals=game.players.map(p=>{
const rs=s.records.filter(r=>r.playerId===p.id&&r.round<=config.earlyRounds);
return rs.length?{p,n:rs.length,avg:rs.reduce((a,r)=>a+(r.kind==="timeout"?8:r.shots),0)/rs.length}:null;
}).filter(Boolean);
if(!vals.length)return[];
const n=Math.max(...vals.map(x=>x.n)),pool=vals.filter(x=>x.n===n);
const low=Math.min(...pool.map(x=>x.avg));
return pool.filter(x=>x.avg===low);
}
function payout(s){
const pot=game.players.length*config.buyin*100;
let first=Math.round(pot*config.firstPct/100),second=Math.round(pot*config.secondPct/100);
let early=pot-first-second,leaders=earlyLeaders(game,s),lines=[];
if(!leaders.length){first+=early;early=0}
if(s.winner)lines.push({name:s.winner.name,label:"First place",value:first});
if(s.runner)lines.push({name:s.runner.name,label:"Second place",value:second});
if(leaders.length){
const each=Math.floor(early/leaders.length),rem=early-each*leaders.length;
leaders.forEach((x,i)=>lines.push({name:x.p.name,label:"Early prize",value:each+(i<rem?1:0)}));
}
return{pot,first,second,early,leaders,lines};
}
/* Build a deterministic complete night by asking the rules engine who shoots next. */
function buildNight(){
const danger=[5,6,4,6,2,5,7,4,6,5,7,6];
while(!derive(game).ended&&game.events.length<400){
const s=derive(game),i=game.players.indexOf(s.current),r=s.round;
let kind="score",shots;
if(i!==4&&r>=9+(i%4)){
shots=5+(i+r)%2;
}else{
const h=(r*17+i*23+r*i*3)%10;
const bad=h<danger[i];
if(bad&&((r+i)%11===0)){kind="timeout"}
else if(bad)shots=5+((r+i)%2);
else shots=1+((r*3+i*2)%4);
}
game.events.push(kind==="timeout"?{playerId:s.current.id,kind}:
{playerId:s.current.id,kind,shots});
}
}
buildNight();
let step=0,playing=false,timer=null;
function avg(id,s){
const rs=s.records.filter(r=>r.playerId===id);
return rs.length?rs.reduce((a,r)=>a+(r.kind==="timeout"?8:r.shots),0)/rs.length:99;
}
function description(r,s){
if(!r)return"Twelve players paid $20 each. Maya is first in the fixed shooting order.";
const name=pname(r.playerId);
let text=r.kind==="timeout"
?`${name} did not reach the table before the clock expired. One chip is gone.`
:`${name} cleared all three balls in ${r.shots} shot${r.shots===1?"":"s"}. ${r.penalty?"That is over four, so one chip is gone.":"No chip lost."}`;
if(!s.active[r.playerId])text+=` ${name} is out of chips and eliminated.`;
if(s.ended)text+=` Only ${s.winner.name} remains, so the night is over.`;
return text;
}
function render(){
const s=derive(game,step),live=liveEarly(s);
$("#progress").textContent=`Step ${step} of ${game.events.length}`;
$("#phase").textContent=s.ended?"Night complete":step===0?"Before the first break":`Round ${s.round}`;
$("#current").textContent=s.ended?`🏆 ${s.winner.name}`:s.current.name;
$("#event").textContent=description(s.last,s);
$("#pot").textContent=cash(game.players.length*config.buyin*100);
$("#round").textContent=s.ended?"Final":s.round;
$("#alive").textContent=game.players.filter(p=>s.active[p.id]).length;
$("#early").textContent=live.length?live.map(x=>x.p.name).join(" / "):"—";
$("#back").disabled=step===0;$("#next").disabled=step===game.events.length;
$("#play").textContent=playing?"❚❚ Pause":"▶ Play";
const order=[...game.players].sort((a,b)=>
Number(s.active[b.id])-Number(s.active[a.id])||
s.chips[b.id]-s.chips[a.id]||avg(a.id,s)-avg(b.id,s)||
game.players.indexOf(a)-game.players.indexOf(b));
$("#standings").innerHTML=order.map((p,i)=>`<div class="person ${s.active[p.id]?"":"out"}">
<b>${i+1}. ${esc(p.name)}</b><span>${s.active[p.id]?"●".repeat(s.chips[p.id]):"OUT"}</span></div>`).join("");
const maxRound=Math.max(1,s.round,...s.records.map(r=>r.round));
let html="<thead><tr><th>Player</th><th>Chips</th>";
for(let r=1;r<=maxRound;r++)html+=`<th>R${r}</th>`;
html+="</tr></thead><tbody>";
for(const p of game.players){
html+=`<tr><td>${esc(p.name)}</td><td>${s.active[p.id]?"●".repeat(s.chips[p.id]):"OUT"}</td>`;
for(let r=1;r<=maxRound;r++){
const x=s.records.find(v=>v.playerId===p.id&&v.round===r);
html+=`<td class="${x?(x.penalty?"bad":"good"):""}">${x?(x.kind==="timeout"?"TO":x.shots):""}</td>`;
}
html+="</tr>";
}
$("#scorecard").innerHTML=html+"</tbody>";
if(s.ended){
const p=payout(s);
$("#money").innerHTML=`<div class="finished">${esc(s.winner.name)} wins</div>`+
p.lines.map(x=>`<div><b>${esc(x.name)}</b> — ${x.label}: <b>${cash(x.value)}</b></div>`).join("");
}else{
$("#money").innerHTML=`<div>Pot held by operator: <b>${cash(game.players.length*config.buyin*100)}</b></div>
<div>Projected split: first 80%, second 15%, early prize 5%</div>`;
}
}
function interval(){return 1800/Number($("#speed").value)}
function schedule(){
clearTimeout(timer);
if(!playing)return;
if(step>=game.events.length){playing=false;render();return}
timer=setTimeout(()=>{step++;render();schedule()},interval());
}
$("#play").onclick=()=>{playing=!playing;render();schedule()};
$("#next").onclick=()=>{playing=false;clearTimeout(timer);step=Math.min(game.events.length,step+1);render()};
$("#back").onclick=()=>{playing=false;clearTimeout(timer);step=Math.max(0,step-1);render()};
$("#restart").onclick=()=>{playing=false;clearTimeout(timer);step=0;render()};
$("#speed").oninput=()=>{
$("#speedText").textContent=Number($("#speed").value).toFixed(Number($("#speed").value)%1?2:0).replace(/0+$/,"").replace(/\.$/,"")+"×";
if(playing)schedule();
};
render();
</script>
</body>
</html>