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, maximum-scale=1, user-scalable=no">
<title>Thursday 3-Ball — Console</title>
<style>
:root { --bg:#0b0e13; --panel:#151a22; --panel2:#1d2430; --line:#2a3444; --text:#f4f7fb; --muted:#8b98ab; --accent:#ffb020; --good:#3ddc97; --bad:#ff5c5c; --blue:#5aa9ff; }
* { box-sizing: border-box; }
html,body { margin:0; padding:0; background:var(--bg); color:var(--text); font-family: system-ui, -apple-system, Segoe UI, Roboto, Arial, sans-serif; }
body { min-height:100vh; }
button { font: inherit; color: inherit; background: var(--panel2); border: 1px solid var(--line); border-radius: 14px; padding: 12px 16px; cursor: pointer; touch-action: manipulation; }
button:active { transform: translateY(1px); }
button.primary { background: var(--accent); color:#111; border-color: var(--accent); font-weight: 800; }
button.good { background: var(--good); color:#071; border-color: var(--good); font-weight: 800; }
button.bad { background: var(--bad); color:#fff; border-color: var(--bad); font-weight: 800; }
button.ghost { background: transparent; }
button:disabled { opacity: .35; cursor: not-allowed; }
.wrap { max-width: 1500px; margin: 0 auto; padding: 14px; }
.top { display:flex; flex-wrap:wrap; gap:10px; align-items:center; justify-content:space-between; margin-bottom:12px; }
.brand { font-size: clamp(20px, 2.4vw, 34px); font-weight: 900; letter-spacing: .5px; }
.brand span { color: var(--accent); }
.badges { display:flex; flex-wrap:wrap; gap:8px; }
.badge { background: var(--panel); border:1px solid var(--line); border-radius:999px; padding:8px 12px; font-size: clamp(12px, 1.2vw, 16px); color: var(--muted); }
.badge b { color: var(--text); }
.layout { display:grid; grid-template-columns: minmax(0, 1.55fr) minmax(340px, .9fr); gap:14px; align-items:start; }
@media (max-width: 980px) { .layout { grid-template-columns: 1fr; } }
.panel { background: var(--panel); border:1px solid var(--line); border-radius: 20px; padding: 14px; }
.panel h2 { margin:0 0 10px; font-size: clamp(14px, 1.4vw, 20px); color: var(--muted); text-transform: uppercase; letter-spacing: 1.5px; }
.score-table { width:100%; border-collapse: collapse; }
.score-table th { text-align:left; font-size: clamp(10px, 1vw, 14px); color: var(--muted); text-transform: uppercase; letter-spacing: 1px; padding: 6px 8px; border-bottom:1px solid var(--line); }
.score-table td { padding: 8px; border-bottom:1px solid var(--line); font-size: clamp(14px, 1.6vw, 24px); }
.score-table tr.current td { background: rgba(255,176,32,.12); }
.score-table tr.out td { opacity:.38; }
.score-table tr.side td:first-child { box-shadow: inset 4px 0 0 var(--blue); }
.name { font-weight: 800; }
.chips { display:flex; gap:4px; align-items:center; }
.chip { width: clamp(10px, 1.2vw, 16px); height: clamp(10px, 1.2vw, 16px); border-radius:50%; background: var(--accent); border:1px solid #ffd77a; }
.chip.empty { background: transparent; border-color: var(--line); }
.num { font-variant-numeric: tabular-nums; }
.status { font-size: clamp(10px, 1vw, 14px); color: var(--muted); }
.status.out { color: var(--bad); }
.status.side { color: var(--blue); }
.turn-panel { position: sticky; top: 10px; }
.turn-name { font-size: clamp(28px, 4vw, 56px); font-weight: 900; line-height:1; margin: 4px 0 8px; }
.turn-stats { display:flex; gap:10px; flex-wrap:wrap; margin: 10px 0; }
.stat { background: var(--panel2); border:1px solid var(--line); border-radius:14px; padding:10px 14px; min-width: 90px; }
.stat .k { font-size: 11px; color: var(--muted); text-transform: uppercase; letter-spacing:1px; }
.stat .v { font-size: clamp(22px, 2.6vw, 38px); font-weight: 900; font-variant-numeric: tabular-nums; }
.timer { font-size: clamp(28px, 4vw, 52px); font-weight: 900; font-variant-numeric: tabular-nums; color: var(--good); }
.timer.warn { color: var(--accent); }
.timer.danger { color: var(--bad); }
.actions { display:grid; grid-template-columns: 1fr 1fr; gap:10px; margin-top:12px; }
.actions .wide { grid-column: 1 / -1; }
.actions button { min-height: 58px; font-size: clamp(16px, 1.8vw, 24px); font-weight: 800; }
.small-actions { display:flex; flex-wrap:wrap; gap:8px; margin-top:10px; }
.small-actions button { padding:8px 10px; font-size: 13px; border-radius:10px; }
.log { margin-top:12px; max-height: 160px; overflow:auto; font-size: 13px; color: var(--muted); }
.log div { padding: 4px 0; border-bottom:1px solid rgba(255,255,255,.04); }
.payout { margin-top:12px; }
.payout-row { display:flex; justify-content:space-between; gap:10px; padding:10px 0; border-bottom:1px solid var(--line); font-size: clamp(16px, 1.8vw, 24px); }
.payout-row b { color: var(--accent); }
.demo { color: var(--accent); }
.hidden { display:none !important; }
</style>
</head>
<body>
<div class="wrap">
<div class="top">
<div class="brand">THURSDAY <span>3-BALL</span> <span id="demoTag" class="demo">DEMO</span></div>
<div class="badges">
<div class="badge">Round <b id="roundBadge">1</b></div>
<div class="badge">Pot <b id="potBadge">$240</b></div>
<div class="badge">Left <b id="leftBadge">12</b></div>
<div class="badge">Side <b id="sideBadge">—</b></div>
</div>
</div>
<div class="layout">
<div class="panel">
<h2>Scorecard</h2>
<div style="overflow:auto;">
<table class="score-table">
<thead><tr><th>Player</th><th>Chips</th><th>Shots</th><th>Turns</th><th>Status</th></tr></thead>
<tbody id="scoreBody"></tbody>
</table>
</div>
<div class="small-actions">
<button id="newNightBtn" class="ghost">New night</button>
<button id="addPlayerBtn" class="ghost">Add player</button>
<button id="undoBtn" class="ghost">Undo</button>
</div>
<div id="log" class="log"></div>
</div>
<div class="panel turn-panel" id="turnPanel">
<h2>On the table</h2>
<div id="turnContent"></div>
</div>
</div>
</div>
<script>
(function(){
'use strict';
const START_CHIPS = 5;
const MAX_FREE_SHOTS = 4;
const ENTRY_FEE = 20;
const SIDE_PRIZE = 10;
const CLOCK_SECONDS = 75;
const SECOND_SHARE = 0.20;
const DEFAULT_NAMES = ['Maya','Dre','Sofia','Ben','Tasha','Owen','Luis','Priya','Cal','Nina','Gus','Elle'];
let state;
let undoStack = [];
let timerId = null;
let timerRunning = false;
let timerLeft = CLOCK_SECONDS;
function clone(o){ return JSON.parse(JSON.stringify(o)); }
function uid(){ return 'p' + Math.random().toString(36).slice(2,9); }
function activePlayers(){ return state.players.filter(p=>!p.eliminated); }
function playerById(id){ return state.players.find(p=>p.id===id); }
function currentPlayer(){ return playerById(state.current.playerId); }
function pot(){ return state.players.length * ENTRY_FEE; }
function money(n){ return '$' + n; }
function makePlayer(name, idx){
return { id: uid(), name: name, chips: START_CHIPS, totalShots: 0, turns: 0, eliminated: false, eliminatedRound: null, eliminatedOrder: null, side: false, order: idx };
}
function newState(names, demoFlag){
const players = names.map((n,i)=>makePlayer(n,i));
return {
players: players,
round: 1,
current: { playerId: players[0].id, shots: 0, balls: 0 },
gameOver: false,
sideWinnerId: null,
sideAwarded: false,
eliminatedOrder: [],
payouts: null,
log: [],
demo: !!demoFlag
};
}
function log(msg){
state.log.push(msg);
if (state.log.length > 80) state.log.shift();
}
function pushUndo(){
undoStack.push(clone(state));
if (undoStack.length > 60) undoStack.shift();
}
function startTimer(){
if (timerRunning || state.gameOver) return;
if (timerLeft <= 0) return;
timerRunning = true;
if (timerId) clearInterval(timerId);
timerId = setInterval(()=>{
if (!timerRunning) return;
timerLeft--;
if (timerLeft <= 0) {
timerLeft = 0;
timerRunning = false;
clearInterval(timerId); timerId = null;
}
renderTimerOnly();
}, 1000);
render();
}
function pauseTimer(){
timerRunning = false;
if (timerId) { clearInterval(timerId); timerId = null; }
render();
}
function renderTimerOnly(){
const el = document.getElementById('timerEl');
if (el) {
el.textContent = timerLeft + 's';
el.className = 'timer' + (timerLeft <= 10 ? ' danger' : timerLeft <= 30 ? ' warn' : '');
}
}
function nextActiveFrom(index){
const n = state.players.length;
for (let step=1; step<=n; step++) {
const i = (index + step) % n;
if (!state.players[i].eliminated) return i;
}
return -1;
}
function beginTurn(playerId){
state.current = { playerId: playerId, shots: 0, balls: 0 };
timerLeft = CLOCK_SECONDS;
timerRunning = false;
if (timerId) { clearInterval(timerId); timerId = null; }
const p = playerById(playerId);
log('Round ' + state.round + ': ' + p.name + ' steps up.');
render();
}
function awardSidePrize(){
if (state.sideAwarded) return;
state.sideAwarded = true;
const eligible = state.players.filter(p=>p.turns > 0);
if (!eligible.length) return;
eligible.sort((a,b)=>{
if (a.totalShots !== b.totalShots) return a.totalShots - b.totalShots;
const aLost = START_CHIPS - a.chips;
const bLost = START_CHIPS - b.chips;
if (aLost !== bLost) return aLost - bLost;
return a.order - b.order;
});
const w = eligible[0];
state.sideWinnerId = w.id;
w.side = true;
log('Early Bird side prize: ' + w.name + ' ($' + SIDE_PRIZE + ') for ' + w.totalShots + ' shots through round 2.');
}
function finishGame(reason){
if (state.gameOver) return;
state.gameOver = true;
timerRunning = false;
if (timerId) { clearInterval(timerId); timerId = null; }
const active = activePlayers();
let winner = null, second = null;
if (active.length === 1) {
winner = active[0];
if (state.eliminatedOrder.length) {
second = playerById(state.eliminatedOrder[state.eliminatedOrder.length - 1]);
}
} else if (active.length > 1) {
const ranked = active.slice().sort((a,b)=>{
if (b.chips !== a.chips) return b.chips - a.chips;
if (a.totalShots !== b.totalShots) return a.totalShots - b.totalShots;
return a.order - b.order;
});
winner = ranked[0];
second = ranked[1] || null;
} else {
if (state.eliminatedOrder.length) {
winner = playerById(state.eliminatedOrder[state.eliminatedOrder.length - 1]);
second = state.eliminatedOrder.length > 1 ? playerById(state.eliminatedOrder[state.eliminatedOrder.length - 2]) : null;
}
}
if (!winner) { state.payouts = []; log('Game over.'); render(); return; }
const totalPot = pot();
let sideAmount = 0;
let sidePlayer = state.sideWinnerId ? playerById(state.sideWinnerId) : null;
if (sidePlayer) sideAmount = SIDE_PRIZE;
const remaining = Math.max(0, totalPot - sideAmount);
let secondAmount = 0;
if (second && second.id !== winner.id) {
secondAmount = Math.round(remaining * SECOND_SHARE);
}
let winnerAmount = remaining - secondAmount;
const payouts = [];
if (sidePlayer) payouts.push({ id: sidePlayer.id, name: sidePlayer.name, amount: sideAmount, reason: 'Early Bird side prize' });
payouts.push({ id: winner.id, name: winner.name, amount: winnerAmount, reason: 'Winner' });
if (second && second.id !== winner.id) payouts.push({ id: second.id, name: second.name, amount: secondAmount, reason: 'Second' });
const combined = [];
payouts.forEach(p=>{
const found = combined.find(c=>c.id===p.id);
if (found) { found.amount += p.amount; found.reason += ' + ' + p.reason; }
else combined.push({...p});
});
state.payouts = combined;
log('Game over (' + reason + '). Winner: ' + winner.name + '.');
render();
}
function endTurn(reason){
if (state.gameOver) return;
const p = currentPlayer();
if (!p) return;
const shots = state.current.shots;
const balls = state.current.balls;
const cleared = balls >= 3;
let penalty = false;
if (reason === 'clock' || reason === 'ended') penalty = true;
else if (cleared && shots > MAX_FREE_SHOTS) penalty = true;
else if (!cleared) penalty = true;
let eliminatedNow = false;
if (penalty) {
p.chips--;
if (p.chips <= 0) {
p.chips = 0;
p.eliminated = true;
p.eliminatedRound = state.round;
p.eliminatedOrder = state.eliminatedOrder.length;
state.eliminatedOrder.push(p.id);
eliminatedNow = true;
}
}
p.totalShots += shots;
p.turns++;
let msg = p.name + ' ';
if (reason === 'clock') msg += 'clock runs out after ' + shots + ' shot' + (shots===1?'':'s') + '.';
else if (cleared) msg += 'clears in ' + shots + ' shot' + (shots===1?'':'s') + '.';
else msg += 'ends early after ' + shots + ' shot' + (shots===1?'':'s') + '.';
if (penalty) msg += ' Loses a chip.';
else msg += ' No chip lost.';
log(msg);
if (eliminatedNow) log(p.name + ' is out of chips.');
if (state.gameOver) { render(); return; }
const active = activePlayers();
if (active.length <= 1) {
finishGame('last player with chips');
return;
}
const curIdx = state.players.findIndex(x=>x.id===p.id);
let nextIdx = nextActiveFrom(curIdx);
if (nextIdx === -1) { finishGame('no active players'); return; }
if (nextIdx <= curIdx) {
if (state.round === 2 && !state.sideAwarded) awardSidePrize();
state.round++;
log('Round ' + state.round + ' begins.');
}
beginTurn(state.players[nextIdx].id);
}
function recordShot(made){
if (state.gameOver) return;
pushUndo();
const p = currentPlayer();
if (!p) return;
state.current.shots++;
if (made) state.current.balls++;
const shotNo = state.current.shots;
if (made) log(p.name + ' makes ball ' + state.current.balls + ' (shot ' + shotNo + ').');
else log(p.name + ' misses (shot ' + shotNo + ').');
if (state.current.balls >= 3) {
endTurn('cleared');
} else {
render();
}
}
function clockOut(){
if (state.gameOver) return;
pushUndo();
timerRunning = false;
if (timerId) { clearInterval(timerId); timerId = null; }
endTurn('clock');
}
function endEarly(){
if (state.gameOver) return;
pushUndo();
endTurn('ended');
}
function undo(){
if (!undoStack.length) return;
state = undoStack.pop();
timerRunning = false;
if (timerId) { clearInterval(timerId); timerId = null; }
timerLeft = CLOCK_SECONDS;
render();
}
function newNight(){
pushUndo();
const names = state.players.map(p=>p.name);
state = newState(names, false);
timerLeft = CLOCK_SECONDS;
timerRunning = false;
if (timerId) { clearInterval(timerId); timerId = null; }
log('New night. ' + names.length + ' players, ' + money(pot()) + ' pot.');
render();
}
function addPlayer(){
if (state.gameOver) return;
const name = prompt('Player name?');
if (!name) return;
pushUndo();
const p = makePlayer(name.trim().slice(0,18) || 'Player', state.players.length);
state.players.push(p);
log(p.name + ' buys in. Pot is now ' + money(pot()) + '.');
render();
}
function render(){
const active = activePlayers();
document.getElementById('roundBadge').textContent = state.round;
document.getElementById('potBadge').textContent = money(pot());
document.getElementById('leftBadge').textContent = active.length;
document.getElementById('sideBadge').textContent = state.sideWinnerId ? playerById(state.sideWinnerId).name : '—';
document.getElementById('demoTag').style.display = state.demo ? '' : 'none';
const tbody = document.getElementById('scoreBody');
tbody.innerHTML = '';
state.players.forEach(p=>{
const tr = document.createElement('tr');
if (p.id === state.current.playerId && !state.gameOver) tr.className += ' current';
if (p.eliminated) tr.className += ' out';
if (p.side) tr.className += ' side';
const chips = [];
for (let i=0;i<START_CHIPS;i++) chips.push('<span class="chip' + (i < p.chips ? '' : ' empty') + '"></span>');
let status = p.eliminated ? 'OUT r' + p.eliminatedRound : (p.id === state.current.playerId && !state.gameOver ? 'shooting' : 'in');
if (p.side) status += ' • side';
tr.innerHTML = '<td class="name">' + escapeHtml(p.name) + '</td>' +
'<td><div class="chips">' + chips.join('') + '</div></td>' +
'<td class="num">' + p.totalShots + '</td>' +
'<td class="num">' + p.turns + '</td>' +
'<td class="status ' + (p.eliminated?'out':'') + '">' + status + '</td>';
tbody.appendChild(tr);
});
const turnContent = document.getElementById('turnContent');
if (state.gameOver) {
let html = '<div class="turn-name">Game over</div>';
if (state.payouts && state.payouts.length) {
html += '<div class="payout">';
state.payouts.forEach(p=>{
html += '<div class="payout-row"><span>' + escapeHtml(p.name) + ' <span class="status">' + escapeHtml(p.reason) + '</span></span><b>' + money(p.amount) + '</b></div>';
});
html += '</div>';
} else {
html += '<div class="status">No payout.</div>';
}
html += '<div class="small-actions"><button id="newNightBtn2" class="primary">New night</button><button id="undoBtn2" class="ghost">Undo</button></div>';
turnContent.innerHTML = html;
document.getElementById('newNightBtn2').onclick = newNight;
document.getElementById('undoBtn2').onclick = undo;
} else {
const p = currentPlayer();
if (!p) { turnContent.innerHTML = '<div class="status">No current player.</div>'; return; }
const timerClass = timerLeft <= 10 ? ' danger' : timerLeft <= 30 ? ' warn' : '';
turnContent.innerHTML = '' +
'<div class="turn-name">' + escapeHtml(p.name) + '</div>' +
'<div class="turn-stats">' +
'<div class="stat"><div class="k">Shots</div><div class="v">' + state.current.shots + '</div></div>' +
'<div class="stat"><div class="k">Balls down</div><div class="v">' + state.current.balls + '/3</div></div>' +
'<div class="stat"><div class="k">Chips</div><div class="v">' + p.chips + '</div></div>' +
'</div>' +
'<div style="display:flex;align-items:center;gap:12px;flex-wrap:wrap;">' +
'<div id="timerEl" class="timer' + timerClass + '">' + timerLeft + 's</div>' +
'<button id="timerBtn" class="ghost">' + (timerRunning ? 'Pause clock' : 'Start clock') + '</button>' +
'</div>' +
'<div class="actions">' +
'<button id="missBtn" class="bad">Miss +1</button>' +
'<button id="makeBtn" class="good">Ball down +1</button>' +
'<button id="clockBtn" class="wide">Clock out — chip</button>' +
'<button id="endBtn" class="wide ghost">End early — chip</button>' +
'</div>' +
'<div class="small-actions">' +
'<button id="undoBtn3" class="ghost">Undo last</button>' +
'<button id="newNightBtn3" class="ghost">New night</button>' +
'</div>';
document.getElementById('missBtn').onclick = ()=>recordShot(false);
document.getElementById('makeBtn').onclick = ()=>recordShot(true);
document.getElementById('clockBtn').onclick = clockOut;
document.getElementById('endBtn').onclick = endEarly;
document.getElementById('undoBtn3').onclick = undo;
document.getElementById('newNightBtn3').onclick = newNight;
document.getElementById('timerBtn').onclick = ()=>{ if (timerRunning) pauseTimer(); else startTimer(); };
}
const logEl = document.getElementById('log');
logEl.innerHTML = state.log.slice(-12).reverse().map(x=>'<div>' + escapeHtml(x) + '</div>').join('');
}
function escapeHtml(s){
return String(s).replace(/[&<>"']/g, c=>({'&':'&','<':'<','>':'>','"':'"',"'":'''}[c]));
}
function makeDemo(){
const s = newState(DEFAULT_NAMES, true);
s.round = 3;
s.sideAwarded = true;
s.sideWinnerId = s.players[2].id;
s.players[2].side = true;
const chips = [5,4,5,3,5,4,2,5,5,4,5,5];
const shots = [11,14,9,16,12,13,18,10,12,15,11,13];
const turns = [2,2,2,2,2,2,2,2,2,2,2,2];
s.players.forEach((p,i)=>{ p.chips = chips[i]; p.totalShots = shots[i]; p.turns = turns[i]; });
s.current = { playerId: s.players[4].id, shots: 1, balls: 1 };
s.log = [
'Early Bird side prize: Sofia ($10) for 9 shots through round 2.',
'Round 3: Tasha steps up.',
'Tasha makes ball 1 (shot 1).'
];
return s;
}
state = makeDemo();
document.getElementById('newNightBtn').onclick = newNight;
document.getElementById('addPlayerBtn').onclick = addPlayer;
document.getElementById('undoBtn').onclick = undo;
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 3-Ball — Playback</title>
<style>
:root { --bg:#0b0e13; --panel:#151a22; --panel2:#1d2430; --line:#2a3444; --text:#f4f7fb; --muted:#8b98ab; --accent:#ffb020; --good:#3ddc97; --bad:#ff5c5c; --blue:#5aa9ff; }
* { box-sizing: border-box; }
html,body { margin:0; padding:0; background:var(--bg); color:var(--text); font-family: system-ui, -apple-system, Segoe UI, Roboto, Arial, sans-serif; }
body { min-height:100vh; }
button { font: inherit; color: inherit; background: var(--panel2); border: 1px solid var(--line); border-radius: 14px; padding: 10px 14px; cursor: pointer; touch-action: manipulation; }
button:active { transform: translateY(1px); }
button.primary { background: var(--accent); color:#111; border-color: var(--accent); font-weight: 800; }
button.ghost { background: transparent; }
button:disabled { opacity: .35; cursor: not-allowed; }
.wrap { max-width: 1400px; margin: 0 auto; padding: 14px; }
.top { display:flex; flex-wrap:wrap; gap:10px; align-items:center; justify-content:space-between; margin-bottom:12px; }
.brand { font-size: clamp(20px, 2.4vw, 34px); font-weight: 900; letter-spacing: .5px; }
.brand span { color: var(--accent); }
.brand .muted { color: var(--muted); font-size: .55em; letter-spacing: 2px; }
.badges { display:flex; flex-wrap:wrap; gap:8px; }
.badge { background: var(--panel); border:1px solid var(--line); border-radius:999px; padding:8px 12px; font-size: clamp(12px, 1.2vw, 16px); color: var(--muted); }
.badge b { color: var(--text); }
.controls { display:flex; flex-wrap:wrap; gap:10px; align-items:center; margin-bottom:14px; }
.speed { display:flex; align-items:center; gap:8px; margin-left:auto; color: var(--muted); font-size: 14px; }
.speed input { width: 180px; accent-color: var(--accent); }
.layout { display:grid; grid-template-columns: minmax(0, 1.4fr) minmax(340px, .9fr); gap:14px; align-items:start; }
@media (max-width: 980px) { .layout { grid-template-columns: 1fr; } }
.panel { background: var(--panel); border:1px solid var(--line); border-radius: 20px; padding: 14px; }
.panel h2 { margin:0 0 10px; font-size: clamp(14px, 1.4vw, 20px); color: var(--muted); text-transform: uppercase; letter-spacing: 1.5px; }
.score-table { width:100%; border-collapse: collapse; }
.score-table th { text-align:left; font-size: clamp(10px, 1vw, 14px); color: var(--muted); text-transform: uppercase; letter-spacing: 1px; padding: 6px 8px; border-bottom:1px solid var(--line); }
.score-table td { padding: 8px; border-bottom:1px solid var(--line); font-size: clamp(14px, 1.6vw, 24px); }
.score-table tr.current td { background: rgba(255,176,32,.12); }
.score-table tr.out td { opacity:.38; }
.score-table tr.side td:first-child { box-shadow: inset 4px 0 0 var(--blue); }
.name { font-weight: 800; }
.chips { display:flex; gap:4px; align-items:center; }
.chip { width: clamp(10px, 1.2vw, 16px); height: clamp(10px, 1.2vw, 16px); border-radius:50%; background: var(--accent); border:1px solid #ffd77a; }
.chip.empty { background: transparent; border-color: var(--line); }
.num { font-variant-numeric: tabular-nums; }
.status { font-size: clamp(10px, 1vw, 14px); color: var(--muted); }
.status.out { color: var(--bad); }
.big { font-size: clamp(28px, 4vw, 56px); font-weight: 900; line-height:1; margin: 4px 0 8px; }
.turn-stats { display:flex; gap:10px; flex-wrap:wrap; margin: 10px 0; }
.stat { background: var(--panel2); border:1px solid var(--line); border-radius:14px; padding:10px 14px; min-width: 90px; }
.stat .k { font-size: 11px; color: var(--muted); text-transform: uppercase; letter-spacing:1px; }
.stat .v { font-size: clamp(22px, 2.6vw, 38px); font-weight: 900; font-variant-numeric: tabular-nums; }
.event-desc { margin-top:12px; padding:12px; border-radius:14px; background: var(--panel2); border:1px solid var(--line); font-size: clamp(15px, 1.6vw, 22px); }
.log { max-height: 260px; overflow:auto; font-size: 13px; color: var(--muted); }
.log div { padding: 5px 0; border-bottom:1px solid rgba(255,255,255,.04); }
.payout { margin-top:12px; }
.payout-row { display:flex; justify-content:space-between; gap:10px; padding:10px 0; border-bottom:1px solid var(--line); font-size: clamp(16px, 1.8vw, 24px); }
.payout-row b { color: var(--accent); }
</style>
</head>
<body>
<div class="wrap">
<div class="top">
<div class="brand">THURSDAY <span>3-BALL</span> <span class="muted">PLAYBACK</span></div>
<div class="badges">
<div class="badge">Step <b id="stepBadge">0</b> / <b id="totalBadge">0</b></div>
<div class="badge">Round <b id="roundBadge">1</b></div>
<div class="badge">Pot <b id="potBadge">$240</b></div>
<div class="badge">Left <b id="leftBadge">12</b></div>
</div>
</div>
<div class="controls panel">
<button id="playBtn" class="primary">Play</button>
<button id="backBtn">◀ Step</button>
<button id="fwdBtn">Step ▶</button>
<button id="resetBtn" class="ghost">Reset</button>
<label class="speed">Speed <input id="speed" type="range" min="0.25" max="8" step="0.25" value="1"> <b id="speedLabel">1x</b></label>
</div>
<div class="layout">
<div class="panel">
<h2>Scorecard</h2>
<div style="overflow:auto;">
<table class="score-table">
<thead><tr><th>Player</th><th>Chips</th><th>Shots</th><th>Turns</th><th>Status</th></tr></thead>
<tbody id="scoreBody"></tbody>
</table>
</div>
</div>
<div class="panel">
<h2>Now</h2>
<div id="nowContent"></div>
<h2 style="margin-top:16px;">Event log</h2>
<div id="log" class="log"></div>
</div>
</div>
</div>
<script>
(function(){
'use strict';
const START_CHIPS = 5;
const MAX_FREE_SHOTS = 4;
const ENTRY_FEE = 20;
const SIDE_PRIZE = 10;
const SECOND_SHARE = 0.20;
const DEFAULT_NAMES = ['Maya','Dre','Sofia','Ben','Tasha','Owen','Luis','Priya','Cal','Nina','Gus','Elle'];
const MAX_ROUNDS = 20;
const MAKE_PROB = 0.62;
const CLOCK_PROB = 0.05;
let seed = 20240516;
function rng(){
seed = (seed * 1664525 + 1013904223) >>> 0;
return seed / 4294967296;
}
function clone(o){ return JSON.parse(JSON.stringify(o)); }
function uid(i){ return 'p' + i; }
function money(n){ return '$' + n; }
function escapeHtml(s){ return String(s).replace(/[&<>"']/g, c=>({'&':'&','<':'<','>':'>','"':'"',"'":'''}[c])); }
function makePlayer(name, i){
return { id: uid(i), name: name, chips: START_CHIPS, totalShots: 0, turns: 0, eliminated: false, eliminatedRound: null, eliminatedOrder: null, side: false, order: i };
}
function activePlayers(s){ return s.players.filter(p=>!p.eliminated); }
function playerById(s,id){ return s.players.find(p=>p.id===id); }
function pot(s){ return s.players.length * ENTRY_FEE; }
function log(s,msg){
s.log.push(msg);
if (s.log.length > 200) s.log.shift();
}
function awardSidePrize(s){
if (s.sideAwarded) return;
s.sideAwarded = true;
const eligible = s.players.filter(p=>p.turns > 0);
if (!eligible.length) return;
eligible.sort((a,b)=>{
if (a.totalShots !== b.totalShots) return a.totalShots - b.totalShots;
const aLost = START_CHIPS - a.chips;
const bLost = START_CHIPS - b.chips;
if (aLost !== bLost) return aLost - bLost;
return a.order - b.order;
});
const w = eligible[0];
s.sideWinnerId = w.id;
w.side = true;
log(s, 'Early Bird side prize: ' + w.name + ' ($' + SIDE_PRIZE + ') for ' + w.totalShots + ' shots through round 2.');
}
function finishGame(s, reason){
if (s.gameOver) return;
s.gameOver = true;
const active = activePlayers(s);
let winner = null, second = null;
if (active.length === 1) {
winner = active[0];
if (s.eliminatedOrder.length) second = playerById(s, s.eliminatedOrder[s.eliminatedOrder.length - 1]);
} else if (active.length > 1) {
const ranked = active.slice().sort((a,b)=>{
if (b.chips !== a.chips) return b.chips - a.chips;
if (a.totalShots !== b.totalShots) return a.totalShots - b.totalShots;
return a.order - b.order;
});
winner = ranked[0];
second = ranked[1] || null;
} else {
if (s.eliminatedOrder.length) {
winner = playerById(s, s.eliminatedOrder[s.eliminatedOrder.length - 1]);
second = s.eliminatedOrder.length > 1 ? playerById(s, s.eliminatedOrder[s.eliminatedOrder.length - 2]) : null;
}
}
if (!winner) { s.payouts = []; log(s, 'Game over.'); return; }
const totalPot = pot(s);
let sideAmount = 0;
const sidePlayer = s.sideWinnerId ? playerById(s, s.sideWinnerId) : null;
if (sidePlayer) sideAmount = SIDE_PRIZE;
const remaining = Math.max(0, totalPot - sideAmount);
let secondAmount = 0;
if (second && second.id !== winner.id) secondAmount = Math.round(remaining * SECOND_SHARE);
let winnerAmount = remaining - secondAmount;
const payouts = [];
if (sidePlayer) payouts.push({ id: sidePlayer.id, name: sidePlayer.name, amount: sideAmount, reason: 'Early Bird side prize' });
payouts.push({ id: winner.id, name: winner.name, amount: winnerAmount, reason: 'Winner' });
if (second && second.id !== winner.id) payouts.push({ id: second.id, name: second.name, amount: secondAmount, reason: 'Second' });
const combined = [];
payouts.forEach(p=>{
const found = combined.find(c=>c.id===p.id);
if (found) { found.amount += p.amount; found.reason += ' + ' + p.reason; }
else combined.push({...p});
});
s.payouts = combined;
log(s, 'Game over (' + reason + '). Winner: ' + winner.name + '.');
}
function simulate(){
seed = 20240516;
const players = DEFAULT_NAMES.map((n,i)=>makePlayer(n,i));
const s = {
players: players,
round: 1,
current: { playerId: players[0].id, shots: 0, balls: 0 },
gameOver: false,
sideWinnerId: null,
sideAwarded: false,
eliminatedOrder: [],
payouts: null,
log: []
};
const frames = [];
function push(desc, type){
frames.push({ desc: desc, type: type || 'event', state: clone(s) });
}
push('Night begins. ' + players.length + ' players, ' + money(pot(s)) + ' pot.', 'start');
log(s, 'Night begins. ' + players.length + ' players, ' + money(pot(s)) + ' pot.');
for (let round=1; round<=MAX_ROUNDS && !s.gameOver; round++) {
s.round = round;
log(s, 'Round ' + round + ' begins.');
push('Round ' + round + ' begins.', 'round');
for (let i=0; i<s.players.length && !s.gameOver; i++) {
const p = s.players[i];
if (p.eliminated) continue;
s.current = { playerId: p.id, shots: 0, balls: 0 };
log(s, 'Round ' + round + ': ' + p.name + ' steps up.');
push('Round ' + round + ': ' + p.name + ' steps up.', 'turn');
let clockAt = null;
if (rng() < CLOCK_PROB) clockAt = 1 + Math.floor(rng() * 3);
let reason = null;
let guard = 0;
while (guard++ < 30) {
if (clockAt !== null && s.current.shots >= clockAt && s.current.balls < 3) {
reason = 'clock';
break;
}
const made = rng() < MAKE_PROB;
s.current.shots++;
if (made) s.current.balls++;
const shotNo = s.current.shots;
let desc;
if (made) desc = p.name + ' makes ball ' + s.current.balls + ' (shot ' + shotNo + ').';
else desc = p.name + ' misses (shot ' + shotNo + ').';
log(s, desc);
push(desc, 'shot');
if (s.current.balls >= 3) { reason = 'cleared'; break; }
}
if (!reason) reason = 'cleared';
const shots = s.current.shots;
const balls = s.current.balls;
const cleared = balls >= 3;
let penalty = false;
if (reason === 'clock') penalty = true;
else if (cleared && shots > MAX_FREE_SHOTS) penalty = true;
else if (!cleared) penalty = true;
let eliminatedNow = false;
if (penalty) {
p.chips--;
if (p.chips <= 0) {
p.chips = 0;
p.eliminated = true;
p.eliminatedRound = round;
p.eliminatedOrder = s.eliminatedOrder.length;
s.eliminatedOrder.push(p.id);
eliminatedNow = true;
}
}
p.totalShots += shots;
p.turns++;
let msg = p.name + ' ';
if (reason === 'clock') msg += 'clock runs out after ' + shots + ' shot' + (shots===1?'':'s') + '.';
else if (cleared) msg += 'clears in ' + shots + ' shot' + (shots===1?'':'s') + '.';
else msg += 'ends early after ' + shots + ' shot' + (shots===1?'':'s') + '.';
if (penalty) msg += ' Loses a chip.';
else msg += ' No chip lost.';
log(s, msg);
push(msg, 'turnend');
if (eliminatedNow) {
const em = p.name + ' is out of chips.';
log(s, em);
push(em, 'elim');
}
if (activePlayers(s).length <= 1) {
finishGame(s, 'last player with chips');
push('Game over. ' + (s.payouts && s.payouts.length ? s.payouts.map(x=>x.name + ' ' + money(x.amount)).join(', ') : ''), 'gameover');
break;
}
}
if (s.gameOver) break;
if (round === 2 && !s.sideAwarded) {
awardSidePrize(s);
const w = s.sideWinnerId ? playerById(s, s.sideWinnerId) : null;
if (w) push('Early Bird side prize: ' + w.name + ' ($' + SIDE_PRIZE + ') for ' + w.totalShots + ' shots through round 2.', 'side');
}
}
if (!s.gameOver) {
finishGame(s, 'time limit');
push('Time limit. ' + (s.payouts && s.payouts.length ? s.payouts.map(x=>x.name + ' ' + money(x.amount)).join(', ') : ''), 'gameover');
}
return frames;
}
let frames = simulate();
let idx = 0;
let playing = false;
let speed = 1;
let playTimer = null;
function currentFrame(){ return frames[idx]; }
function render(){
const f = currentFrame();
const s = f.state;
document.getElementById('stepBadge').textContent = idx;
document.getElementById('totalBadge').textContent = frames.length - 1;
document.getElementById('roundBadge').textContent = s.round;
document.getElementById('potBadge').textContent = money(pot(s));
document.getElementById('leftBadge').textContent = activePlayers(s).length;
const tbody = document.getElementById('scoreBody');
tbody.innerHTML = '';
s.players.forEach(p=>{
const tr = document.createElement('tr');
if (p.id === s.current.playerId && !s.gameOver) tr.className += ' current';
if (p.eliminated) tr.className += ' out';
if (p.side) tr.className += ' side';
const chips = [];
for (let i=0;i<START_CHIPS;i++) chips.push('<span class="chip' + (i < p.chips ? '' : ' empty') + '"></span>');
let status = p.eliminated ? 'OUT r' + p.eliminatedRound : (p.id === s.current.playerId && !s.gameOver ? 'shooting' : 'in');
if (p.side) status += ' • side';
tr.innerHTML = '<td class="name">' + escapeHtml(p.name) + '</td>' +
'<td><div class="chips">' + chips.join('') + '</div></td>' +
'<td class="num">' + p.totalShots + '</td>' +
'<td class="num">' + p.turns + '</td>' +
'<td class="status ' + (p.eliminated?'out':'') + '">' + status + '</td>';
tbody.appendChild(tr);
});
const now = document.getElementById('nowContent');
if (s.gameOver) {
let html = '<div class="big">Game over</div>';
if (s.payouts && s.payouts.length) {
html += '<div class="payout">';
s.payouts.forEach(p=>{
html += '<div class="payout-row"><span>' + escapeHtml(p.name) + ' <span class="status">' + escapeHtml(p.reason) + '</span></span><b>' + money(p.amount) + '</b></div>';
});
html += '</div>';
}
now.innerHTML = html;
} else {
const p = playerById(s, s.current.playerId);
now.innerHTML = '' +
'<div class="big">' + escapeHtml(p ? p.name : '—') + '</div>' +
'<div class="turn-stats">' +
'<div class="stat"><div class="k">Shots</div><div class="v">' + s.current.shots + '</div></div>' +
'<div class="stat"><div class="k">Balls down</div><div class="v">' + s.current.balls + '/3</div></div>' +
'<div class="stat"><div class="k">Chips</div><div class="v">' + (p ? p.chips : '—') + '</div></div>' +
'</div>' +
'<div class="event-desc">' + escapeHtml(f.desc) + '</div>';
}
const logEl = document.getElementById('log');
const start = Math.max(0, idx - 14);
const lines = [];
for (let i=start; i<=idx; i++) lines.push(frames[i].desc);
logEl.innerHTML = lines.reverse().map(x=>'<div>' + escapeHtml(x) + '</div>').join('');
document.getElementById('playBtn').textContent = playing ? 'Pause' : (idx >= frames.length-1 ? 'Replay' : 'Play');
document.getElementById('backBtn').disabled = idx <= 0;
document.getElementById('fwdBtn').disabled = idx >= frames.length-1;
}
function stepForward(){
if (idx < frames.length - 1) { idx++; render(); }
else { pause(); }
}
function stepBack(){
if (idx > 0) { idx--; render(); }
}
function play(){
if (idx >= frames.length - 1) idx = 0;
playing = true;
render();
schedule();
}
function pause(){
playing = false;
if (playTimer) { clearTimeout(playTimer); playTimer = null; }
render();
}
function schedule(){
if (playTimer) { clearTimeout(playTimer); playTimer = null; }
if (!playing) return;
const delay = Math.max(40, 800 / speed);
playTimer = setTimeout(()=>{
if (!playing) return;
if (idx < frames.length - 1) {
idx++;
render();
schedule();
} else {
playing = false;
render();
}
}, delay);
}
document.getElementById('playBtn').onclick = ()=>{ if (playing) pause(); else play(); };
document.getElementById('backBtn').onclick = ()=>{ pause(); stepBack(); };
document.getElementById('fwdBtn').onclick = ()=>{ pause(); stepForward(); };
document.getElementById('resetBtn').onclick = ()=>{ pause(); idx = 0; render(); };
document.getElementById('speed').oninput = (e)=>{
speed = parseFloat(e.target.value);
document.getElementById('speedLabel').textContent = speed + 'x';
if (playing) schedule();
};
render();
})();
</script>
</body>
</html>