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:#0b100e;--card:#141c18;--line:#304037;--text:#f4f7ef;--muted:#aab8ae;--green:#8ce99a;--gold:#ffd166;--red:#ff8787;--blue:#8ecaff}
*{box-sizing:border-box}
[hidden]{display:none!important}
body{margin:0;background:var(--bg);color:var(--text);font:16px/1.4 system-ui,-apple-system,BlinkMacSystemFont,"Segoe UI",sans-serif}
button,input,textarea{font:inherit}
button{min-height:46px;border:1px solid #486052;border-radius:10px;background:#202c25;color:var(--text);padding:10px 14px;font-weight:750;cursor:pointer;touch-action:manipulation}
button:hover{background:#2a3930}
button:focus-visible,input:focus-visible,textarea:focus-visible{outline:3px solid var(--gold);outline-offset:2px}
button:disabled{opacity:.4;cursor:default}
.top{display:flex;align-items:center;justify-content:space-between;gap:12px;padding:12px max(12px,env(safe-area-inset-right)) 12px max(12px,env(safe-area-inset-left));border-bottom:1px solid var(--line);background:#101713;position:sticky;top:0;z-index:5}
.brand h1{font-size:19px;margin:0;letter-spacing:.02em}
.brand p{margin:2px 0 0;color:var(--muted);font-size:13px}
.topactions{display:flex;gap:7px;flex-wrap:wrap;justify-content:flex-end}
.topactions button{min-height:42px;padding:8px 11px;font-size:14px}
.wrap{max-width:1100px;margin:auto;padding:14px 12px 42px}
.statusbar{display:flex;gap:8px;align-items:center;flex-wrap:wrap;margin-bottom:12px}
.pill{display:inline-block;border:1px solid var(--line);border-radius:999px;padding:5px 10px;color:var(--muted);font-size:14px;background:#111813}
.pill.demo{color:#171b12;background:var(--gold);border-color:var(--gold);font-weight:850}
.layout{display:grid;grid-template-columns:minmax(0,1.08fr) minmax(0,.92fr);gap:14px;align-items:start}
.card{background:var(--card);border:1px solid var(--line);border-radius:15px;padding:15px;margin-bottom:14px;min-width:0}
.eyebrow{text-transform:uppercase;letter-spacing:.1em;font-size:12px;font-weight:850;color:var(--muted);margin:0 0 5px}
.nowname{font-size:clamp(34px,7vw,58px);line-height:1.03;margin:4px 0 8px;overflow-wrap:anywhere}
.nowmeta{color:var(--muted);margin:0 0 14px;font-size:17px}
.clockbox{display:grid;grid-template-columns:1fr auto;gap:10px;align-items:center;padding:11px 12px;background:#0c130f;border-radius:12px;margin-bottom:12px}
.clock{font-size:42px;line-height:1;font-weight:850;font-variant-numeric:tabular-nums}
.clock.expired{color:var(--red)}
.clocknote{font-size:12px;color:var(--muted);margin-top:5px}
.clockbuttons{display:flex;gap:6px}
.clockbuttons button{padding:7px 10px;min-height:40px;font-size:13px}
.shotlabel{display:flex;align-items:end;justify-content:space-between;gap:8px;margin:12px 0 7px}
.shotlabel strong{font-size:18px}
.stepper{display:grid;grid-template-columns:64px 1fr 64px;gap:8px;align-items:center}
.stepper button{font-size:30px;min-height:60px;padding:0}
.shotnumber{text-align:center;font-size:46px;font-weight:900;font-variant-numeric:tabular-nums}
.quick{display:grid;grid-template-columns:repeat(6,1fr);gap:6px;margin:8px 0 12px}
.quick button{padding:6px 0;min-height:43px}
.quick button.selected{border-color:var(--gold);color:var(--gold);background:#332d18}
.primary{background:var(--green);color:#102015;border-color:var(--green);font-size:20px;width:100%;min-height:62px}
.primary:hover{background:#a5f3b0}
.danger{color:#ffd4d4;border-color:#8c4c4c;background:#3a2020}
.secondaryrow{display:grid;grid-template-columns:1fr 1fr;gap:8px;margin-top:9px}
.costnote{text-align:center;color:var(--muted);min-height:21px;margin:7px 0 0;font-size:14px}
.sectionhead{display:flex;justify-content:space-between;align-items:center;gap:10px}
.sectionhead h2{font-size:19px;margin:0}
.sectionhead button{min-height:38px;padding:5px 10px;font-size:13px}
.tablewrap{overflow:auto;margin:10px -4px 0}
table{border-collapse:collapse;width:100%;font-variant-numeric:tabular-nums}
th,td{text-align:left;padding:9px 7px;border-bottom:1px solid #29372f;white-space:nowrap}
th{font-size:11px;color:var(--muted);text-transform:uppercase;letter-spacing:.05em}
td{font-size:14px}
tr.current{background:#203c29;box-shadow:inset 3px 0 var(--green)}
tr.out{opacity:.48}
.chips{font-weight:850;color:var(--gold)}
.loss{color:var(--red);font-weight:800}
.hot{margin:11px 0 0;padding:9px 10px;border-radius:9px;background:#272715;color:#fff0b0;font-size:14px}
.log{list-style:none;margin:10px 0 0;padding:0}
.log li{border-top:1px solid #2b3931;padding:9px 0;font-size:14px;color:#d7dfd8}
.log li:first-child{border-top:0}
.log b{color:white}
.status{min-height:24px;color:var(--blue);margin:4px 2px 12px}
.settlement h2{font-size:25px;margin:3px 0 8px}
.payrow{display:grid;grid-template-columns:1fr auto;gap:4px 12px;align-items:center;border-top:1px solid #405247;padding:12px 0}
.payrow .payname{font-size:22px;font-weight:850;overflow-wrap:anywhere}
.payrow .payamount{font-size:29px;font-weight:900;color:var(--green);font-variant-numeric:tabular-nums}
.payrow small{grid-column:1/-1;color:var(--muted)}
.reconcile{color:var(--muted);font-size:14px;margin-top:10px}
.modal{position:fixed;inset:0;background:rgba(0,0,0,.82);z-index:20;display:grid;place-items:center;padding:14px;overflow:auto}
.modalbox{background:#151e19;border:1px solid #53675a;border-radius:16px;max-width:620px;width:100%;padding:18px;max-height:94vh;overflow:auto}
.modalbox h2{margin:0 0 6px;font-size:26px}
.modalbox p{color:var(--muted)}
.modalbox textarea{width:100%;resize:vertical;background:#090f0b;color:white;border:1px solid #4b6153;border-radius:10px;padding:12px;line-height:1.45}
.modalactions{display:grid;grid-template-columns:1fr 1fr;gap:8px;margin-top:12px}
.modalactions .wide{grid-column:1/-1}
.error{color:var(--red);min-height:22px;font-weight:700}
.tv-local-tools{position:sticky;top:0;z-index:4;background:#101713;padding:8px;display:flex;gap:8px;align-items:center;border-bottom:1px solid #385043}
.tv-local-tools span{color:var(--muted);font-size:13px}
@media(max-width:760px){
.layout{grid-template-columns:1fr}
.top{align-items:flex-start}
.brand p{display:none}
.topactions{max-width:205px}
.topactions button{font-size:12px;padding:7px 9px}
.card{padding:13px;border-radius:12px}
.wrap{padding-top:10px}
.scorecard-card{margin-bottom:0}
}
@media(max-width:390px){
.brand h1{font-size:16px}
.clock{grid-template-columns:1fr}
.clockbox{grid-template-columns:1fr}
.clockbuttons button{flex:1}
.secondaryrow{grid-template-columns:1fr}
}
</style>
<style id="tvCss">
.tv-root{box-sizing:border-box;min-height:100vh;background:#07100d;color:#f7fff5;padding:18px 22px;font-family:system-ui,-apple-system,BlinkMacSystemFont,"Segoe UI",sans-serif}
.tv-root *{box-sizing:border-box}
.tv-header{display:flex;justify-content:space-between;align-items:center;border-bottom:3px solid #3b5746;padding-bottom:10px;gap:20px}
.tv-kicker{font-size:clamp(12px,1.25vw,20px);font-weight:900;letter-spacing:.1em;color:#ffd166}
.tv-header h1{font-size:clamp(30px,4vw,58px);line-height:1;margin:4px 0 0}
.tv-pot{text-align:right;font-size:clamp(18px,2vw,30px);color:#b8cabe}
.tv-pot b{display:block;color:#ffd166;font-size:clamp(32px,4vw,58px);line-height:1.1}
.tv-now{display:grid;grid-template-columns:1fr auto;align-items:center;gap:20px;padding:12px 0}
.tv-now small{display:block;color:#a8baad;font-weight:800;letter-spacing:.09em;font-size:clamp(11px,1vw,16px)}
.tv-now strong{display:block;font-size:clamp(35px,5.4vw,78px);line-height:1.05;white-space:nowrap;overflow:hidden;text-overflow:ellipsis;max-width:72vw}
.tv-now p{margin:4px 0 0;color:#ffd166;font-size:clamp(16px,1.8vw,27px)}
.tv-clock{text-align:right;font-weight:900;font-variant-numeric:tabular-nums;font-size:clamp(40px,6vw,86px);line-height:1}
.tv-clock small{margin-top:6px}
.tv-clock.expired{color:#ff8787}
.tv-board{display:grid;grid-template-columns:repeat(2,minmax(0,1fr));gap:6px 16px}
.tv-row{display:grid;grid-template-columns:minmax(0,1fr) auto auto;gap:12px;align-items:center;padding:7px 11px;border:1px solid #263b30;border-radius:8px;background:#101c16;min-height:46px}
.tv-row.current{border:3px solid #8ce99a;background:#1b3b26;padding:5px 9px}
.tv-row.out{opacity:.42}
.tv-name{font-weight:850;font-size:clamp(17px,2vw,31px);white-space:nowrap;overflow:hidden;text-overflow:ellipsis}
.tv-last{color:#b7c8bb;font-size:clamp(12px,1.25vw,19px);white-space:nowrap}
.tv-chips{color:#ffd166;font-weight:900;font-size:clamp(15px,1.55vw,24px);white-space:nowrap;font-variant-numeric:tabular-nums}
.tv-footer{display:flex;justify-content:space-between;gap:18px;align-items:center;margin-top:11px;padding-top:9px;border-top:2px solid #31483a;color:#d5e3d6;font-size:clamp(12px,1.35vw,21px)}
.tv-footer b{color:#ffd166}
.tv-pays{display:flex;gap:18px;flex-wrap:wrap}
.tv-pays span{white-space:nowrap}
@media(max-width:700px){.tv-root{padding:12px}.tv-board{grid-template-columns:1fr}.tv-last{display:none}.tv-now strong{max-width:62vw}.tv-footer{align-items:flex-start;flex-direction:column}}
</style>
</head>
<body>
<div id="app">
<header class="top">
<div class="brand"><h1>Thursday Three-Ball</h1><p>Operator console · money stays with you</p></div>
<div class="topactions">
<button id="tvBtn" type="button">TV here</button>
<button id="popBtn" type="button">Pop-out TV</button>
<button id="newBtn" type="button">New night</button>
</div>
</header>
<main class="wrap">
<div class="statusbar">
<span id="demoLabel" class="pill demo">DEMO</span>
<span id="roundText" class="pill"></span>
<span id="potText" class="pill"></span>
<span class="pill">5 chips · over 4 = −1</span>
</div>
<div id="status" class="status" role="status" aria-live="polite"></div>
<div class="layout">
<div>
<section class="card">
<p id="nowEyebrow" class="eyebrow">At the table</p>
<h2 id="nowName" class="nowname"></h2>
<p id="nowMeta" class="nowmeta"></p>
<div id="livePanel">
<div class="clockbox">
<div>
<div id="clock" class="clock">1:30</div>
<div id="clockNote" class="clocknote">Zero never takes a chip by itself.</div>
</div>
<div class="clockbuttons">
<button id="clockToggle" type="button">Pause</button>
<button id="clockReset" type="button">Reset</button>
</div>
</div>
<div class="shotlabel"><strong>Shots to clear all three</strong><span class="pill">1–4: free</span></div>
<div class="stepper">
<button id="shotMinus" type="button" aria-label="One fewer shot">−</button>
<div id="shotNumber" class="shotnumber">4</div>
<button id="shotPlus" type="button" aria-label="One more shot">+</button>
</div>
<div class="quick" aria-label="Quick shot count">
<button type="button" data-shots="1">1</button><button type="button" data-shots="2">2</button>
<button type="button" data-shots="3">3</button><button type="button" data-shots="4">4</button>
<button type="button" data-shots="5">5</button><button type="button" data-shots="6">6</button>
</div>
<button id="recordBtn" class="primary" type="button">Record clear · 4 shots</button>
<p id="costNote" class="costnote"></p>
<div class="secondaryrow">
<button id="noShowBtn" class="danger" type="button">Away / no-show · −1 chip</button>
<button id="pauseBetweenBtn" type="button">Pause between turns</button>
</div>
</div>
<div id="settlement" class="settlement" hidden aria-live="polite"></div>
</section>
</div>
<div>
<section class="card scorecard-card">
<div class="sectionhead"><h2>Players</h2><button id="undoBtn" type="button">Undo last</button></div>
<div class="tablewrap">
<table>
<thead><tr><th>Name</th><th>Chips</th><th>Last</th><th>Total</th><th>Status</th></tr></thead>
<tbody id="playerRows"></tbody>
</table>
</div>
<p id="hotText" class="hot"></p>
</section>
<section class="card">
<div class="sectionhead"><h2>What just happened</h2><span class="pill">Latest first</span></div>
<ol id="eventLog" class="log"></ol>
</section>
</div>
</div>
</main>
</div>
<section id="tvLocal" hidden>
<div class="tv-local-tools">
<button id="exitTvBtn" type="button">Exit TV</button>
<span id="tvToolNote">Large scoreboard. Exit to run turns.</span>
</div>
<div id="tvMount"></div>
</section>
<div id="setup" class="modal" hidden role="dialog" aria-modal="true" aria-labelledby="setupTitle">
<div class="modalbox">
<h2 id="setupTitle">Start a real night</h2>
<p>One name per line, in shooting order. $20 each, five chips each. The clock is 90 seconds.</p>
<p><b>Keep this page open.</b> There is deliberately no saved storage; refreshing erases the night.</p>
<textarea id="namesInput" rows="16" aria-label="Player names"></textarea>
<div id="setupError" class="error" role="alert"></div>
<div class="modalactions">
<button id="startNightBtn" class="primary wide" type="button">Replace current game & start real night</button>
<button id="cancelSetupBtn" type="button">Cancel</button>
<button id="reloadDemoBtn" type="button">Reload demo</button>
</div>
</div>
</div>
<script>
/* RULE ENGINE: identical in the console and playback. */
const RULES = Object.freeze({
startingChips: 5,
clearLimit: 4,
entryCents: 2000,
runnerUpCents: 2000,
hotStartCents: 2000,
turnSeconds: 90
});
function createGame(names, meta = {}) {
if (!Array.isArray(names) || names.length < 2) throw new Error("At least two players are required.");
const players = names.map((name, i) => ({
id: "p" + (i + 1),
name: String(name),
chips: RULES.startingChips,
turns: [],
outRound: null
}));
return {
players,
actions: [],
log: [],
queue: players.map(p => p.id),
round: 1,
status: "live",
winnerId: null,
eliminationOrder: [],
demo: Boolean(meta.demo)
};
}
function getPlayer(game, id) {
return game.players.find(p => p.id === id) || null;
}
function currentPlayer(game) {
return game.status === "live" ? getPlayer(game, game.queue[0]) : null;
}
function applyAction(game, input) {
if (game.status !== "live") throw new Error("The night is already finished.");
const type = input && input.type;
if (type !== "clear" && type !== "noShow") throw new Error("Unknown turn action.");
const shots = type === "clear" ? Number(input.shots) : null;
if (type === "clear" && (!Number.isInteger(shots) || shots < 1 || shots > 99)) {
throw new Error("Shots must be a whole number from 1 to 99.");
}
const player = currentPlayer(game);
if (!player) throw new Error("There is no current player.");
const actionRound = game.round;
const chipsBefore = player.chips;
const penalty = type === "noShow" || shots > RULES.clearLimit ? 1 : 0;
const action = type === "clear" ? { type, shots } : { type };
player.chips -= penalty;
player.turns.push({
round: actionRound,
type,
shots,
penalty,
chipsAfter: player.chips
});
game.actions.push(action);
game.queue.shift();
let eliminated = false;
if (player.chips === 0) {
player.outRound = actionRound;
game.eliminationOrder.push(player.id);
eliminated = true;
}
const alive = game.players.filter(p => p.chips > 0);
if (alive.length === 1) {
game.status = "finished";
game.winnerId = alive[0].id;
game.queue = [];
} else if (alive.length === 0) {
throw new Error("Invalid game: no players remain.");
} else if (game.queue.length === 0) {
game.round += 1;
game.queue = game.players.filter(p => p.chips > 0).map(p => p.id);
}
const event = {
n: game.log.length + 1,
round: actionRound,
playerId: player.id,
type,
shots,
penalty,
chipsBefore,
chipsAfter: player.chips,
eliminated,
nextId: game.status === "live" ? game.queue[0] : null,
nextRound: game.round,
winnerId: game.winnerId
};
game.log.push(event);
return event;
}
function rebuildGame(names, actions, meta = {}) {
const game = createGame(names, meta);
actions.forEach(action => applyAction(game, action));
return game;
}
function hotStart(game) {
const entries = [];
game.players.forEach(player => {
const turn = player.turns.find(t => t.round === 1 && t.type === "clear");
if (turn) entries.push({ id: player.id, shots: turn.shots });
});
if (!entries.length) return null;
const best = Math.min(...entries.map(e => e.shots));
return {
shots: best,
ids: entries.filter(e => e.shots === best).map(e => e.id)
};
}
function splitCents(total, count) {
if (!count) return [];
const base = Math.floor(total / count);
const remainder = total - base * count;
return Array.from({ length: count }, (_, i) => base + (i < remainder ? 1 : 0));
}
function getSettlement(game) {
if (game.status !== "finished") return null;
const potCents = game.players.length * RULES.entryCents;
let remaining = potCents;
const rowsById = new Map();
function pay(id, amount, reason) {
if (!id || amount <= 0) return;
if (!rowsById.has(id)) {
rowsById.set(id, { id, amountCents: 0, reasons: [] });
}
const row = rowsById.get(id);
row.amountCents += amount;
row.reasons.push(reason);
}
const runnerUpId = game.eliminationOrder.length
? game.eliminationOrder[game.eliminationOrder.length - 1]
: null;
const runnerUpTotal = runnerUpId ? Math.min(RULES.runnerUpCents, remaining) : 0;
pay(runnerUpId, runnerUpTotal, "Second place");
remaining -= runnerUpTotal;
const hot = hotStart(game);
const hotTotal = hot ? Math.min(RULES.hotStartCents, remaining) : 0;
if (hot) {
const shares = splitCents(hotTotal, hot.ids.length);
hot.ids.forEach((id, i) => pay(id, shares[i], "Hot Start · R1 best " + hot.shots));
}
remaining -= hotTotal;
const winnerTotal = remaining;
pay(game.winnerId, winnerTotal, "Winner");
const rows = Array.from(rowsById.values())
.filter(row => row.amountCents > 0)
.sort((a, b) => b.amountCents - a.amountCents ||
getPlayer(game, a.id).name.localeCompare(getPlayer(game, b.id).name));
return {
potCents,
runnerUpId,
runnerUpTotal,
hot,
hotTotal,
winnerId: game.winnerId,
winnerTotal,
rows,
totalCents: rows.reduce((sum, row) => sum + row.amountCents, 0)
};
}
/* END SHARED RULE ENGINE */
const $ = id => document.getElementById(id);
const DEMO_NAMES = ["Avery","Bea","Cal","Dee","Eli","Flo","Gus","Hana","Ike","Jules","Kit","Lou"];
function esc(value) {
return String(value).replace(/[&<>"']/g, ch => ({
"&":"&","<":"<",">":">",'"':""","'":"'"
})[ch]);
}
function money(cents) {
return "$" + (cents / 100).toFixed(cents % 100 ? 2 : 0);
}
function totalShots(player) {
return player.turns.reduce((sum, turn) => sum + (turn.shots || 0), 0);
}
function chipDots(chips) {
return "●".repeat(chips) + "○".repeat(RULES.startingChips - chips);
}
function makeDemo() {
const game = createGame(DEMO_NAMES, { demo: true });
const script = [
3,6,2,4,5,3,7,4,5,2,3,"N",
4,6,3,5,4,2,"N",3,6,4,5,4,
5,4,"N",3,6,2,5,4,4,3,6,5,
4,7,3,5,4,2
];
script.forEach(value => applyAction(game,
value === "N" ? { type: "noShow" } : { type: "clear", shots: value }
));
return game;
}
function describeEvent(event) {
const player = getPlayer(game, event.playerId);
let text = "R" + event.round + " · " + player.name;
if (event.type === "clear") {
text += " cleared in " + event.shots + " shot" + (event.shots === 1 ? "" : "s");
text += event.penalty ? " — over four, −1 chip" : " — no chip loss";
} else {
text += " was away at the clock — −1 chip, turn skipped";
}
if (event.penalty) text += " (" + event.chipsBefore + "→" + event.chipsAfter + ")";
if (event.eliminated) text += ". " + player.name + " is out";
if (event.winnerId) text += ". " + getPlayer(game, event.winnerId).name + " wins";
return text + ".";
}
let game = makeDemo();
let shotCount = 4;
let tvWin = null;
let clockLeft = RULES.turnSeconds * 1000;
let clockEnd = 0;
let clockRunning = false;
let lastClockSecond = -1;
let clockWasRunning = false;
function clockSeconds() {
return Math.max(0, Math.ceil(clockLeft / 1000));
}
function clockString() {
const seconds = clockSeconds();
return Math.floor(seconds / 60) + ":" + String(seconds % 60).padStart(2, "0");
}
function resetClock() {
clockLeft = RULES.turnSeconds * 1000;
clockRunning = game.status === "live";
clockEnd = Date.now() + clockLeft;
lastClockSecond = -1;
}
function pauseClock() {
if (clockRunning) clockLeft = Math.max(0, clockEnd - Date.now());
clockRunning = false;
paintClock();
renderTV();
}
function resumeClock() {
if (game.status !== "live") return;
if (clockLeft <= 0) {
resetClock();
} else {
clockEnd = Date.now() + clockLeft;
clockRunning = true;
}
paintClock();
renderTV();
}
function paintClock() {
$("clock").textContent = clockString();
$("clock").classList.toggle("expired", clockLeft <= 0);
if (clockLeft <= 0) {
$("clockNote").textContent = "Check the table. Charge only if the player is away.";
$("clockToggle").textContent = "Restart";
} else if (!clockRunning) {
$("clockNote").textContent = "Paused by operator.";
$("clockToggle").textContent = "Resume";
} else {
$("clockNote").textContent = "Zero never takes a chip by itself.";
$("clockToggle").textContent = "Pause";
}
}
function tick() {
if (clockRunning) {
clockLeft = Math.max(0, clockEnd - Date.now());
if (clockLeft <= 0) clockRunning = false;
}
const second = clockSeconds();
if (second !== lastClockSecond) {
lastClockSecond = second;
paintClock();
renderTV();
}
}
function setStatus(text) {
$("status").textContent = text;
}
function updateShotUI() {
$("shotNumber").textContent = shotCount;
$("shotMinus").disabled = shotCount <= 1;
$("shotPlus").disabled = shotCount >= 99;
$("recordBtn").textContent = "Record clear · " + shotCount + " shot" + (shotCount === 1 ? "" : "s");
$("costNote").textContent = shotCount > 4
? "Over four: this clear costs one chip."
: "Four or fewer: no chip cost.";
document.querySelectorAll("[data-shots]").forEach(button => {
button.classList.toggle("selected", Number(button.dataset.shots) === shotCount);
});
}
function hotText(game) {
const hot = hotStart(game);
if (!hot) return "Hot Start: waiting for the first round-one clear.";
const names = hot.ids.map(id => getPlayer(game, id).name).join(", ");
return "Hot Start · R1 best " + hot.shots + " · " + names +
(hot.ids.length > 1 ? " · tied, $20 split" : " · $20");
}
function renderPlayers() {
const current = currentPlayer(game);
$("playerRows").innerHTML = game.players.map(player => {
const last = player.turns[player.turns.length - 1];
const lastText = last
? "R" + last.round + " · " + (last.type === "clear" ? last.shots : "NS")
: "—";
const status = game.winnerId === player.id ? "WINNER"
: player.chips === 0 ? "OUT R" + player.outRound
: current && current.id === player.id ? "AT TABLE" : "";
return "<tr class=\"" + (current && current.id === player.id ? "current " : "") +
(player.chips === 0 ? "out" : "") + "\">" +
"<td>" + esc(player.name) + "</td>" +
"<td class=\"chips\">" + player.chips + "/5</td>" +
"<td class=\"" + (last && last.penalty ? "loss" : "") + "\">" + esc(lastText) + "</td>" +
"<td>" + totalShots(player) + "</td><td>" + esc(status) + "</td></tr>";
}).join("");
}
function renderLog() {
const events = game.log.slice(-6).reverse();
$("eventLog").innerHTML = events.length
? events.map(event => "<li><b>#" + event.n + "</b> " + esc(describeEvent(event)) + "</li>").join("")
: "<li>No turns recorded yet.</li>";
}
function renderSettlement() {
const settlement = getSettlement(game);
if (!settlement) {
$("settlement").hidden = true;
return;
}
$("settlement").hidden = false;
$("settlement").innerHTML =
"<p class=\"eyebrow\">Cash settlement</p><h2>Pay these amounts</h2>" +
settlement.rows.map(row => {
const player = getPlayer(game, row.id);
return "<div class=\"payrow\"><span class=\"payname\">" + esc(player.name) + "</span>" +
"<span class=\"payamount\">" + money(row.amountCents) + "</span>" +
"<small>" + esc(row.reasons.join(" + ")) + "</small></div>";
}).join("") +
"<p class=\"reconcile\">Paid " + money(settlement.totalCents) + " · Pot " +
money(settlement.potCents) + " · Difference " +
money(settlement.potCents - settlement.totalCents) + "</p>";
}
function tvMarkup() {
const current = currentPlayer(game);
const settlement = getSettlement(game);
const winner = game.winnerId ? getPlayer(game, game.winnerId) : null;
const pot = game.players.length * RULES.entryCents;
const originalOrder = new Map(game.players.map((p, i) => [p.id, i]));
const ordered = game.players.slice().sort((a, b) => {
if ((a.chips > 0) !== (b.chips > 0)) return a.chips > 0 ? -1 : 1;
if (a.chips !== b.chips) return b.chips - a.chips;
const shotDifference = totalShots(a) - totalShots(b);
return shotDifference || originalOrder.get(a.id) - originalOrder.get(b.id);
});
const rows = ordered.map(player => {
const last = player.turns[player.turns.length - 1];
const lastText = player.chips === 0 ? "OUT R" + player.outRound
: last ? "R" + last.round + " · " + (last.type === "clear" ? last.shots + " shots" : "no-show")
: "no turns";
return "<div class=\"tv-row " +
(current && current.id === player.id ? "current " : "") +
(player.chips === 0 ? "out" : "") + "\">" +
"<span class=\"tv-name\">" + esc(player.name) + "</span>" +
"<span class=\"tv-last\">" + esc(lastText) + "</span>" +
"<span class=\"tv-chips\">" + player.chips + " · " + chipDots(player.chips) + "</span></div>";
}).join("");
const footer = settlement
? "<div class=\"tv-pays\">" + settlement.rows.map(row =>
"<span><b>" + esc(getPlayer(game, row.id).name) + "</b> " + money(row.amountCents) + "</span>"
).join("") + "</div><span>Paid " + money(settlement.totalCents) + "</span>"
: "<span><b>Hot Start</b> " + esc(hotText(game).replace(/^Hot Start · /, "")) +
"</span><span>Over 4 = −1 chip · 0 chips = out</span>";
return "<div class=\"tv-root\">" +
"<header class=\"tv-header\"><div><div class=\"tv-kicker\">THURSDAY THREE-BALL" +
(game.demo ? " · DEMO" : "") + "</div><h1>" +
(game.status === "finished" ? "Final" : "Round " + game.round) + "</h1></div>" +
"<div class=\"tv-pot\">POT<b>" + money(pot) + "</b></div></header>" +
"<section class=\"tv-now\"><div><small>" +
(winner ? "LAST PLAYER WITH CHIPS" : "NOW SHOOTING") + "</small><strong>" +
esc(winner ? winner.name : current ? current.name : "") + "</strong><p>" +
esc(winner ? "Night complete — settle up below" :
current.chips + " chips · " + chipDots(current.chips)) + "</p></div>" +
"<div class=\"tv-clock " + (clockLeft <= 0 && !winner ? "expired" : "") + "\">" +
(winner ? "DONE" : clockString()) + "<small>" +
(winner ? "WINNER" : clockLeft <= 0 ? "CHECK TABLE" : clockRunning ? "TURN CLOCK" : "PAUSED") +
"</small></div></section>" +
"<div class=\"tv-board\">" + rows + "</div>" +
"<footer class=\"tv-footer\">" + footer + "</footer></div>";
}
function renderTV() {
if (!$("tvLocal").hidden) $("tvMount").innerHTML = tvMarkup();
if (tvWin && !tvWin.closed) {
const mount = tvWin.document.getElementById("tvMount");
if (mount) mount.innerHTML = tvMarkup();
}
}
function render() {
const current = currentPlayer(game);
const winner = game.winnerId ? getPlayer(game, game.winnerId) : null;
$("demoLabel").textContent = game.demo ? "DEMO" : "REAL NIGHT";
$("demoLabel").classList.toggle("demo", game.demo);
$("roundText").textContent = game.status === "finished" ? "Night complete" : "Round " + game.round;
$("potText").textContent = "Pot " + money(game.players.length * RULES.entryCents) +
" · " + game.players.length + " players";
$("nowEyebrow").textContent = winner ? "Winner" : "At the table";
$("nowName").textContent = winner ? winner.name : current ? current.name : "Night over";
$("nowMeta").textContent = winner
? "Last player with chips · check the cash settlement below"
: current.chips + " chips · " + game.players.filter(p => p.chips > 0).length + " players still in";
$("livePanel").hidden = game.status !== "live";
$("hotText").textContent = hotText(game);
$("undoBtn").disabled = game.actions.length === 0;
updateShotUI();
renderPlayers();
renderLog();
renderSettlement();
paintClock();
renderTV();
}
function afterAction(event) {
const description = describeEvent(event);
shotCount = 4;
resetClock();
render();
setStatus(description + " Undo last is available.");
}
$("shotMinus").addEventListener("click", () => {
shotCount = Math.max(1, shotCount - 1);
updateShotUI();
});
$("shotPlus").addEventListener("click", () => {
shotCount = Math.min(99, shotCount + 1);
updateShotUI();
});
document.querySelectorAll("[data-shots]").forEach(button => {
button.addEventListener("click", () => {
shotCount = Number(button.dataset.shots);
updateShotUI();
});
});
$("recordBtn").addEventListener("click", () => {
try {
afterAction(applyAction(game, { type: "clear", shots: shotCount }));
} catch (error) {
setStatus(error.message);
}
});
$("noShowBtn").addEventListener("click", () => {
try {
afterAction(applyAction(game, { type: "noShow" }));
} catch (error) {
setStatus(error.message);
}
});
$("undoBtn").addEventListener("click", () => {
if (!game.actions.length) return;
const names = game.players.map(p => p.name);
const wasDemo = game.demo;
try {
game = rebuildGame(names, game.actions.slice(0, -1), { demo: wasDemo });
shotCount = 4;
resetClock();
render();
setStatus("Last turn removed. Subsequent turns, if any, must be re-entered.");
} catch (error) {
setStatus(error.message);
}
});
$("clockToggle").addEventListener("click", () => clockRunning ? pauseClock() : resumeClock());
$("clockReset").addEventListener("click", () => {
resetClock();
paintClock();
renderTV();
});
$("pauseBetweenBtn").addEventListener("click", pauseClock);
$("tvBtn").addEventListener("click", () => {
$("app").hidden = true;
$("tvLocal").hidden = false;
$("tvToolNote").textContent = "Large scoreboard. Exit to run turns.";
renderTV();
});
$("exitTvBtn").addEventListener("click", () => {
$("tvLocal").hidden = true;
$("app").hidden = false;
});
$("popBtn").addEventListener("click", () => {
try {
const popup = window.open("", "thursdayThreeBallTV", "width=1280,height=720");
if (!popup) throw new Error("Popup blocked");
tvWin = popup;
popup.document.open();
popup.document.write("<!doctype html><html><head><meta charset=\"utf-8\">" +
"<meta name=\"viewport\" content=\"width=device-width,initial-scale=1\">" +
"<title>Thursday Three-Ball TV</title><style>" +
$("tvCss").textContent + "</style></head>" +
"<body style=\"margin:0;background:#07100d\"><div id=\"tvMount\"></div></body></html>");
popup.document.close();
renderTV();
setStatus("TV window opened. Keep this console open; the pop-out is synced by this page.");
} catch (error) {
$("app").hidden = true;
$("tvLocal").hidden = false;
$("tvToolNote").textContent = "Pop-out unavailable; using this screen instead.";
renderTV();
}
});
function openSetup() {
clockWasRunning = clockRunning;
pauseClock();
$("namesInput").value = game.players.map(p => p.name).join("\n");
$("setupError").textContent = "";
$("setup").hidden = false;
$("namesInput").focus();
}
$("newBtn").addEventListener("click", openSetup);
$("cancelSetupBtn").addEventListener("click", () => {
$("setup").hidden = true;
if (clockWasRunning) resumeClock();
});
$("reloadDemoBtn").addEventListener("click", () => {
game = makeDemo();
shotCount = 4;
clockWasRunning = false;
$("setup").hidden = true;
resetClock();
render();
setStatus("Fictional demo reloaded in round four.");
});
$("startNightBtn").addEventListener("click", () => {
const names = $("namesInput").value.split(/[\n,]/).map(name => name.trim()).filter(Boolean);
if (names.length < 2) {
$("setupError").textContent = "Enter at least two names.";
return;
}
const folded = names.map(name => name.toLocaleLowerCase());
if (new Set(folded).size !== names.length) {
$("setupError").textContent = "Names must be unique; add an initial or nickname for duplicates.";
return;
}
try {
game = createGame(names, { demo: false });
shotCount = 4;
clockWasRunning = false;
$("setup").hidden = true;
resetClock();
render();
setStatus("Real night started. " + names[0] + " is at the table; the clock is running.");
} catch (error) {
$("setupError").textContent = error.message;
}
});
window.addEventListener("beforeunload", event => {
if (!game.demo && game.actions.length) {
event.preventDefault();
event.returnValue = "";
}
});
resetClock();
render();
setInterval(tick, 500);
</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 — Night Playback</title>
<style>
:root{color-scheme:dark;--bg:#0a100d;--card:#141d18;--line:#304239;--text:#f5f8f2;--muted:#a9b9ae;--green:#8ce99a;--gold:#ffd166;--red:#ff8787;--blue:#8ecaff}
*{box-sizing:border-box}
body{margin:0;background:var(--bg);color:var(--text);font:16px/1.45 system-ui,-apple-system,BlinkMacSystemFont,"Segoe UI",sans-serif}
button,input{font:inherit}
button{min-height:48px;border:1px solid #486052;border-radius:10px;background:#202d25;color:var(--text);padding:10px 15px;font-weight:800;cursor:pointer;touch-action:manipulation}
button:hover{background:#2b3a30}
button:focus-visible,input:focus-visible{outline:3px solid var(--gold);outline-offset:2px}
button:disabled{opacity:.38;cursor:default}
header{padding:20px max(16px,calc((100vw - 1240px)/2));border-bottom:1px solid var(--line);background:#101813;display:flex;justify-content:space-between;gap:18px;align-items:center}
header h1{font-size:clamp(25px,4vw,42px);line-height:1.1;margin:3px 0}
header p{margin:0;color:var(--muted)}
.eyebrow{text-transform:uppercase;letter-spacing:.11em;color:var(--gold);font-size:12px;font-weight:900}
.pot{text-align:right;font-size:28px;font-weight:900;color:var(--gold);white-space:nowrap}
.pot small{display:block;color:var(--muted);font-size:12px;font-weight:600}
main{max-width:1240px;margin:auto;padding:16px}
.card{background:var(--card);border:1px solid var(--line);border-radius:15px;padding:17px;margin-bottom:16px;min-width:0}
.stage{position:relative;overflow:hidden}
.stepmeta{display:flex;justify-content:space-between;gap:10px;flex-wrap:wrap;color:var(--muted);font-size:14px}
.eventtitle{font-size:clamp(29px,5vw,52px);line-height:1.08;margin:16px 0 9px;overflow-wrap:anywhere}
.eventdetail{font-size:clamp(17px,2vw,22px);color:#d1ded4;max-width:950px;min-height:58px;margin:0}
.winnerbanner{margin-top:13px;padding:13px;border-radius:10px;background:#233e29;color:var(--green);font-size:22px;font-weight:900}
.transport{display:grid;grid-template-columns:repeat(5,1fr);gap:8px;margin-top:20px}
.transport .play{background:var(--green);border-color:var(--green);color:#102015;font-size:18px}
.sliderblock{margin-top:17px}
.sliderline{display:flex;justify-content:space-between;gap:10px;align-items:center;margin-bottom:5px}
.sliderline label{font-weight:800}
.sliderline output{color:var(--muted);font-variant-numeric:tabular-nums}
input[type=range]{width:100%;accent-color:var(--green);min-height:30px;cursor:pointer}
.speedline{display:grid;grid-template-columns:1fr minmax(180px,45%);gap:15px;align-items:center}
.currentline{display:flex;gap:10px;align-items:center;flex-wrap:wrap;margin-bottom:12px}
.pill{border:1px solid var(--line);border-radius:999px;padding:5px 10px;background:#101712;color:var(--muted);font-size:14px}
.pill.live{color:#102015;background:var(--green);border-color:var(--green);font-weight:900}
.cardhead{display:flex;justify-content:space-between;gap:12px;align-items:center}
.cardhead h2{margin:0;font-size:22px}
.hint{color:var(--muted);font-size:13px}
.tablewrap{overflow:auto;margin-top:13px;border:1px solid #2b3c32;border-radius:9px;max-height:570px}
table{border-collapse:separate;border-spacing:0;width:max-content;min-width:100%;font-variant-numeric:tabular-nums}
th,td{padding:9px 10px;border-bottom:1px solid #293b31;border-right:1px solid #24352b;text-align:center;white-space:nowrap;min-width:55px;background:#141d18}
th{position:sticky;top:0;z-index:2;background:#202c25;color:#c1cec4;font-size:12px;text-transform:uppercase;letter-spacing:.04em}
th:first-child,td:first-child{position:sticky;left:0;z-index:1;text-align:left;min-width:145px;max-width:210px;overflow:hidden;text-overflow:ellipsis;background:#18231d}
th:first-child{z-index:3;background:#202c25}
th:nth-child(2),td:nth-child(2){min-width:70px}
tr.current td{background:#203d29}
tr.current td:first-child{background:#254832;color:white}
tr.out td{opacity:.48}
td.name{font-weight:850}
td.chips{color:var(--gold);font-weight:900}
td.penalty{color:var(--red);font-weight:900;background:#30201e}
.lossmark{font-size:11px;margin-left:2px}
td.total{color:var(--blue);font-weight:800}
.bottom{display:grid;grid-template-columns:1.15fr .85fr;gap:16px}
.transcript{list-style:none;padding:0;margin:12px 0 0}
.transcript li{padding:10px 0;border-top:1px solid #2b3b32;color:#c8d4ca;font-size:14px}
.transcript li.latest{color:white;border-left:3px solid var(--green);padding-left:9px}
.transcript b{color:var(--gold);margin-right:6px}
.rules{font-size:14px;color:var(--muted);margin:12px 0 0;padding-left:20px}
.rules li{margin:5px 0}
.payrow{display:grid;grid-template-columns:1fr auto;gap:2px 12px;padding:11px 0;border-top:1px solid #35483c}
.payname{font-size:20px;font-weight:850}
.payamount{font-size:25px;font-weight:900;color:var(--green)}
.payrow small{grid-column:1/-1;color:var(--muted)}
.reconcile{color:var(--muted);font-size:14px}
.empty{color:var(--muted)}
footer{max-width:1240px;margin:0 auto;padding:0 16px 30px;color:var(--muted);font-size:13px}
@media(max-width:760px){
header{padding:15px 16px;align-items:flex-start}
.pot{font-size:22px}
main{padding:10px}
.card{padding:13px;border-radius:12px}
.transport{grid-template-columns:repeat(2,1fr)}
.transport .play{grid-column:1/-1;grid-row:1}
.speedline{grid-template-columns:1fr}
.bottom{grid-template-columns:1fr}
.eventdetail{min-height:90px}
}
</style>
</head>
<body>
<header>
<div>
<div class="eyebrow">Rules-locked rehearsal · deterministic fictional night</div>
<h1>A whole Thursday, start to finish</h1>
<p id="nightSummary">Building the night…</p>
</div>
<div class="pot"><small>12 × $20</small>$240</div>
</header>
<main>
<section class="card stage">
<div class="stepmeta">
<span id="stepLabel">Step 0</span>
<span id="roundLabel">Doors open</span>
<span id="timeLabel">9:00 PM</span>
</div>
<h2 id="eventTitle" class="eventtitle"></h2>
<p id="eventDetail" class="eventdetail" aria-live="polite"></p>
<div id="winnerBanner" class="winnerbanner" hidden></div>
<div class="transport">
<button id="restartBtn" type="button" title="Return to the start">↺ Start</button>
<button id="backBtn" type="button">← Back one</button>
<button id="playBtn" class="play" type="button">▶ Play</button>
<button id="nextBtn" type="button">Forward one →</button>
<button id="endBtn" type="button">Jump to end ⇥</button>
</div>
<div class="sliderblock">
<div class="sliderline"><label for="positionSlider">Night position</label><output id="positionOutput"></output></div>
<input id="positionSlider" type="range" min="0" step="1" value="0">
</div>
<div class="sliderblock speedline">
<div class="sliderline"><label for="speedSlider">Playback speed</label><output id="speedOutput"></output></div>
<input id="speedSlider" type="range" min="0.25" max="4" step="0.25" value="1" aria-label="Playback speed, slow to fast">
</div>
</section>
<section class="card">
<div class="currentline">
<span id="livePill" class="pill live">READY</span>
<span id="currentText" class="pill"></span>
<span id="aliveText" class="pill"></span>
<span class="pill">NS = no-show · red = chip lost</span>
</div>
<div class="cardhead"><h2>Scorecard</h2><span class="hint">Scroll sideways as rounds accumulate →</span></div>
<div id="scoreWrap" class="tablewrap">
<table id="scorecard"></table>
</div>
</section>
<div class="bottom">
<section class="card">
<div class="cardhead"><h2>Night transcript</h2><span class="hint">Most recent seven steps</span></div>
<ol id="transcript" class="transcript"></ol>
</section>
<section class="card">
<div class="cardhead"><h2>Money & house rules</h2></div>
<div id="settlement"></div>
<ol class="rules">
<li>Clear in 1–4 shots: keep all chips. Clear in 5+: lose one chip.</li>
<li>Away when the 90-second clock expires: lose one chip and skip the table.</li>
<li>Zero chips means out. The last player with chips wins immediately.</li>
<li>Second place: last player eliminated, $20.</li>
<li>Hot Start: lowest round-one clear, $20; ties split it.</li>
<li>Winner receives the remainder. Combined prizes appear on one payment line.</li>
</ol>
</section>
</div>
</main>
<footer>
This is a rehearsal, not a separate scoring model. Every displayed state is rebuilt by running the same turn and settlement engine used by the console. The seeded player performance only decides which legal action to feed that engine.
</footer>
<script>
/* RULE ENGINE: identical in the console and playback. */
const RULES = Object.freeze({
startingChips: 5,
clearLimit: 4,
entryCents: 2000,
runnerUpCents: 2000,
hotStartCents: 2000,
turnSeconds: 90
});
function createGame(names, meta = {}) {
if (!Array.isArray(names) || names.length < 2) throw new Error("At least two players are required.");
const players = names.map((name, i) => ({
id: "p" + (i + 1),
name: String(name),
chips: RULES.startingChips,
turns: [],
outRound: null
}));
return {
players,
actions: [],
log: [],
queue: players.map(p => p.id),
round: 1,
status: "live",
winnerId: null,
eliminationOrder: [],
demo: Boolean(meta.demo)
};
}
function getPlayer(game, id) {
return game.players.find(p => p.id === id) || null;
}
function currentPlayer(game) {
return game.status === "live" ? getPlayer(game, game.queue[0]) : null;
}
function applyAction(game, input) {
if (game.status !== "live") throw new Error("The night is already finished.");
const type = input && input.type;
if (type !== "clear" && type !== "noShow") throw new Error("Unknown turn action.");
const shots = type === "clear" ? Number(input.shots) : null;
if (type === "clear" && (!Number.isInteger(shots) || shots < 1 || shots > 99)) {
throw new Error("Shots must be a whole number from 1 to 99.");
}
const player = currentPlayer(game);
if (!player) throw new Error("There is no current player.");
const actionRound = game.round;
const chipsBefore = player.chips;
const penalty = type === "noShow" || shots > RULES.clearLimit ? 1 : 0;
const action = type === "clear" ? { type, shots } : { type };
player.chips -= penalty;
player.turns.push({
round: actionRound,
type,
shots,
penalty,
chipsAfter: player.chips
});
game.actions.push(action);
game.queue.shift();
let eliminated = false;
if (player.chips === 0) {
player.outRound = actionRound;
game.eliminationOrder.push(player.id);
eliminated = true;
}
const alive = game.players.filter(p => p.chips > 0);
if (alive.length === 1) {
game.status = "finished";
game.winnerId = alive[0].id;
game.queue = [];
} else if (alive.length === 0) {
throw new Error("Invalid game: no players remain.");
} else if (game.queue.length === 0) {
game.round += 1;
game.queue = game.players.filter(p => p.chips > 0).map(p => p.id);
}
const event = {
n: game.log.length + 1,
round: actionRound,
playerId: player.id,
type,
shots,
penalty,
chipsBefore,
chipsAfter: player.chips,
eliminated,
nextId: game.status === "live" ? game.queue[0] : null,
nextRound: game.round,
winnerId: game.winnerId
};
game.log.push(event);
return event;
}
function rebuildGame(names, actions, meta = {}) {
const game = createGame(names, meta);
actions.forEach(action => applyAction(game, action));
return game;
}
function hotStart(game) {
const entries = [];
game.players.forEach(player => {
const turn = player.turns.find(t => t.round === 1 && t.type === "clear");
if (turn) entries.push({ id: player.id, shots: turn.shots });
});
if (!entries.length) return null;
const best = Math.min(...entries.map(e => e.shots));
return {
shots: best,
ids: entries.filter(e => e.shots === best).map(e => e.id)
};
}
function splitCents(total, count) {
if (!count) return [];
const base = Math.floor(total / count);
const remainder = total - base * count;
return Array.from({ length: count }, (_, i) => base + (i < remainder ? 1 : 0));
}
function getSettlement(game) {
if (game.status !== "finished") return null;
const potCents = game.players.length * RULES.entryCents;
let remaining = potCents;
const rowsById = new Map();
function pay(id, amount, reason) {
if (!id || amount <= 0) return;
if (!rowsById.has(id)) {
rowsById.set(id, { id, amountCents: 0, reasons: [] });
}
const row = rowsById.get(id);
row.amountCents += amount;
row.reasons.push(reason);
}
const runnerUpId = game.eliminationOrder.length
? game.eliminationOrder[game.eliminationOrder.length - 1]
: null;
const runnerUpTotal = runnerUpId ? Math.min(RULES.runnerUpCents, remaining) : 0;
pay(runnerUpId, runnerUpTotal, "Second place");
remaining -= runnerUpTotal;
const hot = hotStart(game);
const hotTotal = hot ? Math.min(RULES.hotStartCents, remaining) : 0;
if (hot) {
const shares = splitCents(hotTotal, hot.ids.length);
hot.ids.forEach((id, i) => pay(id, shares[i], "Hot Start · R1 best " + hot.shots));
}
remaining -= hotTotal;
const winnerTotal = remaining;
pay(game.winnerId, winnerTotal, "Winner");
const rows = Array.from(rowsById.values())
.filter(row => row.amountCents > 0)
.sort((a, b) => b.amountCents - a.amountCents ||
getPlayer(game, a.id).name.localeCompare(getPlayer(game, b.id).name));
return {
potCents,
runnerUpId,
runnerUpTotal,
hot,
hotTotal,
winnerId: game.winnerId,
winnerTotal,
rows,
totalCents: rows.reduce((sum, row) => sum + row.amountCents, 0)
};
}
/* END SHARED RULE ENGINE */
const $ = id => document.getElementById(id);
const NAMES = ["Avery","Bea","Cal","Dee","Eli","Flo","Gus","Hana","Ike","Jules","Kit","Lou"];
function esc(value) {
return String(value).replace(/[&<>"']/g, ch => ({
"&":"&","<":"<",">":">",'"':""","'":"'"
})[ch]);
}
function money(cents) {
return "$" + (cents / 100).toFixed(cents % 100 ? 2 : 0);
}
function seededRandom(seed) {
return function() {
seed |= 0;
seed = seed + 0x6D2B79F5 | 0;
let t = Math.imul(seed ^ seed >>> 15, 1 | seed);
t = t + Math.imul(t ^ t >>> 7, 61 | t) ^ t;
return ((t ^ t >>> 14) >>> 0) / 4294967296;
};
}
function sampleShots(random, difficulty, round) {
const roll = random() * difficulty;
let shots;
if (roll < .06) shots = 1;
else if (roll < .20) shots = 2;
else if (roll < .43) shots = 3;
else if (roll < .68) shots = 4;
else if (roll < .82) shots = 5;
else if (roll < .91) shots = 6;
else if (roll < .975) shots = 7;
else shots = 8;
if (round > 4 && random() < Math.min(.16, (round - 4) * .012)) shots++;
if (round > 30) shots = Math.max(shots, 5);
return Math.min(9, shots);
}
function buildNight() {
const random = seededRandom(0x34A19C);
const game = createGame(NAMES, { demo: true });
const difficulty = [.82,1.12,.87,1.17,.96,.84,1.20,.91,1.05,.86,1.00,.95];
const times = [];
let elapsed = 0;
let safety = 0;
while (game.status === "live" && safety++ < 3000) {
const player = currentPlayer(game);
const playerNumber = Number(player.id.slice(1)) - 1;
let action;
if (random() < .022) {
action = { type: "noShow" };
elapsed += 90;
} else {
const shots = sampleShots(random, difficulty[playerNumber], game.round);
action = { type: "clear", shots };
elapsed += 18 + shots * 9;
}
applyAction(game, action);
times.push(elapsed);
}
if (game.status !== "finished") throw new Error("The rehearsal could not finish.");
return {
names: NAMES.slice(),
actions: game.actions.slice(),
times,
final: game
};
}
function nightTime(elapsedSeconds) {
const absolute = 21 * 3600 + elapsedSeconds;
const hour24 = Math.floor(absolute / 3600) % 24;
const minute = Math.floor(absolute / 60) % 60;
const hour12 = hour24 % 12 || 12;
return hour12 + ":" + String(minute).padStart(2, "0") + " " + (hour24 >= 12 ? "PM" : "AM");
}
function totalShots(player) {
return player.turns.reduce((sum, turn) => sum + (turn.shots || 0), 0);
}
const night = buildNight();
const total = night.actions.length;
let index = 0;
let state = rebuildGame(night.names, [], { demo: true });
let playing = false;
let speed = 1;
let timer = null;
function eventDescription(event) {
const player = getPlayer(night.final, event.playerId);
let title, detail;
if (event.type === "clear") {
title = player.name + " cleared in " + event.shots + " shot" + (event.shots === 1 ? "" : "s");
detail = event.penalty
? "Over four shots, so one chip comes off: " + event.chipsBefore + " → " + event.chipsAfter + "."
: "Four or fewer, so there is no chip penalty. " + player.name + " still has " + event.chipsAfter + ".";
} else {
title = player.name + " missed the clock";
detail = "Away from the table when the 90-second clock expired. One chip comes off: " +
event.chipsBefore + " → " + event.chipsAfter + "; no table score is recorded.";
}
if (event.eliminated) detail += " " + player.name + " is out of chips and out of the night.";
if (event.winnerId) {
detail += " " + getPlayer(night.final, event.winnerId).name +
" is the only player left with chips, so the night ends now.";
} else if (event.nextId) {
if (event.nextRound > event.round) detail += " That closes the round; round " + event.nextRound + " begins.";
detail += " Next: " + getPlayer(night.final, event.nextId).name + ".";
}
return { title, detail };
}
function shortEvent(event) {
const player = getPlayer(night.final, event.playerId);
if (event.type === "noShow") {
return player.name + " · no-show · " + event.chipsBefore + "→" + event.chipsAfter + " chips" +
(event.eliminated ? " · OUT" : "");
}
return player.name + " · " + event.shots + " shots · " +
(event.penalty ? "−1 chip" : "no penalty") + (event.eliminated ? " · OUT" : "");
}
function renderTransport() {
$("stepLabel").textContent = "Step " + index + " of " + total;
$("positionSlider").value = index;
$("positionOutput").textContent = total
? Math.round(index / total * 100) + "% through the turns"
: "";
$("backBtn").disabled = index === 0;
$("nextBtn").disabled = index === total;
$("endBtn").disabled = index === total;
$("playBtn").textContent = playing ? "❚❚ Pause" : index === total ? "↺ Replay" : "▶ Play";
}
function renderScorecard() {
const current = currentPlayer(state);
const maxRound = Math.max(1, state.round, ...state.players.flatMap(p => p.turns.map(t => t.round)));
let html = "<thead><tr><th>Player</th><th>Chips</th>";
for (let round = 1; round <= maxRound; round++) html += "<th>R" + round + "</th>";
html += "<th>Total shots</th></tr></thead><tbody>";
state.players.forEach(player => {
const isCurrent = current && current.id === player.id;
html += "<tr class=\"" + (isCurrent ? "current " : "") + (player.chips === 0 ? "out" : "") + "\">";
html += "<td class=\"name\" title=\"" + esc(player.name) + "\">" +
(isCurrent ? "▶ " : "") + esc(player.name) + "</td>";
html += "<td class=\"chips\">" + (player.chips === 0 ? "OUT" : player.chips + "/5") + "</td>";
for (let round = 1; round <= maxRound; round++) {
const turn = player.turns.find(t => t.round === round);
if (!turn) {
html += "<td>·</td>";
} else if (turn.type === "noShow") {
html += "<td class=\"penalty\" title=\"No-show: one chip lost\">NS<span class=\"lossmark\">↓</span></td>";
} else {
html += "<td class=\"" + (turn.penalty ? "penalty" : "") + "\" title=\"" +
turn.shots + " shots" + (turn.penalty ? "; one chip lost" : "; no penalty") + "\">" +
turn.shots + (turn.penalty ? "<span class=\"lossmark\">↓</span>" : "") + "</td>";
}
}
html += "<td class=\"total\">" + totalShots(player) + "</td></tr>";
});
html += "</tbody>";
$("scorecard").innerHTML = html;
if (playing) $("scoreWrap").scrollLeft = $("scoreWrap").scrollWidth;
}
function renderTranscript() {
if (!index) {
$("transcript").innerHTML = "<li class=\"empty\">Nothing has happened yet. Play or step forward.</li>";
return;
}
const start = Math.max(0, index - 7);
const events = night.final.log.slice(start, index);
$("transcript").innerHTML = events.map((event, i) =>
"<li class=\"" + (i === events.length - 1 ? "latest" : "") + "\"><b>#" + event.n +
" · R" + event.round + "</b>" + esc(shortEvent(event)) + "</li>"
).join("");
}
function renderMoney() {
const settlement = getSettlement(state);
if (settlement) {
$("settlement").innerHTML = settlement.rows.map(row =>
"<div class=\"payrow\"><span class=\"payname\">" + esc(getPlayer(state, row.id).name) + "</span>" +
"<span class=\"payamount\">" + money(row.amountCents) + "</span><small>" +
esc(row.reasons.join(" + ")) + "</small></div>"
).join("") +
"<p class=\"reconcile\">Payout total " + money(settlement.totalCents) + " · Pot " +
money(settlement.potCents) + " · Unallocated " +
money(settlement.potCents - settlement.totalCents) + "</p>";
} else {
const hot = hotStart(state);
const hotLine = hot
? hot.ids.map(id => getPlayer(state, id).name).join(", ") + " leads at " + hot.shots + " shots"
: "No eligible round-one clear yet";
$("settlement").innerHTML =
"<p><b>Pot: $240.</b> Settlement appears when one player remains.</p>" +
"<p class=\"empty\"><b>Hot Start pace:</b> " + esc(hotLine) + "</p>" +
"<p class=\"empty\">Reserved at the end: $20 second place, $20 Hot Start, remainder to the winner.</p>";
}
}
function render() {
const current = currentPlayer(state);
const winner = state.winnerId ? getPlayer(state, state.winnerId) : null;
const alive = state.players.filter(p => p.chips > 0).length;
if (!index) {
$("eventTitle").textContent = "Twelve players. Five chips each.";
$("eventDetail").textContent = "9:00 PM · $240 in the pot. Avery leads off. Press Play to run the night, Forward one to inspect a single turn, or drag the night-position slider.";
$("roundLabel").textContent = "Ready for round 1";
$("timeLabel").textContent = "9:00 PM";
} else {
const event = night.final.log[index - 1];
const description = eventDescription(event);
$("eventTitle").textContent = description.title;
$("eventDetail").textContent = description.detail;
$("roundLabel").textContent = "Turn in round " + event.round;
$("timeLabel").textContent = nightTime(night.times[index - 1]);
}
$("winnerBanner").hidden = !winner;
if (winner) {
const settlement = getSettlement(state);
$("winnerBanner").textContent = winner.name + " wins · final round " + state.round +
" · " + money(settlement.potCents) + " fully allocated below";
}
$("livePill").textContent = winner ? "FINISHED" : index ? "LIVE STATE" : "READY";
$("livePill").classList.toggle("live", !winner);
$("currentText").textContent = current ? "At table: " + current.name : "No turns remain";
$("aliveText").textContent = alive + " of " + state.players.length + " still in";
renderTransport();
renderScorecard();
renderTranscript();
renderMoney();
}
function goTo(nextIndex) {
index = Math.max(0, Math.min(total, Number(nextIndex)));
state = rebuildGame(night.names, night.actions.slice(0, index), { demo: true });
render();
}
function pause() {
playing = false;
clearTimeout(timer);
timer = null;
renderTransport();
}
function schedule() {
clearTimeout(timer);
if (!playing) return;
if (index >= total) {
pause();
return;
}
timer = setTimeout(() => {
goTo(index + 1);
schedule();
}, 1400 / speed);
}
function updateSpeed() {
speed = Number($("speedSlider").value);
$("speedOutput").textContent = speed.toFixed(speed % 1 ? 2 : 0) + "× · " +
(1.4 / speed).toFixed(speed >= 2 ? 2 : 1) + " seconds per step";
}
$("playBtn").addEventListener("click", () => {
if (playing) {
pause();
return;
}
if (index >= total) goTo(0);
playing = true;
renderTransport();
schedule();
});
$("backBtn").addEventListener("click", () => {
pause();
goTo(index - 1);
});
$("nextBtn").addEventListener("click", () => {
pause();
goTo(index + 1);
});
$("restartBtn").addEventListener("click", () => {
pause();
goTo(0);
});
$("endBtn").addEventListener("click", () => {
pause();
goTo(total);
});
$("positionSlider").addEventListener("input", event => {
pause();
goTo(Number(event.target.value));
});
$("speedSlider").addEventListener("input", () => {
updateSpeed();
if (playing) schedule();
});
document.addEventListener("keydown", event => {
if (event.target.matches("input,textarea,button")) return;
if (event.key === "ArrowLeft") {
pause();
goTo(index - 1);
} else if (event.key === "ArrowRight") {
pause();
goTo(index + 1);
} else if (event.key === " ") {
event.preventDefault();
$("playBtn").click();
}
});
$("positionSlider").max = total;
$("nightSummary").textContent = total + " turn steps · simulated finish at " +
nightTime(night.times[total - 1]) + " · replay the same night every time";
updateSpeed();
render();
</script>
</body>
</html>