styles.css
Responsive app styling
:root {
color-scheme: dark;
--cabinet: #07124a;
--cabinet-2: #113e96;
--screen: #061833;
--screen-2: #082b58;
--pay-red: #ba1727;
--pay-blue: #132d82;
--gold: #ffd34f;
--gold-2: #ad7b18;
--white: #fff7db;
--ink: #f9f2cc;
--muted: #b9c6e8;
--line: rgba(255, 232, 116, .42);
--red: #d7192f;
--black-card: #111111;
--paper: #fffaf0;
--button-red: #b91521;
--button-blue: #0b64b6;
}
* {
box-sizing: border-box;
}
html,
body {
width: 100%;
height: 100%;
overflow: hidden;
}
body {
margin: 0;
font-family: "Arial Black", Impact, "Segoe UI", system-ui, sans-serif;
color: var(--ink);
background:
linear-gradient(90deg, rgba(255, 255, 255, .04) 1px, transparent 1px),
radial-gradient(circle at 50% 0%, rgba(255, 211, 79, .22), transparent 30rem),
linear-gradient(160deg, #050815, #0a1640 48%, #040610);
background-size: 28px 28px, auto, auto;
-webkit-tap-highlight-color: transparent;
touch-action: manipulation;
}
button,
select {
font: inherit;
}
button {
cursor: pointer;
}
button:disabled,
select:disabled {
cursor: not-allowed;
opacity: .58;
}
h1,
h2,
p {
margin: 0;
}
.app {
width: 100vw;
height: 100vh;
height: 100svh;
max-height: 100vh;
max-height: 100svh;
display: grid;
place-items: center;
overflow: hidden;
padding: clamp(4px, 1.5vmin, 14px);
}
.machine {
width: min(1280px, 100%);
height: 100%;
min-height: 0;
display: grid;
grid-template-rows: clamp(62px, 12svh, 112px) clamp(38px, 6.5svh, 58px) minmax(0, 1fr) clamp(60px, 10.5svh, 88px);
gap: clamp(4px, .8vmin, 10px);
padding: clamp(6px, 1.2vmin, 14px);
border: clamp(3px, .7vmin, 8px) solid var(--gold-2);
border-radius: 8px;
background:
linear-gradient(90deg, rgba(255, 255, 255, .13), transparent 7%, transparent 93%, rgba(255, 255, 255, .11)),
linear-gradient(155deg, var(--cabinet), var(--cabinet-2) 54%, #050929);
box-shadow:
inset 0 0 0 2px rgba(255, 245, 181, .36),
inset 0 0 38px rgba(0, 0, 0, .55),
0 16px 60px rgba(0, 0, 0, .45);
}
.machine > * {
min-height: 0;
}
.marquee {
min-height: 0;
display: grid;
grid-template-columns: minmax(0, 1fr) minmax(270px, .72fr);
gap: clamp(8px, 1.5vmin, 18px);
align-items: center;
padding: clamp(8px, 1.4vmin, 16px);
border: 2px solid var(--line);
border-radius: 7px;
background:
repeating-linear-gradient(90deg, rgba(255, 211, 79, .13) 0 2px, transparent 2px 12px),
linear-gradient(180deg, #1c48a3, #07195c 58%, #020a34);
}
.eyebrow {
color: #ffef9a;
font-family: "Segoe UI", system-ui, sans-serif;
font-size: clamp(.58rem, 1.35vmin, .82rem);
font-weight: 900;
letter-spacing: 0;
text-transform: uppercase;
}
h1 {
color: var(--gold);
font-size: clamp(1.35rem, 5.1vmin, 4.15rem);
line-height: .86;
letter-spacing: 0;
text-shadow:
0 2px 0 #801318,
0 4px 0 #2b0815,
0 0 18px rgba(255, 211, 79, .55);
text-transform: uppercase;
}
.meters {
display: grid;
grid-template-columns: repeat(3, minmax(0, 1fr));
gap: clamp(4px, .8vmin, 8px);
}
.meters div {
min-width: 0;
padding: clamp(6px, 1vmin, 12px);
border: 2px solid rgba(255, 211, 79, .76);
border-radius: 5px;
color: #111;
background: linear-gradient(180deg, #fff0a4, #e1a726 50%, #7b4204);
box-shadow: inset 0 1px rgba(255, 255, 255, .75);
text-align: center;
}
.meters span,
label,
.section-heading span,
.message {
font-family: "Segoe UI", system-ui, sans-serif;
}
.meters span {
display: block;
color: #301a00;
font-size: clamp(.56rem, 1.2vmin, .74rem);
font-weight: 900;
text-transform: uppercase;
}
.meters strong {
display: block;
margin-top: 1px;
color: #101010;
font-size: clamp(1rem, 2.5vmin, 1.65rem);
line-height: 1;
}
.pay-glass {
min-height: 0;
display: grid;
grid-template-columns: auto minmax(0, 1fr);
align-items: stretch;
overflow: hidden;
border: 2px solid var(--line);
border-radius: 6px;
background: linear-gradient(180deg, #fbe476, #b2182a 13%, #560813 100%);
box-shadow: inset 0 0 20px rgba(255, 255, 255, .16);
}
.pay-title {
display: grid;
place-items: center;
padding: 0 clamp(8px, 1.2vmin, 16px);
color: var(--white);
background: linear-gradient(180deg, #2f65c6, #07165c);
font-size: clamp(.64rem, 1.4vmin, .86rem);
text-align: center;
text-transform: uppercase;
text-shadow: 0 2px #05081b;
}
.paytable-strip {
display: grid;
grid-template-columns: repeat(9, minmax(0, 1fr));
}
.paytable-strip div {
min-width: 0;
display: grid;
place-items: center;
gap: 1px;
padding: 2px 3px;
border-left: 1px solid rgba(255, 255, 255, .38);
text-align: center;
}
.paytable-strip span {
color: #fff7d5;
font-size: clamp(.45rem, 1vmin, .66rem);
line-height: 1;
text-transform: uppercase;
}
.paytable-strip strong {
color: var(--gold);
font-size: clamp(.72rem, 1.8vmin, 1.08rem);
line-height: 1;
text-shadow: 0 2px #3c050b;
}
.screen {
min-height: 0;
display: grid;
grid-template-rows: auto minmax(72px, 1fr) minmax(0, 1.55fr);
overflow: hidden;
border: 2px solid rgba(140, 205, 255, .52);
border-radius: 7px;
background:
radial-gradient(circle at 50% 0%, rgba(64, 170, 255, .24), transparent 30%),
linear-gradient(180deg, var(--screen-2), var(--screen) 48%, #020817);
box-shadow: inset 0 0 0 2px rgba(0, 0, 0, .38), inset 0 0 46px rgba(0, 0, 0, .62);
}
.status-row {
min-height: 0;
display: grid;
grid-template-columns: minmax(150px, .45fr) minmax(0, 1fr);
gap: clamp(6px, 1.2vmin, 14px);
align-items: center;
padding: clamp(6px, 1vmin, 12px);
border-bottom: 1px solid rgba(140, 205, 255, .28);
}
.state-label {
color: var(--gold);
font-size: clamp(.72rem, 1.6vmin, 1rem);
line-height: 1;
text-transform: uppercase;
text-shadow: 0 2px #21060a;
}
.message {
margin-top: 2px;
color: var(--muted);
font-size: clamp(.66rem, 1.45vmin, .95rem);
font-weight: 700;
line-height: 1.16;
}
.multiplier-badge {
min-width: 0;
height: clamp(38px, 6.4vmin, 54px);
display: grid;
place-items: center;
padding: clamp(6px, 1.1vmin, 11px);
border: 2px solid rgba(255, 211, 79, .72);
border-radius: 5px;
color: var(--white);
background: linear-gradient(180deg, #113fad, #07185f);
font-size: clamp(.72rem, 1.95vmin, 1.2rem);
line-height: 1;
text-align: center;
text-transform: uppercase;
text-shadow: 0 2px #060816;
overflow: hidden;
}
.multiplier-badge.eligible {
color: #fff6b4;
}
.multiplier-badge.hit {
color: #1a0b00;
background: linear-gradient(180deg, #fff6a5, var(--gold) 45%, #c37b0c);
text-shadow: 0 1px rgba(255, 255, 255, .45);
animation: pulse-win .7s ease-in-out infinite alternate;
}
.multiplier-badge.spinning {
position: relative;
gap: 1px;
padding-block: clamp(3px, .65vmin, 6px);
color: #fff9b8;
background:
linear-gradient(90deg, transparent, rgba(255, 255, 255, .28), transparent),
repeating-linear-gradient(90deg, rgba(255, 255, 255, .12) 0 2px, transparent 2px 9px),
linear-gradient(180deg, #092c96, #07155c 60%, #02071f);
background-size: 70px 100%, auto, auto;
animation: multiplier-scan .34s linear infinite;
box-shadow:
inset 0 0 0 2px rgba(255, 255, 255, .13),
0 0 18px rgba(255, 211, 79, .32);
}
.multiplier-badge.spinning strong {
color: var(--gold);
font-size: clamp(.9rem, 2.3vmin, 1.35rem);
line-height: .9;
text-shadow:
0 2px #52040b,
0 0 12px rgba(255, 211, 79, .75);
transform-origin: center;
animation: multiplier-pop .18s ease-out infinite alternate;
}
.reveal-label,
.reveal-dots {
font-family: "Segoe UI", system-ui, sans-serif;
font-size: clamp(.4rem, .82vmin, .58rem);
font-weight: 1000;
line-height: 1;
}
.reveal-dots {
display: flex;
gap: 4px;
}
.reveal-dots i {
width: clamp(3px, .65vmin, 5px);
height: clamp(3px, .65vmin, 5px);
border-radius: 50%;
background: var(--gold);
box-shadow: 0 0 8px rgba(255, 211, 79, .8);
animation: reveal-dot .45s ease-in-out infinite alternate;
}
.reveal-dots i:nth-child(2) {
animation-delay: .12s;
}
.reveal-dots i:nth-child(3) {
animation-delay: .24s;
}
.multiplier-badge.miss,
.multiplier-badge.idle {
color: #cdd9ff;
}
.main-hand {
min-height: 0;
display: grid;
align-items: center;
padding: clamp(8px, 1.5vmin, 18px);
}
.card-row {
display: grid;
grid-template-columns: repeat(5, minmax(44px, min(11.5vw, 15.5svh, 150px)));
justify-content: center;
gap: clamp(5px, 1.45vmin, 16px);
}
.card {
position: relative;
min-width: 0;
aspect-ratio: 5 / 7;
border: 3px solid #f4df9a;
border-radius: 7px;
color: var(--black-card);
background:
radial-gradient(circle at 50% 46%, rgba(255, 255, 255, .9), transparent 28%),
linear-gradient(135deg, #ffffff, var(--paper));
box-shadow: 0 10px 18px rgba(0, 0, 0, .36), inset 0 0 0 2px rgba(0, 0, 0, .08);
}
.card.back {
background:
linear-gradient(45deg, rgba(255, 255, 255, .15) 25%, transparent 25%, transparent 50%, rgba(255, 255, 255, .15) 50%, rgba(255, 255, 255, .15) 75%, transparent 75%),
radial-gradient(circle, #3176e0, #0b277a);
background-size: 18px 18px, auto;
}
.card.red {
color: var(--red);
}
.card.held {
transform: translateY(-7%);
border-color: var(--gold);
box-shadow: 0 0 0 3px #ff5d2e, 0 14px 24px rgba(0, 0, 0, .44);
}
.card.advised:not(.held) {
border-color: #ffe77a;
box-shadow: 0 0 0 2px rgba(255, 211, 79, .75), 0 10px 18px rgba(0, 0, 0, .36), inset 0 0 0 2px rgba(0, 0, 0, .08);
}
.rank {
position: absolute;
top: 7%;
left: 8%;
font-size: clamp(1rem, 3.2vmin, 2rem);
line-height: 1;
}
.rank.bottom {
inset: auto 8% 7% auto;
transform: rotate(180deg);
}
.suit {
display: grid;
place-items: center;
height: 100%;
font-size: clamp(2.5rem, 8.5vmin, 5.4rem);
line-height: 1;
}
.card em {
position: absolute;
left: 50%;
bottom: -10px;
transform: translateX(-50%);
padding: 3px 9px;
border: 2px solid #8d2000;
border-radius: 4px;
color: #230c00;
background: linear-gradient(180deg, #fff4a5, var(--gold));
font-family: "Segoe UI", system-ui, sans-serif;
font-size: clamp(.54rem, 1.25vmin, .78rem);
font-style: normal;
font-weight: 1000;
line-height: 1;
text-transform: uppercase;
}
.advice-star {
position: absolute;
top: 3%;
right: 8%;
width: clamp(1.05rem, 3.1vmin, 1.85rem);
height: clamp(1.05rem, 3.1vmin, 1.85rem);
display: grid;
place-items: center;
border: 2px solid #8d2000;
border-radius: 50%;
color: #230c00;
background: linear-gradient(180deg, #fff8b8, var(--gold));
font-family: "Segoe UI", system-ui, sans-serif;
font-size: clamp(.9rem, 2.6vmin, 1.55rem);
font-weight: 1000;
line-height: 1;
box-shadow: 0 2px 6px rgba(0, 0, 0, .3);
}
.results-panel {
min-height: 0;
display: grid;
grid-template-rows: auto minmax(0, 1fr);
padding: 0 clamp(6px, 1.2vmin, 12px) clamp(6px, 1.2vmin, 12px);
}
.section-heading {
display: flex;
justify-content: space-between;
gap: 10px;
align-items: center;
min-height: 0;
padding-bottom: clamp(4px, .8vmin, 8px);
}
.section-heading h2 {
color: var(--gold);
font-size: clamp(.74rem, 1.65vmin, 1.05rem);
line-height: 1;
text-transform: uppercase;
}
.section-heading span {
color: var(--muted);
font-size: clamp(.62rem, 1.35vmin, .86rem);
font-weight: 800;
}
.hands-grid {
min-height: 0;
display: grid;
grid-template-columns: repeat(5, minmax(0, 1fr));
gap: clamp(4px, .9vmin, 8px);
}
.mini-hand {
min-width: 0;
min-height: 0;
display: grid;
grid-template-rows: auto minmax(0, 1fr) auto;
padding: clamp(4px, .8vmin, 8px);
border: 1px solid rgba(140, 205, 255, .36);
border-radius: 5px;
background: rgba(0, 0, 0, .22);
}
.mini-hand.winner {
border-color: rgba(255, 211, 79, .9);
background: rgba(255, 211, 79, .16);
}
.mini-hand-top {
display: flex;
justify-content: space-between;
gap: 5px;
color: var(--muted);
font-family: "Segoe UI", system-ui, sans-serif;
font-size: clamp(.5rem, 1.1vmin, .72rem);
font-weight: 800;
line-height: 1;
}
.mini-hand-top strong {
color: var(--gold);
}
.mini-card-row {
min-height: 0;
display: grid;
grid-template-columns: repeat(5, 1fr);
gap: 2px;
margin: 4px 0;
}
.mini-card-row span,
.mini-card-row i {
display: grid;
place-items: center;
min-height: 18px;
border-radius: 3px;
color: var(--black-card);
background: var(--paper);
font-family: "Segoe UI", system-ui, sans-serif;
font-size: clamp(.46rem, 1.05vmin, .68rem);
font-style: normal;
font-weight: 1000;
line-height: 1;
}
.mini-card-row small {
font-size: .72em;
}
.mini-card-row .red {
color: var(--red);
}
.mini-card-row i {
background: rgba(180, 212, 255, .18);
}
.mini-hand p {
min-height: 1em;
overflow: hidden;
color: var(--ink);
font-family: "Segoe UI", system-ui, sans-serif;
font-size: clamp(.48rem, 1.08vmin, .72rem);
font-weight: 800;
line-height: 1.05;
text-overflow: ellipsis;
white-space: nowrap;
}
.controls {
min-height: 0;
display: grid;
grid-template-columns: minmax(116px, 1.25fr) repeat(2, minmax(70px, .65fr)) minmax(112px, 1fr) repeat(2, minmax(78px, .72fr));
grid-auto-rows: minmax(0, 1fr);
gap: clamp(5px, 1vmin, 10px);
align-items: stretch;
overflow: hidden;
padding: clamp(6px, 1vmin, 12px);
border: 2px solid var(--line);
border-radius: 7px;
background: linear-gradient(180deg, #1d2c6d, #060b2b);
}
label {
min-width: 0;
display: grid;
gap: 3px;
color: #e8efff;
font-size: clamp(.54rem, 1.15vmin, .72rem);
font-weight: 900;
line-height: 1;
text-transform: uppercase;
}
select,
.secondary-button,
.primary-button {
min-width: 0;
min-height: 0;
border: 2px solid rgba(255, 255, 255, .44);
border-radius: 6px;
color: #fff;
box-shadow: inset 0 1px rgba(255, 255, 255, .32), 0 4px 0 rgba(0, 0, 0, .42);
text-transform: uppercase;
}
select {
width: 100%;
height: 100%;
padding: 0 28px 0 8px;
background: linear-gradient(180deg, #2c70d4, #093b83);
}
.primary-button {
padding: 0 10px;
color: var(--white);
background: linear-gradient(180deg, #ff4758, var(--button-red) 62%, #710811);
font-size: clamp(.92rem, 2.6vmin, 1.45rem);
text-shadow: 0 2px #3b0308;
}
.secondary-button {
padding: 0 8px;
background: linear-gradient(180deg, #2990f0, var(--button-blue) 58%, #063c75);
font-size: clamp(.68rem, 1.55vmin, .92rem);
}
.toggle {
min-height: 0;
display: flex;
flex-direction: row;
align-items: center;
gap: 7px;
padding: 0 8px;
border: 2px solid rgba(255, 255, 255, .44);
border-radius: 6px;
background: linear-gradient(180deg, #1f67bc, #082f75);
box-shadow: inset 0 1px rgba(255, 255, 255, .28), 0 4px 0 rgba(0, 0, 0, .42);
}
.toggle input {
flex: 0 0 auto;
width: clamp(14px, 2.3vmin, 20px);
height: clamp(14px, 2.3vmin, 20px);
accent-color: var(--gold);
}
.toggle span {
min-width: 0;
overflow: hidden;
text-overflow: ellipsis;
white-space: nowrap;
}
dialog {
width: min(420px, calc(100% - 28px));
border: 2px solid var(--line);
border-radius: 8px;
color: var(--ink);
background: linear-gradient(180deg, #113fad, #07103e);
padding: 0;
}
dialog::backdrop {
background: rgba(0, 0, 0, .72);
}
.dialog-header {
display: flex;
justify-content: space-between;
align-items: center;
padding: 14px;
border-bottom: 1px solid var(--line);
}
.dialog-header h2 {
color: var(--gold);
font-size: 1.1rem;
text-transform: uppercase;
}
.dialog-header button {
width: 38px;
height: 38px;
border: 1px solid var(--line);
border-radius: 6px;
color: var(--ink);
background: rgba(255, 255, 255, .08);
font-size: 1.4rem;
}
.paytable-list {
padding: 10px 16px 16px;
}
.paytable-list div {
display: flex;
justify-content: space-between;
gap: 18px;
padding: 9px 0;
border-bottom: 1px solid var(--line);
font-family: "Segoe UI", system-ui, sans-serif;
font-weight: 800;
}
.paytable-list div:last-child {
border-bottom: 0;
}
.paytable-list strong {
color: var(--gold);
}
@keyframes pulse-win {
from {
filter: brightness(1);
}
to {
filter: brightness(1.22);
}
}
@keyframes multiplier-scan {
from {
background-position: -70px 0, 0 0, 0 0;
}
to {
background-position: 70px 0, 0 0, 0 0;
}
}
@keyframes multiplier-pop {
from {
transform: scale(.94);
}
to {
transform: scale(1.03);
}
}
@keyframes reveal-dot {
from {
opacity: .25;
transform: translateY(1px);
}
to {
opacity: 1;
transform: translateY(-1px);
}
}
@media (orientation: portrait) {
.machine {
grid-template-rows: clamp(72px, 16svh, 128px) clamp(34px, 6.5svh, 48px) minmax(0, 1fr) clamp(92px, 18svh, 118px);
}
.marquee {
grid-template-columns: 1fr;
gap: 5px;
padding: 7px;
}
h1 {
font-size: clamp(1.3rem, 7.2vw, 2.7rem);
}
.meters div {
padding: 5px;
}
.pay-glass {
grid-template-columns: 1fr;
}
.pay-title {
display: none;
}
.paytable-strip {
grid-template-columns: repeat(5, minmax(0, 1fr));
}
.paytable-strip div:nth-child(n+6) {
display: none;
}
.screen {
grid-template-rows: auto minmax(88px, 1fr) minmax(0, 2.3fr);
}
.status-row {
grid-template-columns: 1fr;
gap: 4px;
}
.main-hand {
padding: 8px 6px 10px;
}
.card-row {
grid-template-columns: repeat(5, minmax(0, min(18vw, 11svh)));
gap: 4px;
}
.hands-grid {
grid-template-columns: repeat(2, minmax(0, 1fr));
}
.controls {
grid-template-columns: repeat(3, minmax(0, 1fr));
}
}
@media (max-width: 640px) {
.app {
padding: 3px;
}
.machine {
padding: 5px;
border-width: 3px;
gap: 4px;
}
.brand {
text-align: center;
}
.message {
display: -webkit-box;
overflow: hidden;
-webkit-box-orient: vertical;
-webkit-line-clamp: 2;
}
.card {
border-width: 2px;
border-radius: 5px;
}
.card em {
bottom: -8px;
padding: 2px 5px;
}
.results-panel {
padding-inline: 5px;
padding-bottom: 5px;
}
.mini-hand {
padding: 4px;
}
.mini-card-row {
gap: 1px;
margin: 3px 0;
}
.controls {
padding: 5px;
gap: 4px;
}
.toggle {
padding: 0 5px;
}
}
@media (max-height: 560px) {
.machine {
grid-template-rows: clamp(44px, 15svh, 70px) clamp(30px, 8svh, 38px) minmax(0, 1fr) clamp(46px, 13svh, 58px);
}
.eyebrow,
.message,
.mini-hand p {
display: none;
}
.screen {
grid-template-rows: auto minmax(58px, .8fr) minmax(0, 1.4fr);
}
.main-hand {
padding-block: 5px 8px;
}
.card-row {
grid-template-columns: repeat(5, minmax(34px, min(9vw, 9.5svh)));
gap: 5px;
}
.controls label {
gap: 1px;
}
.controls {
grid-template-columns: minmax(94px, 1.25fr) repeat(5, minmax(54px, 1fr));
}
}
engine.js
Poker rules and hand evaluation
const RANKS = ["2", "3", "4", "5", "6", "7", "8", "9", "10", "J", "Q", "K", "A"];
const SUITS = ["spades", "hearts", "diamonds", "clubs"];
const SUIT_SYMBOLS = {
spades: "\u2660",
hearts: "\u2665",
diamonds: "\u2666",
clubs: "\u2663",
};
const PAYTABLE = {
"Royal Flush": 800,
"Straight Flush": 50,
"Four of a Kind": 25,
"Full House": 9,
Flush: 6,
Straight: 4,
"Three of a Kind": 3,
"Two Pair": 2,
"Jacks or Better": 1,
"No Win": 0,
};
const MULTIPLIERS = [2, 3, 4, 5, 8, 10];
const MULTIPLIER_TRIGGER_CHANCE = 0.1;
function createDeck() {
return SUITS.flatMap((suit) =>
RANKS.map((rank, index) => ({
rank,
suit,
value: index + 2,
id: `${rank}-${suit}`,
symbol: SUIT_SYMBOLS[suit],
})),
);
}
function shuffle(cards, random = Math.random) {
const deck = [...cards];
for (let i = deck.length - 1; i > 0; i -= 1) {
const j = Math.floor(random() * (i + 1));
[deck[i], deck[j]] = [deck[j], deck[i]];
}
return deck;
}
function dealBaseHand(random = Math.random) {
const deck = shuffle(createDeck(), random);
return {
hand: deck.slice(0, 5),
deck: deck.slice(5),
};
}
function drawFinalHands(baseHand, heldIndexes, handCount, random = Math.random) {
const held = baseHand.filter((_, index) => heldIndexes.includes(index));
const unavailable = new Set(baseHand.map((card) => card.id));
return Array.from({ length: handCount }, (_, handIndex) => {
const drawPool = shuffle(
createDeck().filter((card) => !unavailable.has(card.id)),
random,
);
const needed = 5 - held.length;
const drawn = drawPool.slice(0, needed);
const cards = mergeHeldAndDrawn(baseHand, heldIndexes, drawn);
return {
id: handIndex + 1,
cards,
drawn,
};
});
}
function mergeHeldAndDrawn(baseHand, heldIndexes, drawn) {
let drawIndex = 0;
return baseHand.map((card, index) => {
if (heldIndexes.includes(index)) return card;
const nextCard = drawn[drawIndex];
drawIndex += 1;
return nextCard;
});
}
function evaluateHand(cards) {
const values = cards.map((card) => card.value).sort((a, b) => a - b);
const suits = cards.map((card) => card.suit);
const counts = countBy(values);
const countValues = Object.values(counts).sort((a, b) => b - a);
const pairs = Object.entries(counts)
.filter(([, count]) => count === 2)
.map(([value]) => Number(value));
const isFlush = suits.every((suit) => suit === suits[0]);
const isStraight = detectStraight(values);
const isRoyal = JSON.stringify(values) === JSON.stringify([10, 11, 12, 13, 14]);
let name = "No Win";
if (isFlush && isRoyal) name = "Royal Flush";
else if (isFlush && isStraight) name = "Straight Flush";
else if (countValues[0] === 4) name = "Four of a Kind";
else if (countValues[0] === 3 && countValues[1] === 2) name = "Full House";
else if (isFlush) name = "Flush";
else if (isStraight) name = "Straight";
else if (countValues[0] === 3) name = "Three of a Kind";
else if (pairs.length === 2) name = "Two Pair";
else if (pairs.some((value) => value >= 11 || value === 14)) name = "Jacks or Better";
return {
handName: name,
basePayout: PAYTABLE[name],
isWinner: PAYTABLE[name] > 0,
};
}
function recommendHoldIndexes(cards) {
if (!cards.length) return [];
const evaluation = evaluateHand(cards);
const byValue = groupIndexes(cards, (card) => card.value);
const bySuit = groupIndexes(cards, (card) => card.suit);
const pairs = Object.entries(byValue)
.filter(([, indexes]) => indexes.length === 2)
.map(([value, indexes]) => ({ value: Number(value), indexes }));
const three = Object.values(byValue).find((indexes) => indexes.length === 3);
const four = Object.values(byValue).find((indexes) => indexes.length === 4);
if (["Royal Flush", "Straight Flush", "Full House", "Flush", "Straight"].includes(evaluation.handName)) {
return [0, 1, 2, 3, 4];
}
if (four) return four;
if (three) return three;
if (pairs.length === 2) return pairs.flatMap((pair) => pair.indexes).sort((a, b) => a - b);
if (pairs.length === 1) return pairs[0].indexes;
const royalDraw = bestRoyalDraw(cards);
if (royalDraw.length >= 4) return royalDraw;
const straightFlushDraw = bestStraightDraw(cards, true);
if (straightFlushDraw.length >= 4) return straightFlushDraw;
const flushDraw = Object.values(bySuit)
.filter((indexes) => indexes.length >= 4)
.sort((a, b) => b.length - a.length)[0];
if (flushDraw) return flushDraw;
const straightDraw = bestStraightDraw(cards, false);
if (straightDraw.length >= 4) return straightDraw;
if (royalDraw.length >= 3) return royalDraw;
return cards
.map((card, index) => ({ card, index }))
.filter(({ card }) => card.value >= 11 || card.value === 14)
.sort((a, b) => b.card.value - a.card.value)
.slice(0, 2)
.map(({ index }) => index)
.sort((a, b) => a - b);
}
function groupIndexes(items, keyFn) {
return items.reduce((groups, item, index) => {
const key = keyFn(item);
groups[key] = groups[key] || [];
groups[key].push(index);
return groups;
}, {});
}
function bestRoyalDraw(cards) {
const royalValues = new Set([10, 11, 12, 13, 14]);
return Object.values(groupIndexes(cards, (card) => card.suit))
.map((indexes) => indexes.filter((index) => royalValues.has(cards[index].value)))
.sort((a, b) => b.length - a.length)[0] || [];
}
function bestStraightDraw(cards, requireSameSuit) {
const wheelsHigh = cards.map((card) => (card.value === 14 ? { ...card, value: 1 } : card));
const candidates = straightWindows()
.flatMap((windowValues) => {
const sourceHands = requireSameSuit
? Object.values(groupIndexes(cards, (card) => card.suit))
: [[0, 1, 2, 3, 4]];
return sourceHands.map((indexes) => {
const matchesByValue = indexes.reduce((matches, index) => {
const values = [cards[index].value, wheelsHigh[index].value];
const matchValue = values.find((value) => windowValues.includes(value));
if (matchValue && !matches.has(matchValue)) matches.set(matchValue, index);
return matches;
}, new Map());
return [...matchesByValue.values()];
});
})
.filter((indexes) => indexes.length >= 3)
.sort((a, b) => b.length - a.length);
return candidates[0] || [];
}
function straightWindows() {
return [
[1, 2, 3, 4, 5],
[2, 3, 4, 5, 6],
[3, 4, 5, 6, 7],
[4, 5, 6, 7, 8],
[5, 6, 7, 8, 9],
[6, 7, 8, 9, 10],
[7, 8, 9, 10, 11],
[8, 9, 10, 11, 12],
[9, 10, 11, 12, 13],
[10, 11, 12, 13, 14],
];
}
function countBy(items) {
return items.reduce((counts, item) => {
counts[item] = (counts[item] || 0) + 1;
return counts;
}, {});
}
function detectStraight(sortedValues) {
const unique = [...new Set(sortedValues)];
if (unique.length !== 5) return false;
if (JSON.stringify(unique) === JSON.stringify([2, 3, 4, 5, 14])) return true;
return unique.every((value, index) => index === 0 || value === unique[index - 1] + 1);
}
function rollMultiplier(isActive, random = Math.random) {
if (!isActive || random() >= MULTIPLIER_TRIGGER_CHANCE) {
return {
active: false,
value: 1,
};
}
return {
active: true,
value: MULTIPLIERS[Math.floor(random() * MULTIPLIERS.length)],
};
}
function scoreHands(finalHands, betPerHand, multiplier) {
const results = finalHands.map((hand) => {
const evaluation = evaluateHand(hand.cards);
const baseWin = betPerHand * evaluation.basePayout;
return {
...hand,
...evaluation,
baseWin,
finalWin: baseWin * multiplier.value,
};
});
return {
results,
baseTotal: results.reduce((sum, result) => sum + result.baseWin, 0),
finalTotal: results.reduce((sum, result) => sum + result.finalWin, 0),
};
}
function calculateTotalBet({ handCount, betPerHand, multiplierEnabled }) {
return handCount * betPerHand + (multiplierEnabled ? handCount : 0);
}
const PokerEngine = {
RANKS,
SUITS,
SUIT_SYMBOLS,
PAYTABLE,
MULTIPLIERS,
createDeck,
shuffle,
dealBaseHand,
drawFinalHands,
evaluateHand,
recommendHoldIndexes,
rollMultiplier,
scoreHands,
calculateTotalBet,
};
if (typeof window !== "undefined") {
window.PokerEngine = PokerEngine;
}
app.js
Interface state and gameplay controls
(function () {
const Engine = window.PokerEngine;
const state = {
phase: "idle",
credits: 1000,
lastWin: 0,
baseHand: [],
heldIndexes: [],
finalResults: [],
multiplier: { active: false, value: 1 },
multiplierReveal: {
spinning: false,
display: "",
timer: null,
},
};
const els = {
credits: document.querySelector("#credits"),
currentBet: document.querySelector("#currentBet"),
lastWin: document.querySelector("#lastWin"),
stateLabel: document.querySelector("#stateLabel"),
message: document.querySelector("#message"),
multiplierBadge: document.querySelector("#multiplierBadge"),
baseHand: document.querySelector("#baseHand"),
finalHands: document.querySelector("#finalHands"),
resultSummary: document.querySelector("#resultSummary"),
paytableStrip: document.querySelector("#paytableStrip"),
dealDrawButton: document.querySelector("#dealDrawButton"),
betPerHand: document.querySelector("#betPerHand"),
handCount: document.querySelector("#handCount"),
paytableButton: document.querySelector("#paytableButton"),
resetButton: document.querySelector("#resetButton"),
paytableDialog: document.querySelector("#paytableDialog"),
closePaytable: document.querySelector("#closePaytable"),
paytableList: document.querySelector("#paytableList"),
};
const sound = {
ctx: null,
enabled: true,
};
function getAudioContext() {
if (!sound.enabled) return null;
if (!sound.ctx) {
const AudioContext = window.AudioContext || window.webkitAudioContext;
if (!AudioContext) {
sound.enabled = false;
return null;
}
sound.ctx = new AudioContext();
}
if (sound.ctx.state === "suspended") sound.ctx.resume();
return sound.ctx;
}
function playTone(frequency, duration, type = "square", gainValue = 0.06, delay = 0) {
const ctx = getAudioContext();
if (!ctx) return;
const start = ctx.currentTime + delay;
const osc = ctx.createOscillator();
const gain = ctx.createGain();
osc.type = type;
osc.frequency.setValueAtTime(frequency, start);
gain.gain.setValueAtTime(0.0001, start);
gain.gain.exponentialRampToValueAtTime(gainValue, start + 0.012);
gain.gain.exponentialRampToValueAtTime(0.0001, start + duration);
osc.connect(gain);
gain.connect(ctx.destination);
osc.start(start);
osc.stop(start + duration + 0.02);
}
function playSound(name) {
if (name === "deal") {
[220, 260, 300, 340, 380].forEach((freq, index) => playTone(freq, 0.045, "square", 0.045, index * 0.035));
} else if (name === "hold") {
playTone(520, 0.055, "triangle", 0.05);
} else if (name === "draw") {
[380, 330, 450, 410, 560].forEach((freq, index) => playTone(freq, 0.038, "sawtooth", 0.04, index * 0.028));
} else if (name === "win") {
[523, 659, 784, 1046].forEach((freq, index) => playTone(freq, 0.11, "triangle", 0.07, index * 0.08));
} else if (name === "bigWin") {
[392, 523, 659, 784, 1046, 1318].forEach((freq, index) => playTone(freq, 0.12, "triangle", 0.075, index * 0.07));
} else if (name === "multiplierTick") {
playTone(740, 0.035, "square", 0.035);
} else if (name === "multiplierHit") {
[440, 660, 880, 1320].forEach((freq, index) => playTone(freq, 0.105, "triangle", 0.075, index * 0.075));
} else if (name === "multiplierMiss") {
[260, 210].forEach((freq, index) => playTone(freq, 0.09, "sawtooth", 0.045, index * 0.075));
} else {
playTone(180, 0.04, "square", 0.035);
}
}
function clearMultiplierReveal() {
if (state.multiplierReveal.timer) {
window.clearTimeout(state.multiplierReveal.timer);
}
state.multiplierReveal.timer = null;
state.multiplierReveal.spinning = false;
state.multiplierReveal.display = "";
}
function getSettings() {
return {
betPerHand: Number(els.betPerHand.value),
handCount: Number(els.handCount.value),
multiplierEnabled: true,
};
}
function deal() {
const settings = getSettings();
const totalBet = Engine.calculateTotalBet(settings);
if (state.credits < totalBet) {
state.message = "Not enough credits for that bet. Lower the settings or reset credits.";
render();
return;
}
playSound("deal");
const dealResult = Engine.dealBaseHand();
state.phase = "dealt";
state.credits -= totalBet;
state.lastWin = 0;
state.baseHand = dealResult.hand;
state.heldIndexes = [];
state.finalResults = [];
state.multiplier = Engine.rollMultiplier(settings.multiplierEnabled);
state.message = "Video Poker is choosing a multiplier...";
render();
startMultiplierReveal(settings.multiplierEnabled);
}
function draw() {
if (state.multiplierReveal.spinning) return;
const settings = getSettings();
playSound("draw");
const finalHands = Engine.drawFinalHands(state.baseHand, state.heldIndexes, settings.handCount);
const scored = Engine.scoreHands(finalHands, settings.betPerHand, state.multiplier);
state.phase = "result";
state.finalResults = scored.results;
state.lastWin = scored.finalTotal;
state.credits += scored.finalTotal;
state.message = buildResultMessage(scored);
render();
if (scored.finalTotal > 0) {
playSound(scored.finalTotal >= settings.betPerHand * settings.handCount * 10 ? "bigWin" : "win");
}
}
function buildResultMessage(scored) {
if (scored.finalTotal === 0) return "No winning hands this round.";
if (state.multiplier.active && scored.baseTotal > 0) {
return `${scored.baseTotal} credits became ${scored.finalTotal} with a ${state.multiplier.value}x multiplier.`;
}
return `${scored.finalTotal} credits won.`;
}
function toggleHold(index) {
if (state.phase !== "dealt" || state.multiplierReveal.spinning) return;
if (state.heldIndexes.includes(index)) {
state.heldIndexes = state.heldIndexes.filter((heldIndex) => heldIndex !== index);
} else {
state.heldIndexes = [...state.heldIndexes, index].sort((a, b) => a - b);
}
playSound("hold");
renderBaseHand();
}
function resetGame() {
clearMultiplierReveal();
state.phase = "idle";
state.credits = 1000;
state.lastWin = 0;
state.baseHand = [];
state.heldIndexes = [];
state.finalResults = [];
state.multiplier = { active: false, value: 1 };
state.message = "Credits reset. Choose your bet, then deal.";
playSound("button");
render();
}
function startMultiplierReveal(enabled) {
clearMultiplierReveal();
if (!enabled) return;
state.multiplierReveal.spinning = true;
els.dealDrawButton.disabled = true;
const stops = [...Engine.MULTIPLIERS, "NO"];
const totalTicks = 24 + Math.floor(Math.random() * 5);
let tick = 0;
function step() {
const isLastTick = tick >= totalTicks;
if (isLastTick) {
state.multiplierReveal.spinning = false;
state.multiplierReveal.display = "";
state.message = state.multiplier.active
? `${state.multiplier.value}x Video Poker multiplier is active. ${buildDealAdviceMessage()}`
: `No multiplier this deal. ${buildDealAdviceMessage()}`;
render();
playSound(state.multiplier.active ? "multiplierHit" : "multiplierMiss");
return;
}
const suspenseIndex = tick % stops.length;
const display = stops[suspenseIndex];
state.multiplierReveal.display = display === "NO" ? "No Multiplier?" : `${display}x`;
renderMultiplier();
playSound("multiplierTick");
tick += 1;
const delay = 42 + Math.pow(tick / totalTicks, 2.6) * 170;
state.multiplierReveal.timer = window.setTimeout(step, delay);
}
step();
}
function render() {
const settings = getSettings();
els.credits.textContent = state.credits;
els.currentBet.textContent = Engine.calculateTotalBet(settings);
els.lastWin.textContent = state.lastWin;
els.stateLabel.textContent = state.phase === "idle" ? "Ready" : state.phase === "dealt" ? "Hold" : "Result";
els.message.textContent = state.message || "Choose your bet, then deal.";
els.dealDrawButton.textContent = state.multiplierReveal.spinning ? "Wait" : state.phase === "dealt" ? "Draw" : state.phase === "result" ? "Deal Again" : "Deal";
els.dealDrawButton.disabled = state.multiplierReveal.spinning;
const lockSettings = state.phase === "dealt";
els.betPerHand.disabled = lockSettings;
els.handCount.disabled = lockSettings;
renderMultiplier();
renderBaseHand();
renderFinalHands();
renderPaytable();
}
function renderMultiplier() {
const label = els.multiplierBadge;
label.className = "multiplier-badge";
if (state.multiplierReveal.spinning) {
label.innerHTML = `
<span class="reveal-label">Video Poker</span>
<strong>${state.multiplierReveal.display || "..."}</strong>
<span class="reveal-dots"><i></i><i></i><i></i></span>
`;
label.classList.add("spinning");
} else if (state.phase === "idle") {
label.textContent = "Multiplier eligible";
label.classList.add("eligible");
} else if (state.multiplier.active) {
label.textContent = `${state.multiplier.value}x Video Poker`;
label.classList.add("hit");
} else {
label.textContent = "No multiplier this deal";
label.classList.add("miss");
}
}
function renderBaseHand() {
els.baseHand.innerHTML = "";
const cards = state.baseHand.length ? state.baseHand : Array.from({ length: 5 }, () => null);
const advisedIndexes = state.phase === "dealt" && !state.multiplierReveal.spinning
? Engine.recommendHoldIndexes(state.baseHand)
: [];
cards.forEach((card, index) => {
const isAdvised = advisedIndexes.includes(index);
const button = document.createElement("button");
button.type = "button";
button.className = "card";
if (!card) button.classList.add("back");
if (card && isRed(card)) button.classList.add("red");
if (isAdvised) button.classList.add("advised");
if (state.heldIndexes.includes(index)) button.classList.add("held");
button.disabled = state.phase !== "dealt" || state.multiplierReveal.spinning;
button.setAttribute("aria-label", card ? `${card.rank} of ${card.suit}${isAdvised ? ", suggested hold" : ""}` : "Card back");
button.innerHTML = card ? cardFace(card, state.heldIndexes.includes(index), isAdvised) : "";
button.addEventListener("click", () => toggleHold(index));
els.baseHand.appendChild(button);
});
}
function renderFinalHands() {
els.finalHands.innerHTML = "";
if (!state.finalResults.length) {
const settings = getSettings();
for (let i = 0; i < settings.handCount; i += 1) {
const placeholder = document.createElement("article");
placeholder.className = "mini-hand placeholder";
placeholder.innerHTML = `<span>Hand ${i + 1}</span><div class="mini-card-row">${Array.from({ length: 5 }, () => "<i></i>").join("")}</div>`;
els.finalHands.appendChild(placeholder);
}
els.resultSummary.textContent = "Wins appear after draw";
return;
}
const wins = state.finalResults.filter((result) => result.isWinner);
els.resultSummary.textContent = `${wins.length} winning hand${wins.length === 1 ? "" : "s"} / ${state.finalResults.length}`;
state.finalResults.forEach((result) => {
const hand = document.createElement("article");
hand.className = `mini-hand${result.isWinner ? " winner" : ""}`;
hand.innerHTML = `
<div class="mini-hand-top">
<span>Hand ${result.id}</span>
<strong>${result.finalWin}</strong>
</div>
<div class="mini-card-row">
${result.cards.map((card) => `<span class="${isRed(card) ? "red" : ""}">${card.rank}<small>${card.symbol}</small></span>`).join("")}
</div>
<p>${result.handName}</p>
`;
els.finalHands.appendChild(hand);
});
}
function renderPaytable() {
const rows = Object.entries(Engine.PAYTABLE)
.filter(([, value]) => value > 0)
.map(([name, value]) => `<div><span>${name}</span><strong>${value}</strong></div>`);
els.paytableList.innerHTML = rows.join("");
els.paytableStrip.innerHTML = rows.join("");
}
function buildDealAdviceMessage() {
const evaluation = Engine.evaluateHand(state.baseHand);
const advisedIndexes = Engine.recommendHoldIndexes(state.baseHand);
const holdText = advisedIndexes.length
? "Starred cards are suggested holds."
: "No strong hold is suggested.";
return `Dealt: ${evaluation.handName}. ${holdText}`;
}
function cardFace(card, held, advised) {
return `
<span class="rank">${card.rank}</span>
<span class="suit">${card.symbol}</span>
<span class="rank bottom">${card.rank}</span>
${advised ? '<span class="advice-star" aria-hidden="true">*</span>' : ""}
${held ? "<em>Hold</em>" : ""}
`;
}
function isRed(card) {
return card.suit === "hearts" || card.suit === "diamonds";
}
els.dealDrawButton.addEventListener("click", () => {
if (state.phase === "dealt") draw();
else deal();
});
els.betPerHand.addEventListener("change", () => {
playSound("button");
render();
});
els.handCount.addEventListener("change", () => {
playSound("button");
render();
});
els.resetButton.addEventListener("click", resetGame);
els.paytableButton.addEventListener("click", () => {
playSound("button");
els.paytableDialog.showModal();
});
els.closePaytable.addEventListener("click", () => {
playSound("button");
els.paytableDialog.close();
});
render();
}());