Skip to content

Instantly share code, notes, and snippets.

@basperheim
Created June 9, 2026 01:18
Show Gist options
  • Select an option

  • Save basperheim/f47a862ced9a2d0160e34ae726f49dc9 to your computer and use it in GitHub Desktop.

Select an option

Save basperheim/f47a862ced9a2d0160e34ae726f49dc9 to your computer and use it in GitHub Desktop.
A complete, dependency-free Yahtzee game built as a single standalone HTML file. Features a responsive metallic-blue neon interface, animated and clickable dice, keyboard controls, sound effects generated with the Web Audio API, full Yahtzee scoring, upper-section bonus tracking, local high-score persistence, and optional educational strategy hi…
<!DOCTYPE html>
<html lang="en">
<head>
<meta charset="UTF-8" />
<meta name="viewport" content="width=device-width, initial-scale=1.0" />
<meta name="color-scheme" content="dark" />
<title>NEON YAHTZEE</title>
<style>
:root {
--bg-0: #020712;
--bg-1: #071426;
--bg-2: #0c2440;
--panel: rgba(8, 26, 49, 0.82);
--panel-strong: rgba(7, 20, 39, 0.95);
--line: rgba(124, 201, 255, 0.26);
--line-bright: rgba(150, 220, 255, 0.65);
--text: #eaf7ff;
--muted: #87a8bf;
--cyan: #55d9ff;
--blue: #2c84ff;
--electric: #8ee8ff;
--gold: #ffc857;
--danger: #ff5b7f;
--success: #65f5b4;
--die-size: clamp(64px, 8vw, 96px);
--radius: 22px;
--shadow:
0 24px 80px rgba(0, 0, 0, 0.45),
inset 0 1px 0 rgba(255, 255, 255, 0.06);
}
* { box-sizing: border-box; }
html, body {
min-height: 100%;
margin: 0;
}
body {
overflow-x: hidden;
color: var(--text);
font-family:
Inter, ui-sans-serif, system-ui, -apple-system, BlinkMacSystemFont,
"Segoe UI", sans-serif;
background:
radial-gradient(circle at 18% 10%, rgba(40, 132, 255, 0.22), transparent 34%),
radial-gradient(circle at 82% 18%, rgba(65, 223, 255, 0.12), transparent 30%),
linear-gradient(160deg, var(--bg-0), var(--bg-1) 45%, #06101d 100%);
}
body::before {
content: "";
position: fixed;
inset: 0;
pointer-events: none;
opacity: 0.22;
background-image:
linear-gradient(rgba(255,255,255,0.025) 1px, transparent 1px),
linear-gradient(90deg, rgba(255,255,255,0.025) 1px, transparent 1px);
background-size: 42px 42px;
mask-image: radial-gradient(circle at center, black, transparent 86%);
}
button, input {
font: inherit;
}
button {
color: inherit;
}
.app-shell {
width: min(1520px, 100%);
margin: 0 auto;
padding: 24px;
position: relative;
z-index: 1;
}
.topbar {
display: flex;
gap: 18px;
align-items: center;
justify-content: space-between;
margin-bottom: 22px;
padding: 18px 20px;
border: 1px solid var(--line);
border-radius: var(--radius);
background:
linear-gradient(180deg, rgba(23, 65, 102, 0.72), rgba(5, 19, 38, 0.9));
box-shadow: var(--shadow);
backdrop-filter: blur(18px);
}
.brand {
display: flex;
align-items: center;
gap: 14px;
min-width: 0;
}
.brand-mark {
width: 48px;
height: 48px;
display: grid;
place-items: center;
border-radius: 14px;
background:
linear-gradient(145deg, #b9eeff, #3c90d6 42%, #0f355d 72%, #17334b);
border: 1px solid rgba(255,255,255,0.4);
color: #03101d;
font-weight: 1000;
font-size: 21px;
box-shadow:
0 0 28px rgba(85,217,255,0.35),
inset 2px 2px 4px rgba(255,255,255,0.65),
inset -4px -4px 8px rgba(0,0,0,0.38);
transform: rotate(-7deg);
}
h1 {
margin: 0;
font-size: clamp(1.2rem, 2vw, 2rem);
line-height: 1;
letter-spacing: 0.08em;
text-transform: uppercase;
}
.subtitle {
margin-top: 6px;
color: var(--muted);
font-size: 0.86rem;
letter-spacing: 0.08em;
text-transform: uppercase;
}
.topbar-actions {
display: flex;
gap: 10px;
flex-wrap: wrap;
justify-content: flex-end;
}
.game-grid {
display: grid;
grid-template-columns: minmax(0, 1.25fr) minmax(360px, 0.75fr);
gap: 22px;
align-items: start;
}
.panel {
border: 1px solid var(--line);
border-radius: var(--radius);
background:
linear-gradient(180deg, rgba(13, 38, 70, 0.78), rgba(4, 16, 31, 0.93));
box-shadow: var(--shadow);
backdrop-filter: blur(18px);
}
.arena {
min-height: 690px;
position: relative;
overflow: hidden;
}
.arena::before {
content: "";
position: absolute;
inset: 0;
pointer-events: none;
background:
radial-gradient(circle at 50% 44%, rgba(54, 155, 255, 0.2), transparent 34%),
linear-gradient(135deg, rgba(255,255,255,0.025), transparent 45%);
}
.arena-inner {
position: relative;
min-height: 690px;
padding: clamp(22px, 4vw, 50px);
display: flex;
flex-direction: column;
}
.status-row {
display: flex;
justify-content: space-between;
gap: 16px;
align-items: flex-start;
margin-bottom: 26px;
}
.round-label {
color: var(--cyan);
font-size: 0.8rem;
font-weight: 800;
letter-spacing: 0.16em;
text-transform: uppercase;
}
.status-main {
margin-top: 8px;
font-size: clamp(1.25rem, 2vw, 2rem);
font-weight: 900;
letter-spacing: -0.02em;
}
.roll-counter {
text-align: right;
color: var(--muted);
white-space: nowrap;
}
.roll-counter strong {
display: block;
color: var(--text);
font-size: 2rem;
line-height: 1;
}
.dice-zone {
flex: 1;
display: grid;
place-items: center;
min-height: 320px;
perspective: 1100px;
}
.dice-row {
width: 100%;
display: flex;
justify-content: center;
align-items: center;
gap: clamp(12px, 2vw, 24px);
flex-wrap: wrap;
}
.die-wrap {
position: relative;
width: var(--die-size);
height: calc(var(--die-size) + 34px);
display: grid;
justify-items: center;
align-content: start;
gap: 9px;
user-select: none;
}
.die {
width: var(--die-size);
height: var(--die-size);
position: relative;
display: grid;
grid-template-columns: repeat(3, 1fr);
grid-template-rows: repeat(3, 1fr);
padding: 13%;
border: 1px solid rgba(255,255,255,0.72);
border-radius: 19%;
cursor: pointer;
background:
radial-gradient(circle at 28% 20%, rgba(255,255,255,0.96), transparent 23%),
linear-gradient(145deg, #e7f6ff 0%, #8bbbdc 36%, #3f779e 72%, #173854 100%);
box-shadow:
0 18px 32px rgba(0,0,0,0.48),
0 0 22px rgba(71, 188, 255, 0.18),
inset 5px 5px 12px rgba(255,255,255,0.72),
inset -8px -8px 16px rgba(0,20,42,0.44);
transition:
transform 180ms ease,
box-shadow 180ms ease,
filter 180ms ease;
transform-style: preserve-3d;
}
.die:hover {
transform: translateY(-5px) rotateX(6deg) rotateY(-5deg);
box-shadow:
0 25px 38px rgba(0,0,0,0.55),
0 0 30px rgba(85,217,255,0.32),
inset 5px 5px 12px rgba(255,255,255,0.72),
inset -8px -8px 16px rgba(0,20,42,0.44);
}
.die.held {
transform: translateY(-10px);
filter: saturate(1.15) brightness(1.08);
box-shadow:
0 0 0 3px rgba(85,217,255,0.85),
0 0 34px rgba(85,217,255,0.72),
0 24px 42px rgba(0,0,0,0.56),
inset 5px 5px 12px rgba(255,255,255,0.75),
inset -8px -8px 16px rgba(0,20,42,0.42);
}
.die.hold-disabled {
cursor: default;
}
.die.rolling {
animation: tumble 650ms cubic-bezier(.2,.85,.25,1);
pointer-events: none;
}
.die-question {
position: absolute;
inset: 0;
display: grid;
place-items: center;
color: #06213a;
font-size: calc(var(--die-size) * 0.5);
font-weight: 1000;
line-height: 1;
text-shadow:
0 1px 0 rgba(255, 255, 255, 0.5),
0 0 10px rgba(85, 217, 255, 0.24);
opacity: 0;
pointer-events: none;
}
@keyframes tumble {
0% { transform: translateY(0) rotateX(0) rotateY(0) rotateZ(0) scale(1); }
20% { transform: translateY(-45px) rotateX(160deg) rotateY(110deg) rotateZ(35deg) scale(1.08); }
52% { transform: translateY(10px) rotateX(380deg) rotateY(260deg) rotateZ(120deg) scale(0.94); }
78% { transform: translateY(-12px) rotateX(510deg) rotateY(420deg) rotateZ(170deg) scale(1.02); }
100% { transform: translateY(0) rotateX(720deg) rotateY(540deg) rotateZ(180deg) scale(1); }
}
.pip {
width: 46%;
aspect-ratio: 1;
place-self: center;
border-radius: 50%;
opacity: 0;
background:
radial-gradient(circle at 35% 30%, #d6f5ff 0 10%, #0f385a 20%, #020b14 78%);
box-shadow:
inset 1px 1px 2px rgba(255,255,255,0.18),
0 1px 1px rgba(255,255,255,0.25);
}
.pip.visible { opacity: 1; }
.hold-label {
min-height: 22px;
color: transparent;
font-size: 0.72rem;
font-weight: 900;
letter-spacing: 0.18em;
text-transform: uppercase;
transition: color 150ms ease;
}
.die-wrap[data-held="true"] .hold-label {
color: var(--cyan);
text-shadow: 0 0 12px rgba(85,217,255,0.8);
}
.controls {
display: grid;
grid-template-columns: minmax(0, 1fr) auto;
gap: 14px;
align-items: stretch;
margin-top: 24px;
}
.hint {
display: flex;
align-items: center;
min-height: 62px;
padding: 14px 16px;
border: 1px solid rgba(115, 190, 235, 0.18);
border-radius: 16px;
color: var(--muted);
background: rgba(0, 8, 18, 0.28);
}
.primary-btn, .secondary-btn, .icon-btn {
border: 0;
cursor: pointer;
border-radius: 14px;
font-weight: 900;
letter-spacing: 0.06em;
text-transform: uppercase;
transition:
transform 150ms ease,
filter 150ms ease,
box-shadow 150ms ease,
opacity 150ms ease;
}
.primary-btn {
min-width: 190px;
padding: 0 24px;
background:
linear-gradient(180deg, #b5efff 0%, #4fbdf5 34%, #1976d7 64%, #0a3f85 100%);
color: #03101d;
border: 1px solid rgba(255,255,255,0.56);
box-shadow:
0 12px 30px rgba(31, 128, 235, 0.33),
inset 0 2px 0 rgba(255,255,255,0.68),
inset 0 -3px 8px rgba(0,0,0,0.28);
text-shadow: 0 1px rgba(255,255,255,0.46);
}
.primary-btn:hover:not(:disabled),
.secondary-btn:hover:not(:disabled),
.icon-btn:hover:not(:disabled) {
transform: translateY(-2px);
filter: brightness(1.08);
}
.primary-btn:active:not(:disabled),
.secondary-btn:active:not(:disabled),
.icon-btn:active:not(:disabled) {
transform: translateY(1px);
}
button:disabled {
cursor: not-allowed;
opacity: 0.44;
filter: grayscale(0.3);
}
.secondary-btn, .icon-btn {
min-height: 42px;
padding: 0 15px;
border: 1px solid var(--line);
background: linear-gradient(180deg, rgba(28, 71, 111, 0.82), rgba(8, 28, 52, 0.88));
color: var(--text);
box-shadow: inset 0 1px 0 rgba(255,255,255,0.09);
}
.icon-btn {
min-width: 46px;
padding: 0 12px;
font-size: 1.05rem;
}
.score-panel {
overflow: hidden;
}
.score-header {
padding: 20px;
border-bottom: 1px solid var(--line);
background:
linear-gradient(180deg, rgba(30, 79, 120, 0.4), rgba(0,0,0,0.08));
}
.score-title-row,
.score-summary {
display: flex;
justify-content: space-between;
align-items: baseline;
gap: 14px;
}
.score-title {
font-size: 1rem;
letter-spacing: 0.16em;
text-transform: uppercase;
color: var(--cyan);
font-weight: 900;
}
.score-summary {
margin-top: 12px;
}
.total-value {
font-size: 2.15rem;
line-height: 1;
font-weight: 1000;
text-shadow: 0 0 20px rgba(85,217,255,0.28);
}
.score-note {
text-align: right;
color: var(--muted);
font-size: 0.78rem;
line-height: 1.4;
}
.score-list {
max-height: 610px;
overflow-y: auto;
scrollbar-width: thin;
scrollbar-color: rgba(85,217,255,0.45) rgba(0,0,0,0.18);
}
.section-label {
padding: 12px 18px 8px;
color: var(--muted);
background: rgba(0, 8, 18, 0.32);
font-size: 0.68rem;
font-weight: 900;
letter-spacing: 0.2em;
text-transform: uppercase;
}
.score-row {
min-height: 48px;
display: grid;
grid-template-columns: 1fr auto;
gap: 14px;
align-items: center;
padding: 10px 16px;
border-top: 1px solid rgba(120, 195, 240, 0.11);
cursor: pointer;
transition: background 120ms ease, transform 120ms ease;
}
.score-row:hover:not(.used):not(.locked) {
background: rgba(65, 174, 255, 0.12);
transform: translateX(-2px);
}
.score-row.used {
cursor: default;
background: rgba(0,0,0,0.14);
}
.score-row.locked {
cursor: default;
opacity: 0.58;
}
.score-row {
position: relative;
z-index: 0;
}
.score-row:hover,
.score-row:focus,
.score-row:focus-within {
z-index: 100;
}
.category-name {
font-weight: 780;
}
.category-help {
margin-top: 2px;
color: var(--muted);
font-size: 0.72rem;
}
.score-value {
min-width: 56px;
padding: 6px 10px;
text-align: center;
border-radius: 10px;
font-weight: 1000;
background: rgba(1, 10, 20, 0.4);
border: 1px solid rgba(130, 205, 250, 0.18);
}
.score-row:not(.used):not(.locked) .score-value.preview {
color: var(--cyan);
border-color: rgba(85,217,255,0.55);
box-shadow: inset 0 0 14px rgba(85,217,255,0.08);
}
.score-row.used .score-value {
color: var(--success);
}
.score-row.recommended {
position: relative;
z-index: 3;
background:
linear-gradient(90deg, rgba(42, 145, 255, 0.24), rgba(85, 217, 255, 0.08));
box-shadow:
inset 3px 0 0 var(--cyan),
inset 0 0 24px rgba(85, 217, 255, 0.12),
0 0 22px rgba(85, 217, 255, 0.12);
}
.score-row.recommended .score-value {
color: #dff9ff;
border-color: rgba(142, 232, 255, 0.85);
box-shadow:
0 0 18px rgba(85, 217, 255, 0.28),
inset 0 0 15px rgba(85, 217, 255, 0.12);
}
.recommendation-badge {
display: inline-block;
margin-left: 8px;
padding: 2px 6px;
border: 1px solid rgba(142, 232, 255, 0.58);
border-radius: 999px;
color: var(--cyan);
background: rgba(8, 42, 67, 0.72);
font-size: 0.57rem;
font-weight: 950;
letter-spacing: 0.12em;
text-transform: uppercase;
vertical-align: 2px;
text-shadow: 0 0 8px rgba(85, 217, 255, 0.55);
}
.strategy-tooltip {
position: absolute;
right: 14px;
top: calc(100% - 4px);
z-index: 101;
width: min(330px, calc(100vw - 42px));
padding: 13px 14px;
border: 1px solid rgba(142, 232, 255, 0.48);
border-radius: 13px;
color: var(--text);
background:
linear-gradient(180deg, rgba(11, 39, 68, 0.98), rgba(3, 15, 29, 0.99));
box-shadow:
0 18px 44px rgba(0, 0, 0, 0.58),
0 0 24px rgba(85, 217, 255, 0.12);
opacity: 0;
visibility: hidden;
pointer-events: none;
transform: translateY(-4px) scale(0.98);
transform-origin: top right;
transition:
opacity 130ms ease,
transform 130ms ease,
visibility 130ms ease;
}
.score-row:hover .strategy-tooltip,
.score-row:focus-within .strategy-tooltip,
.score-row:focus .strategy-tooltip {
opacity: 1;
visibility: visible;
transform: translateY(0) scale(1);
}
.tooltip-title {
margin-bottom: 8px;
color: var(--cyan);
font-weight: 900;
}
.tooltip-line {
display: flex;
justify-content: space-between;
gap: 16px;
margin-top: 5px;
color: var(--muted);
font-size: 0.75rem;
}
.tooltip-line strong {
color: var(--text);
text-align: right;
}
.tooltip-advice {
margin-top: 10px;
padding-top: 9px;
border-top: 1px solid rgba(130, 205, 250, 0.16);
color: #b9d8ea;
font-size: 0.76rem;
line-height: 1.45;
}
.tooltip-advice.good {
color: var(--success);
}
.strategy-off .strategy-tooltip,
.strategy-off .recommendation-badge {
display: none;
}
.strategy-off .score-row.recommended {
background: transparent;
box-shadow: none;
}
.bonus-meter {
margin-top: 14px;
height: 8px;
overflow: hidden;
border-radius: 999px;
background: rgba(0, 0, 0, 0.32);
border: 1px solid rgba(255,255,255,0.06);
}
.bonus-fill {
height: 100%;
width: 0%;
background: linear-gradient(90deg, var(--blue), var(--cyan), var(--electric));
box-shadow: 0 0 16px rgba(85,217,255,0.45);
transition: width 400ms ease;
}
.modal-backdrop {
position: fixed;
inset: 0;
z-index: 20;
display: grid;
place-items: center;
padding: 22px;
background: rgba(0, 5, 12, 0.78);
backdrop-filter: blur(14px);
opacity: 0;
pointer-events: none;
transition: opacity 180ms ease;
}
.modal-backdrop.open {
opacity: 1;
pointer-events: auto;
}
.modal {
width: min(520px, 100%);
padding: 28px;
border: 1px solid var(--line-bright);
border-radius: 24px;
background:
linear-gradient(180deg, rgba(18, 52, 88, 0.98), rgba(4, 15, 30, 0.99));
box-shadow:
0 30px 100px rgba(0,0,0,0.7),
0 0 60px rgba(45, 148, 255, 0.18),
inset 0 1px 0 rgba(255,255,255,0.08);
transform: translateY(10px) scale(0.98);
transition: transform 180ms ease;
}
.modal-backdrop.open .modal {
transform: translateY(0) scale(1);
}
.modal h2 {
margin: 0 0 10px;
font-size: 2rem;
}
.modal p {
color: var(--muted);
line-height: 1.6;
}
.modal-score {
margin: 18px 0;
font-size: 3.4rem;
font-weight: 1000;
color: var(--cyan);
text-shadow: 0 0 32px rgba(85,217,255,0.38);
}
.modal-actions {
display: flex;
gap: 12px;
justify-content: flex-end;
margin-top: 22px;
}
.toast-stack {
position: fixed;
right: 22px;
bottom: 22px;
z-index: 30;
display: grid;
gap: 10px;
pointer-events: none;
}
.toast {
min-width: 250px;
max-width: 360px;
padding: 13px 15px;
border-radius: 14px;
color: var(--text);
background: rgba(4, 18, 34, 0.94);
border: 1px solid var(--line-bright);
box-shadow: 0 18px 40px rgba(0,0,0,0.45);
animation: toastIn 260ms ease, toastOut 280ms ease 2.5s forwards;
}
@keyframes toastIn {
from { opacity: 0; transform: translateY(16px) scale(0.96); }
to { opacity: 1; transform: translateY(0) scale(1); }
}
@keyframes toastOut {
to { opacity: 0; transform: translateY(10px) scale(0.98); }
}
.particle {
position: fixed;
z-index: 25;
width: 8px;
height: 8px;
border-radius: 50%;
pointer-events: none;
background: var(--cyan);
box-shadow: 0 0 12px currentColor;
animation: burst 900ms ease-out forwards;
}
@keyframes burst {
to {
opacity: 0;
transform: translate(var(--dx), var(--dy)) scale(0.2) rotate(360deg);
}
}
.kbd {
display: inline-block;
min-width: 24px;
padding: 2px 6px;
margin: 0 2px;
text-align: center;
color: #cceeff;
background: rgba(255,255,255,0.06);
border: 1px solid rgba(255,255,255,0.14);
border-bottom-width: 2px;
border-radius: 6px;
font-size: 0.72rem;
font-weight: 800;
}
@media (max-width: 980px) {
.game-grid {
grid-template-columns: 1fr;
}
.score-list {
max-height: none;
}
.arena,
.arena-inner {
min-height: 590px;
}
}
@media (max-width: 680px) {
.app-shell { padding: 12px; }
.topbar {
align-items: flex-start;
padding: 14px;
}
.subtitle { display: none; }
.topbar-actions {
gap: 7px;
}
.secondary-btn {
min-height: 38px;
padding: 0 11px;
font-size: 0.75rem;
}
.arena-inner {
padding: 20px 14px;
}
.controls {
grid-template-columns: 1fr;
}
.primary-btn {
min-height: 58px;
width: 100%;
}
.hint {
order: 2;
}
.status-main {
max-width: 230px;
}
}
@media (prefers-reduced-motion: reduce) {
*, *::before, *::after {
animation-duration: 1ms !important;
animation-iteration-count: 1 !important;
scroll-behavior: auto !important;
}
}
</style>
</head>
<body>
<main class="app-shell">
<header class="topbar">
<div class="brand">
<div class="brand-mark" aria-hidden="true">Y!</div>
<div>
<h1>Neon Yahtzee</h1>
<div class="subtitle">Five dice. Thirteen rounds. Zero mercy.</div>
</div>
</div>
<div class="topbar-actions">
<button class="secondary-btn" id="strategyBtn" type="button" title="Toggle educational strategy hints">Hints: On</button>
<button class="secondary-btn" id="newGameBtn" type="button">New Game</button>
<button class="icon-btn" id="soundBtn" type="button" aria-label="Toggle sound" title="Toggle sound">🔊</button>
</div>
</header>
<section class="game-grid">
<section class="panel arena" aria-label="Dice arena">
<div class="arena-inner">
<div class="status-row">
<div>
<div class="round-label" id="roundLabel">Round 1 of 13</div>
<div class="status-main" id="statusText">Lock in. Roll the dice.</div>
</div>
<div class="roll-counter">
Rolls left
<strong id="rollsLeft">3</strong>
</div>
</div>
<div class="dice-zone">
<div class="dice-row" id="diceRow" aria-label="Dice"></div>
</div>
<div class="controls">
<div class="hint" id="hintText">
Roll, then click dice to hold them. Keyboard: <span class="kbd">Space</span> to roll, <span class="kbd">1–5</span> to hold.
</div>
<button class="primary-btn" id="rollBtn" type="button">Roll Dice</button>
</div>
</div>
</section>
<aside class="panel score-panel" aria-label="Scorecard">
<div class="score-header">
<div class="score-title-row">
<div class="score-title">Combat Scorecard</div>
<div id="highScoreLabel">Best: 0</div>
</div>
<div class="score-summary">
<div>
<div class="total-value" id="grandTotal">0</div>
<div class="score-note">Current score</div>
</div>
<div class="score-note">
Upper: <strong id="upperTotal">0</strong> / 63<br />
Bonus: <strong id="bonusValue">0</strong>
</div>
</div>
<div class="bonus-meter" aria-label="Upper section bonus progress">
<div class="bonus-fill" id="bonusFill"></div>
</div>
</div>
<div class="score-list" id="scoreList"></div>
</aside>
</section>
</main>
<div class="modal-backdrop" id="modalBackdrop" role="dialog" aria-modal="true" aria-labelledby="modalTitle">
<div class="modal">
<h2 id="modalTitle">Mission Complete</h2>
<p id="modalText">Your final score:</p>
<div class="modal-score" id="modalScore">0</div>
<div class="modal-actions">
<button class="primary-btn" id="playAgainBtn" type="button">Play Again</button>
</div>
</div>
</div>
<div class="toast-stack" id="toastStack" aria-live="polite"></div>
<script>
"use strict";
const CATEGORY_DEFINITIONS = [
{ id: "ones", label: "Ones", section: "upper", help: "Sum all ones" },
{ id: "twos", label: "Twos", section: "upper", help: "Sum all twos" },
{ id: "threes", label: "Threes", section: "upper", help: "Sum all threes" },
{ id: "fours", label: "Fours", section: "upper", help: "Sum all fours" },
{ id: "fives", label: "Fives", section: "upper", help: "Sum all fives" },
{ id: "sixes", label: "Sixes", section: "upper", help: "Sum all sixes" },
{ id: "threeKind", label: "Three of a Kind", section: "lower", help: "3+ matching — sum all dice" },
{ id: "fourKind", label: "Four of a Kind", section: "lower", help: "4+ matching — sum all dice" },
{ id: "fullHouse", label: "Full House", section: "lower", help: "Three + pair — 25 points" },
{ id: "smallStraight", label: "Small Straight", section: "lower", help: "Four in sequence — 30 points" },
{ id: "largeStraight", label: "Large Straight", section: "lower", help: "Five in sequence — 40 points" },
{ id: "yahtzee", label: "YAHTZEE", section: "lower", help: "Five matching — 50 points" },
{ id: "chance", label: "Chance", section: "lower", help: "Sum all dice" }
];
const state = {
dice: [null, null, null, null, null],
held: [false, false, false, false, false],
rollsLeft: 3,
hasRolled: false,
isRolling: false,
scores: Object.fromEntries(CATEGORY_DEFINITIONS.map(({ id }) => [id, null])),
soundEnabled: true,
strategyEnabled: localStorage.getItem("neon-yahtzee-strategy") !== "off"
};
const diceRow = document.getElementById("diceRow");
const rollBtn = document.getElementById("rollBtn");
const strategyBtn = document.getElementById("strategyBtn");
const newGameBtn = document.getElementById("newGameBtn");
const soundBtn = document.getElementById("soundBtn");
const scoreList = document.getElementById("scoreList");
const roundLabel = document.getElementById("roundLabel");
const statusText = document.getElementById("statusText");
const rollsLeftEl = document.getElementById("rollsLeft");
const grandTotalEl = document.getElementById("grandTotal");
const upperTotalEl = document.getElementById("upperTotal");
const bonusValueEl = document.getElementById("bonusValue");
const bonusFillEl = document.getElementById("bonusFill");
const highScoreLabel = document.getElementById("highScoreLabel");
const hintText = document.getElementById("hintText");
const modalBackdrop = document.getElementById("modalBackdrop");
const modalScore = document.getElementById("modalScore");
const modalText = document.getElementById("modalText");
const playAgainBtn = document.getElementById("playAgainBtn");
const toastStack = document.getElementById("toastStack");
let audioContext = null;
function getHighScore() {
return Number(localStorage.getItem("neon-yahtzee-high-score") || 0);
}
function setHighScore(score) {
localStorage.setItem("neon-yahtzee-high-score", String(score));
}
function sum(values) {
return values.reduce((total, value) => total + value, 0);
}
function getCounts(dice) {
return dice.reduce((counts, die) => {
counts[die] = (counts[die] || 0) + 1;
return counts;
}, {});
}
function calculateScore(categoryId, dice) {
const counts = getCounts(dice);
const values = Object.values(counts);
const total = sum(dice);
const uniqueSorted = [...new Set(dice)].sort((a, b) => a - b);
const sequence = uniqueSorted.join("");
const upperMap = {
ones: 1,
twos: 2,
threes: 3,
fours: 4,
fives: 5,
sixes: 6
};
if (upperMap[categoryId]) {
const face = upperMap[categoryId];
return (counts[face] || 0) * face;
}
switch (categoryId) {
case "threeKind":
return values.some(count => count >= 3) ? total : 0;
case "fourKind":
return values.some(count => count >= 4) ? total : 0;
case "fullHouse":
return values.includes(3) && values.includes(2) ? 25 : 0;
case "smallStraight":
return ["1234", "2345", "3456"].some(run => sequence.includes(run)) ? 30 : 0;
case "largeStraight":
return sequence === "12345" || sequence === "23456" ? 40 : 0;
case "yahtzee":
return values.includes(5) ? 50 : 0;
case "chance":
return total;
default:
return 0;
}
}
const UPPER_FACE_BY_CATEGORY = {
ones: 1,
twos: 2,
threes: 3,
fours: 4,
fives: 5,
sixes: 6
};
const BASE_ODDS_CACHE = new Map();
function formatPercent(probability) {
if (probability === null) {
return "—";
}
const percent = probability * 100;
if (percent > 0 && percent < 0.1) {
return "<0.1%";
}
return `${percent.toFixed(percent < 10 ? 1 : 0)}%`;
}
function enumerateRolls(count, callback, working = []) {
if (working.length === count) {
callback(working);
return;
}
for (let value = 1; value <= 6; value += 1) {
working.push(value);
enumerateRolls(count, callback, working);
working.pop();
}
}
function meetsLearningTarget(categoryId, dice) {
const counts = getCounts(dice);
const values = Object.values(counts);
const uniqueSorted = [...new Set(dice)].sort((a, b) => a - b);
const sequence = uniqueSorted.join("");
if (UPPER_FACE_BY_CATEGORY[categoryId]) {
return (counts[UPPER_FACE_BY_CATEGORY[categoryId]] || 0) >= 3;
}
switch (categoryId) {
case "threeKind":
return values.some(count => count >= 3);
case "fourKind":
return values.some(count => count >= 4);
case "fullHouse":
return values.includes(3) && values.includes(2);
case "smallStraight":
return ["1234", "2345", "3456"].some(run => sequence.includes(run));
case "largeStraight":
return sequence === "12345" || sequence === "23456";
case "yahtzee":
return values.includes(5);
default:
return false;
}
}
function getBaseOdds(categoryId) {
if (categoryId === "chance") {
return null;
}
if (BASE_ODDS_CACHE.has(categoryId)) {
return BASE_ODDS_CACHE.get(categoryId);
}
let successes = 0;
let total = 0;
enumerateRolls(5, dice => {
total += 1;
if (meetsLearningTarget(categoryId, dice)) {
successes += 1;
}
});
const probability = successes / total;
BASE_ODDS_CACHE.set(categoryId, probability);
return probability;
}
function getSuggestedHoldIndexes(categoryId, dice) {
const counts = getCounts(dice);
if (UPPER_FACE_BY_CATEGORY[categoryId]) {
const face = UPPER_FACE_BY_CATEGORY[categoryId];
return dice.map((value, index) => value === face ? index : -1).filter(index => index >= 0);
}
if (["threeKind", "fourKind", "yahtzee"].includes(categoryId)) {
const bestFace = Object.entries(counts)
.sort((a, b) => Number(b[1]) - Number(a[1]) || Number(b[0]) - Number(a[0]))[0][0];
return dice.map((value, index) => value === Number(bestFace) ? index : -1).filter(index => index >= 0);
}
if (categoryId === "fullHouse") {
const usefulFaces = Object.entries(counts)
.filter(([, count]) => count >= 2)
.sort((a, b) => Number(b[1]) - Number(a[1]))
.slice(0, 2)
.map(([face]) => Number(face));
if (usefulFaces.length === 0) {
const bestFace = Number(Object.entries(counts).sort((a, b) => Number(b[1]) - Number(a[1]))[0][0]);
usefulFaces.push(bestFace);
}
return dice.map((value, index) => usefulFaces.includes(value) ? index : -1).filter(index => index >= 0);
}
if (categoryId === "smallStraight" || categoryId === "largeStraight") {
const runs = categoryId === "smallStraight"
? [[1, 2, 3, 4], [2, 3, 4, 5], [3, 4, 5, 6]]
: [[1, 2, 3, 4, 5], [2, 3, 4, 5, 6]];
const bestRun = runs
.map(run => ({
run,
matches: new Set(dice.filter(value => run.includes(value))).size
}))
.sort((a, b) => b.matches - a.matches)[0].run;
const heldValues = new Set();
const indexes = [];
dice.forEach((value, index) => {
if (bestRun.includes(value) && !heldValues.has(value)) {
heldValues.add(value);
indexes.push(index);
}
});
return indexes;
}
if (categoryId === "chance") {
return dice.map((value, index) => value >= 4 ? index : -1).filter(index => index >= 0);
}
return [];
}
function getNextRollProbability(categoryId, dice) {
if (categoryId === "chance" || state.rollsLeft <= 0) {
return null;
}
const heldIndexes = new Set(getSuggestedHoldIndexes(categoryId, dice));
const rerollIndexes = dice
.map((_, index) => index)
.filter(index => !heldIndexes.has(index));
if (rerollIndexes.length === 0) {
return meetsLearningTarget(categoryId, dice) ? 1 : 0;
}
let successes = 0;
let total = 0;
enumerateRolls(rerollIndexes.length, rolledValues => {
const candidate = [...dice];
rerollIndexes.forEach((dieIndex, valueIndex) => {
candidate[dieIndex] = rolledValues[valueIndex];
});
total += 1;
if (meetsLearningTarget(categoryId, candidate)) {
successes += 1;
}
});
return successes / total;
}
function getHoldDescription(categoryId, dice) {
if (categoryId === "chance") {
return "Keep high dice (4–6)";
}
const indexes = getSuggestedHoldIndexes(categoryId, dice);
if (indexes.length === 0) {
return "Reroll all five";
}
const values = indexes.map(index => dice[index]);
return `Hold ${values.join("–")}`;
}
function getCategoryAdvice(category, score) {
const face = UPPER_FACE_BY_CATEGORY[category.id];
const counts = getCounts(state.dice);
const rollsLeft = state.rollsLeft;
const upperTotal = getUpperTotal();
const upperSlotsUsed = CATEGORY_DEFINITIONS
.filter(item => item.section === "upper" && state.scores[item.id] !== null)
.length;
const upperPaceNeeded = Math.max(0, 63 - upperTotal);
const upperSlotsLeft = 6 - upperSlotsUsed;
const requiredAverage = upperSlotsLeft > 0 ? upperPaceNeeded / upperSlotsLeft : 0;
let rank = score;
let reason = "Legal placement, but another slot may preserve more flexibility.";
let strong = false;
if (face) {
const count = counts[face] || 0;
const benchmark = face * 3;
rank = score - benchmark + (face >= 4 ? 3 : 0);
if (count >= 4) {
rank += 30;
strong = true;
reason = `Excellent upper-section result: ${count} ${category.label.toLowerCase()} strongly supports the 63-point bonus.`;
} else if (count === 3) {
rank += 18;
strong = true;
reason = `On bonus pace: three of this number is the standard ${benchmark}-point benchmark.`;
} else if (score >= requiredAverage && score > 0) {
rank += 8;
reason = `Useful for the upper bonus; you currently need about ${requiredAverage.toFixed(1)} points per remaining upper slot.`;
} else if (score === 0) {
rank -= face >= 5 ? 30 : face * 2;
reason = rollsLeft > 0
? "A zero here is premature while rerolls remain."
: "A zero may be necessary, but high-number upper slots are expensive to sacrifice.";
} else {
reason = `Below the three-of-a-number bonus benchmark (${benchmark}), so this is usually a fallback.`;
}
} else {
switch (category.id) {
case "yahtzee":
rank = score === 50 ? 120 : -45;
strong = score === 50;
reason = score === 50
? "Take it. Yahtzee is both maximum value and extremely difficult to reproduce."
: "Do not burn Yahtzee for zero while a safer sacrifice exists.";
break;
case "largeStraight":
rank = score === 40 ? 95 : -28;
strong = score === 40;
reason = score === 40
? "Maximum fixed score and hard to reproduce. This is an excellent lock-in."
: "Keep this slot alive unless the scorecard leaves no cheaper sacrifice.";
break;
case "smallStraight":
rank = score === 30 ? 65 : -14;
strong = score === 30;
reason = score === 30
? "Maximum fixed score. Usually worth banking now."
: "A useful category to preserve while rerolls remain.";
break;
case "fullHouse":
rank = score === 25 ? 58 : -12;
strong = score === 25;
reason = score === 25
? "Maximum fixed score. Banking the full house is normally correct."
: "A fixed 25-point category is worth preserving when possible.";
break;
case "fourKind":
rank = score > 0 ? score + (score >= 24 ? 30 : 12) : -18;
strong = score >= 24;
reason = score > 0
? score >= 24
? "Strong four-of-a-kind total. This is difficult to improve meaningfully."
: "Valid, but low totals can be worth rerolling if you still have time."
: "Four of a Kind is difficult; avoid sacrificing it early.";
break;
case "threeKind":
rank = score > 0 ? score + (score >= 20 ? 18 : 5) : -6;
strong = score >= 20;
reason = score > 0
? score >= 20
? "A solid three-of-a-kind total that preserves Chance."
: "Playable, but modest; consider rerolling if stronger paths are open."
: "This is one of the less painful lower slots to sacrifice late.";
break;
case "chance":
rank = score - 9;
if (score >= 24) {
rank += 16;
strong = true;
reason = "A high Chance score is useful, especially when no rarer category is completed.";
} else {
reason = "Chance is flexible. Avoid spending it on a mediocre total unless you need the escape hatch.";
}
break;
}
}
if (rollsLeft > 0 && !strong) {
rank -= 18;
}
return { rank, reason, strong };
}
function getRecommendation(previews) {
if (!state.hasRolled || state.isRolling) {
return null;
}
const candidates = CATEGORY_DEFINITIONS
.filter(category => state.scores[category.id] === null)
.map(category => ({
category,
score: previews[category.id],
...getCategoryAdvice(category, previews[category.id])
}))
.sort((a, b) => b.rank - a.rank);
const best = candidates[0];
if (!best) {
return null;
}
// With rerolls remaining, only recommend scoring genuinely strong results.
if (state.rollsLeft > 0 && !best.strong) {
return null;
}
return best;
}
function buildStrategyTooltip(category, score, advice) {
const baseOdds = getBaseOdds(category.id);
const nextRollProbability = state.hasRolled
? getNextRollProbability(category.id, state.dice)
: null;
const tooltip = document.createElement("div");
tooltip.className = "strategy-tooltip";
tooltip.setAttribute("role", "tooltip");
const title = document.createElement("div");
title.className = "tooltip-title";
title.textContent = `${category.label} strategy`;
const currentLine = document.createElement("div");
currentLine.className = "tooltip-line";
currentLine.innerHTML = `<span>Current score</span><strong>${state.hasRolled ? score : "Roll first"}</strong>`;
const oddsLine = document.createElement("div");
oddsLine.className = "tooltip-line";
oddsLine.innerHTML = category.id === "chance"
? "<span>Random-hand average</span><strong>17.5 pts</strong>"
: `<span>One-roll base chance</span><strong>${formatPercent(baseOdds)}</strong>`;
const nextLine = document.createElement("div");
nextLine.className = "tooltip-line";
nextLine.innerHTML = state.hasRolled && state.rollsLeft > 0
? `<span>Next-roll target chance</span><strong>${formatPercent(nextRollProbability)}</strong>`
: `<span>Suggested hold</span><strong>${state.hasRolled ? "No rolls left" : "Shown after roll"}</strong>`;
const holdLine = document.createElement("div");
holdLine.className = "tooltip-line";
holdLine.innerHTML = `<span>Learning hint</span><strong>${state.hasRolled && state.rollsLeft > 0 ? getHoldDescription(category.id, state.dice) : category.help}</strong>`;
const adviceText = document.createElement("div");
adviceText.className = `tooltip-advice${advice.strong ? " good" : ""}`;
adviceText.textContent = advice.reason;
tooltip.append(title, currentLine, oddsLine, nextLine, holdLine, adviceText);
return tooltip;
}
function getUsedCategoryCount() {
return Object.values(state.scores).filter(score => score !== null).length;
}
function getUpperTotal() {
return CATEGORY_DEFINITIONS
.filter(category => category.section === "upper")
.reduce((total, category) => total + (state.scores[category.id] || 0), 0);
}
function getBonus() {
return getUpperTotal() >= 63 ? 35 : 0;
}
function getGrandTotal() {
return Object.values(state.scores).reduce((total, score) => total + (score || 0), 0) + getBonus();
}
function createDie(index) {
const wrap = document.createElement("div");
wrap.className = "die-wrap";
wrap.dataset.index = String(index);
wrap.dataset.held = String(state.held[index]);
const die = document.createElement("button");
const questionMark = document.createElement("span");
questionMark.className = "die-question";
questionMark.textContent = "?";
die.appendChild(questionMark);
die.type = "button";
die.className = "die";
die.setAttribute("aria-label", `Die ${index + 1}: ${state.dice[index]}`);
die.addEventListener("click", () => toggleHold(index));
for (let row = 1; row <= 3; row += 1) {
for (let column = 1; column <= 3; column += 1) {
const pip = document.createElement("span");
pip.className = "pip";
pip.style.gridRow = String(row);
pip.style.gridColumn = String(column);
die.appendChild(pip);
}
}
const holdLabel = document.createElement("div");
holdLabel.className = "hold-label";
holdLabel.textContent = "Held";
wrap.append(die, holdLabel);
return wrap;
}
function getVisiblePipIndexes(value) {
if (value === null) {
return [];
}
const map = {
1: [4],
2: [0, 8],
3: [0, 4, 8],
4: [0, 2, 6, 8],
5: [0, 2, 4, 6, 8],
6: [0, 2, 3, 5, 6, 8]
};
return map[value];
}
function renderDice() {
if (diceRow.children.length !== 5) {
diceRow.innerHTML = "";
for (let index = 0; index < 5; index += 1) {
diceRow.appendChild(createDie(index));
}
}
[...diceRow.children].forEach((wrap, index) => {
const die = wrap.querySelector(".die");
const value = state.dice[index];
const visiblePips = new Set(
value === null ? [] : getVisiblePipIndexes(value)
);
const questionMark = die.querySelector(".die-question");
questionMark.style.opacity = value === null ? "1" : "0";
wrap.dataset.held = String(state.held[index]);
const canHold = state.hasRolled && !state.isRolling;
die.classList.toggle("held", state.held[index]);
die.classList.toggle(
"rolling",
state.isRolling && !state.held[index]
);
die.classList.toggle("hold-disabled", !canHold);
die.setAttribute("aria-disabled", String(!canHold));
die.setAttribute(
"aria-label",
value === null
? `Die ${index + 1}: not rolled`
: `Die ${index + 1}: ${value}${state.held[index] ? ", held" : ""}`
);
[...die.querySelectorAll(".pip")].forEach((pip, pipIndex) => {
pip.classList.toggle("visible", visiblePips.has(pipIndex));
});
});
}
function renderScorecard() {
const previews = Object.fromEntries(
CATEGORY_DEFINITIONS.map(category => [
category.id,
state.hasRolled ? calculateScore(category.id, state.dice) : null
])
);
const recommendation = getRecommendation(previews);
scoreList.innerHTML = "";
scoreList.classList.toggle("strategy-off", !state.strategyEnabled);
for (const section of ["upper", "lower"]) {
const sectionLabel = document.createElement("div");
sectionLabel.className = "section-label";
sectionLabel.textContent = section === "upper" ? "Upper Deck" : "Lower Deck";
scoreList.appendChild(sectionLabel);
CATEGORY_DEFINITIONS
.filter(category => category.section === section)
.forEach(category => {
const usedScore = state.scores[category.id];
const currentScore = previews[category.id] ?? 0;
const advice = state.hasRolled
? getCategoryAdvice(category, currentScore)
: {
rank: 0,
strong: false,
reason: category.section === "upper"
? "Three of each number keeps you on pace for the 63-point upper bonus."
: "Hover after a roll for a current-hand recommendation."
};
const row = document.createElement("div");
row.className = "score-row";
row.dataset.category = category.id;
const isUsed = usedScore !== null;
const isLocked = !state.hasRolled || state.isRolling;
const isRecommended = state.strategyEnabled
&& recommendation?.category.id === category.id
&& !isUsed;
row.classList.toggle("used", isUsed);
row.classList.toggle("locked", !isUsed && isLocked);
row.classList.toggle("recommended", isRecommended);
const textWrap = document.createElement("div");
const name = document.createElement("div");
const help = document.createElement("div");
name.className = "category-name";
help.className = "category-help";
name.textContent = category.label;
if (isRecommended) {
const badge = document.createElement("span");
badge.className = "recommendation-badge";
badge.textContent = "Recommended";
name.appendChild(badge);
}
help.textContent = category.help;
textWrap.append(name, help);
const scoreValue = document.createElement("div");
scoreValue.className = "score-value";
if (isUsed) {
scoreValue.textContent = String(usedScore);
} else if (state.hasRolled) {
scoreValue.classList.add("preview");
scoreValue.textContent = String(currentScore);
} else {
scoreValue.textContent = "—";
}
row.append(textWrap, scoreValue);
if (!isUsed) {
row.appendChild(buildStrategyTooltip(category, currentScore, advice));
}
if (!isUsed && state.hasRolled && !state.isRolling) {
row.setAttribute("role", "button");
row.setAttribute("tabindex", "0");
row.setAttribute("aria-label", `Score ${category.label} for ${currentScore} points${isRecommended ? ", recommended" : ""}`);
row.addEventListener("click", () => chooseCategory(category.id));
row.addEventListener("keydown", event => {
if (event.key === "Enter" || event.key === " ") {
event.preventDefault();
chooseCategory(category.id);
}
});
}
scoreList.appendChild(row);
});
}
}
function renderSummary() {
const used = getUsedCategoryCount();
const round = Math.min(used + 1, 13);
const upper = getUpperTotal();
const total = getGrandTotal();
roundLabel.textContent = used >= 13 ? "Game Complete" : `Round ${round} of 13`;
rollsLeftEl.textContent = String(state.rollsLeft);
upperTotalEl.textContent = String(upper);
bonusValueEl.textContent = String(getBonus());
grandTotalEl.textContent = String(total);
highScoreLabel.textContent = `Best: ${getHighScore()}`;
bonusFillEl.style.width = `${Math.min(100, (upper / 63) * 100)}%`;
rollBtn.disabled = state.isRolling || state.rollsLeft <= 0 || used >= 13;
rollBtn.textContent = state.isRolling
? "Rolling..."
: state.hasRolled
? state.rollsLeft > 0
? `Roll Again (${state.rollsLeft})`
: "Choose a Score"
: "Roll Dice";
if (state.isRolling) {
statusText.textContent = "The cubes are in motion...";
} else if (!state.hasRolled) {
statusText.textContent = used === 0 ? "Lock in. Roll the dice." : "New round. Make it count.";
} else if (state.rollsLeft === 0) {
statusText.textContent = "No rolls left. Pick a category.";
} else if (state.held.some(Boolean)) {
statusText.textContent = `${state.held.filter(Boolean).length} held. Send the rest.`;
} else {
statusText.textContent = "Hold what you want. Re-roll the rest.";
}
const previews = Object.fromEntries(
CATEGORY_DEFINITIONS.map(category => [
category.id,
state.hasRolled ? calculateScore(category.id, state.dice) : null
])
);
const recommendation = state.strategyEnabled ? getRecommendation(previews) : null;
if (recommendation) {
hintText.innerHTML = `<strong style="color: var(--cyan)">Guide:</strong>&nbsp; ${recommendation.category.label} is a strong score now. Hover its row to see why.`;
} else if (state.hasRolled && state.rollsLeft > 0 && state.strategyEnabled) {
hintText.innerHTML = 'No urgent score yet. Improve the hand or follow the hover hints. <span class="kbd">Space</span> rolls, <span class="kbd">1–5</span> holds.';
} else {
hintText.innerHTML = state.hasRolled
? 'Choose a score. Hover unused rows for a short strategy explanation.'
: 'Roll, then click dice to hold them. Keyboard: <span class="kbd">Space</span> to roll, <span class="kbd">1–5</span> to hold.';
}
}
function render() {
renderDice();
renderScorecard();
renderSummary();
soundBtn.textContent = state.soundEnabled ? "🔊" : "🔇";
soundBtn.setAttribute("aria-label", state.soundEnabled ? "Mute sound" : "Enable sound");
strategyBtn.textContent = state.strategyEnabled ? "Hints: On" : "Hints: Off";
strategyBtn.setAttribute("aria-pressed", String(state.strategyEnabled));
}
function randomDie() {
return Math.floor(Math.random() * 6) + 1;
}
function toggleHold(index) {
if (!state.hasRolled || state.isRolling || state.rollsLeft === 3) {
return;
}
state.held[index] = !state.held[index];
playTone(state.held[index] ? 520 : 320, 0.06, "sine", 0.025);
render();
}
async function rollDice() {
if (state.isRolling || state.rollsLeft <= 0 || getUsedCategoryCount() >= 13) {
return;
}
state.isRolling = true;
state.hasRolled = true;
state.rollsLeft -= 1;
playRollSound();
render();
const shuffleTimer = window.setInterval(() => {
state.dice = state.dice.map((value, index) => state.held[index] ? value : randomDie());
renderDice();
}, 85);
await new Promise(resolve => window.setTimeout(resolve, 680));
window.clearInterval(shuffleTimer);
state.dice = state.dice.map((value, index) => state.held[index] ? value : randomDie());
state.isRolling = false;
render();
const yahtzeeScore = calculateScore("yahtzee", state.dice);
if (yahtzeeScore === 50 && state.scores.yahtzee === null) {
showToast("YAHTZEE detected. Fifty points are waiting.");
playFanfare();
burstParticles(window.innerWidth * 0.5, window.innerHeight * 0.45, 32);
}
}
function chooseCategory(categoryId) {
if (!state.hasRolled || state.isRolling || state.scores[categoryId] !== null) {
return;
}
const category = CATEGORY_DEFINITIONS.find(item => item.id === categoryId);
const score = calculateScore(categoryId, state.dice);
state.scores[categoryId] = score;
playScoreSound(score);
showToast(`${category.label}: ${score} point${score === 1 ? "" : "s"}.`);
if (score >= 40) {
const row = document.querySelector(`[data-category="${categoryId}"]`);
const rect = row?.getBoundingClientRect();
burstParticles(
rect ? rect.left + rect.width / 2 : window.innerWidth * 0.75,
rect ? rect.top + rect.height / 2 : window.innerHeight * 0.4,
score >= 50 ? 44 : 26
);
}
if (getUsedCategoryCount() >= 13) {
finishGame();
return;
}
state.dice = [null, null, null, null, null];
state.held = [false, false, false, false, false];
state.rollsLeft = 3;
state.hasRolled = false;
render();
}
function finishGame() {
const total = getGrandTotal();
const previousHigh = getHighScore();
let message = "Steel nerves. Solid run.";
if (total > previousHigh) {
setHighScore(total);
message = "New high score. The machine approves.";
burstParticles(window.innerWidth / 2, window.innerHeight / 2, 70);
playFanfare();
} else if (total >= 250) {
message = "That was ruthless. Strong score.";
} else if (total < 150) {
message = "The dice won this round. Rematch them.";
}
modalScore.textContent = String(total);
modalText.textContent = message;
modalBackdrop.classList.add("open");
render();
}
function resetGame() {
state.dice = [null, null, null, null, null];
state.held = [false, false, false, false, false];
state.rollsLeft = 3;
state.hasRolled = false;
state.isRolling = false;
state.scores = Object.fromEntries(CATEGORY_DEFINITIONS.map(({ id }) => [id, null]));
modalBackdrop.classList.remove("open");
render();
showToast("Fresh scorecard. No excuses.");
}
function showToast(message) {
const toast = document.createElement("div");
toast.className = "toast";
toast.textContent = message;
toastStack.appendChild(toast);
window.setTimeout(() => toast.remove(), 2900);
}
function burstParticles(x, y, count) {
const fragment = document.createDocumentFragment();
for (let index = 0; index < count; index += 1) {
const particle = document.createElement("div");
const angle = Math.random() * Math.PI * 2;
const distance = 60 + Math.random() * 180;
particle.className = "particle";
particle.style.left = `${x}px`;
particle.style.top = `${y}px`;
particle.style.setProperty("--dx", `${Math.cos(angle) * distance}px`);
particle.style.setProperty("--dy", `${Math.sin(angle) * distance}px`);
particle.style.background = Math.random() > 0.25 ? "var(--cyan)" : "var(--gold)";
particle.style.width = `${4 + Math.random() * 7}px`;
particle.style.height = particle.style.width;
fragment.appendChild(particle);
window.setTimeout(() => particle.remove(), 1000);
}
document.body.appendChild(fragment);
}
function ensureAudioContext() {
if (!state.soundEnabled) {
return null;
}
if (!audioContext) {
const AudioContextClass = window.AudioContext || window.webkitAudioContext;
if (!AudioContextClass) {
return null;
}
audioContext = new AudioContextClass();
}
if (audioContext.state === "suspended") {
audioContext.resume();
}
return audioContext;
}
function playTone(frequency, duration, wave = "sine", volume = 0.04, delay = 0) {
const context = ensureAudioContext();
if (!context) {
return;
}
const oscillator = context.createOscillator();
const gain = context.createGain();
const start = context.currentTime + delay;
const end = start + duration;
oscillator.type = wave;
oscillator.frequency.setValueAtTime(frequency, start);
gain.gain.setValueAtTime(0.0001, start);
gain.gain.exponentialRampToValueAtTime(volume, start + 0.012);
gain.gain.exponentialRampToValueAtTime(0.0001, end);
oscillator.connect(gain);
gain.connect(context.destination);
oscillator.start(start);
oscillator.stop(end + 0.03);
}
function playRollSound() {
[120, 165, 205, 145, 250].forEach((frequency, index) => {
playTone(frequency, 0.09, "square", 0.018, index * 0.055);
});
}
function playScoreSound(score) {
const base = score === 0 ? 140 : 360 + Math.min(score, 50) * 5;
playTone(base, 0.12, score === 0 ? "sawtooth" : "triangle", 0.035);
if (score >= 25) {
playTone(base * 1.25, 0.18, "sine", 0.028, 0.08);
}
}
function playFanfare() {
[523.25, 659.25, 783.99, 1046.5].forEach((frequency, index) => {
playTone(frequency, 0.24, "triangle", 0.04, index * 0.1);
});
}
rollBtn.addEventListener("click", rollDice);
strategyBtn.addEventListener("click", () => {
state.strategyEnabled = !state.strategyEnabled;
localStorage.setItem("neon-yahtzee-strategy", state.strategyEnabled ? "on" : "off");
render();
showToast(state.strategyEnabled
? "Strategy hints enabled. Hover unused score rows."
: "Strategy hints disabled.");
});
newGameBtn.addEventListener("click", resetGame);
playAgainBtn.addEventListener("click", resetGame);
soundBtn.addEventListener("click", () => {
state.soundEnabled = !state.soundEnabled;
render();
if (state.soundEnabled) {
playTone(660, 0.08, "sine", 0.03);
}
});
document.addEventListener("keydown", event => {
const target = event.target;
if (target instanceof HTMLInputElement || target instanceof HTMLTextAreaElement) {
return;
}
if (event.code === "Space") {
event.preventDefault();
rollDice();
return;
}
const index = Number(event.key) - 1;
if (index >= 0 && index < 5) {
toggleHold(index);
}
});
render();
</script>
</body>
</html>
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment