Higher or Lower
High-Low Card Game · Call the next card. Keep the streak alive.
Higher or Lower is the classic high-low card game, played with one shuffled 52-card deck. A card sits face up; call whether the next card ranks higher or lower. Aces are high and suits don't count. Drawing the same rank again is a push, so your streak is safe. One wrong call ends the run. Dots under the table count every rank still in the deck, an optional odds view shows your chances for each call, and your longest streak is saved on this device.
How to play
- 1Look at the face-up card. Aces are high; suits don't matter.
- 2Tap Higher or Lower, press the Up or Down arrow (or H / L), or swipe on the cards.
- 3A correct call adds one to your streak. The same rank again is a push: no point, no loss.
- 4One wrong call ends the run. The dots show which ranks are left in the deck; press O to see the odds.
Runs in an isolated sandbox with no network access and no access to this site — the same safe runtime every uploaded game uses.
Controls
Tap Higher / Lower · Arrow keys or H / L · Swipe up or down · O shows odds
Pause, restart, and mute are built into the game frame. Your best score is stored on this device only.
How the code works
The rules live in a small window.HiLo module with no DOM code: makeDeck() builds 52 cards, shuffle() is a Fisher-Yates shuffle, and guess() draws the next card, scores the call and reshuffles a fresh deck when the old one runs out. The second script only renders cards as HTML and SVG, so the whole game works without a canvas, and the odds come from counting the cards you have not seen yet.
Like every Upload Arcade original, it is one self-contained HTML file: inline CSS, the markup for the score bar and overlays, and plain JavaScript with no libraries. Sound comes from Web Audio oscillators, and the best score is sent to the host page with postMessage, so the game never needs storage of its own.
MIT License. Released by Upload Arcade Studio. Copy it, change it, and share it; keep the license notice at the top of the file.
832 lines · 33 KB · opens offline in any browser
<!doctype html>
<!--
Higher or Lower · https://www.uploadarcade.com/games/higher-or-lower
MIT License
Copyright (c) 2026 Upload Arcade Studio
Permission is hereby granted, free of charge, to any person obtaining a copy
of this software and associated documentation files (the "Software"), to deal
in the Software without restriction, including without limitation the rights
to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
copies of the Software, and to permit persons to whom the Software is
furnished to do so, subject to the following conditions:
The above copyright notice and this permission notice shall be included in all
copies or substantial portions of the Software.
THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
SOFTWARE.
-->
<html lang="en">
<head>
<meta charset="utf-8" />
<meta name="viewport" content="width=device-width, initial-scale=1" />
<title>Higher or Lower</title>
<style>
:root {
--bg: #0a0e1c;
--felt: #141c38;
--ink: #eef2ff;
--dim: #9aa8c8;
--line: rgba(154, 168, 200, 0.24);
--violet: #a97bff;
--up: #4ade80;
--down: #ff7a8a;
--amber: #ffb43a;
--card: #f7f8fc;
--card-ink: #18203a;
--card-red: #d22f47;
--card-h: clamp(112px, min(30vh, 44vw), 236px);
}
* { box-sizing: border-box; }
html, body {
margin: 0;
height: 100%;
background: var(--bg);
color: var(--ink);
font-family: ui-monospace, "SF Mono", "Cascadia Code", Menlo, Consolas, monospace;
overflow: hidden;
-webkit-user-select: none;
user-select: none;
touch-action: manipulation;
}
button { font-family: inherit; }
#stage {
position: fixed;
inset: 0;
display: flex;
flex-direction: column;
background: radial-gradient(120% 90% at 50% 38%, var(--felt) 0%, var(--bg) 70%);
}
/* HUD: real buttons (no form) so pause/restart/odds/mute work in the sandbox. */
.hud {
display: flex;
align-items: flex-start;
justify-content: space-between;
gap: 12px;
padding: 12px 14px 0;
}
.scores { display: flex; gap: 18px; }
.score-box .label {
font-size: 10px;
letter-spacing: 0.16em;
text-transform: uppercase;
color: var(--dim);
}
.score-box .value { font-size: 26px; font-weight: 700; line-height: 1.1; }
#best.value { color: var(--amber); }
.controls { display: flex; gap: 8px; }
.controls button {
width: 40px; height: 40px;
display: grid; place-items: center;
background: rgba(10, 14, 28, 0.72);
color: var(--ink);
border: 1px solid var(--line);
border-radius: 10px;
cursor: pointer;
font-size: 13px;
font-weight: 700;
}
.controls button[aria-pressed="true"] { border-color: var(--violet); color: var(--violet); }
.controls button:focus-visible, .call:focus-visible, .play:focus-visible {
outline: 2px solid var(--amber);
outline-offset: 2px;
}
.controls svg { width: 17px; height: 17px; }
#muteBtn .off { display: none; }
#muteBtn.muted .on { display: none; }
#muteBtn.muted .off { display: block; }
/* Table */
.table {
flex: 1;
min-height: 0;
display: flex;
flex-direction: column;
align-items: center;
justify-content: center;
gap: clamp(8px, 2.2vh, 20px);
padding: 6px 14px 14px;
}
.cards {
display: flex;
align-items: flex-end;
justify-content: center;
gap: clamp(18px, 6vw, 56px);
touch-action: none;
}
.slot { margin: 0; display: flex; flex-direction: column; align-items: center; gap: 8px; }
.slot figcaption { font-size: 11px; letter-spacing: 0.08em; color: var(--dim); }
.slot figcaption b { color: var(--ink); font-weight: 700; }
.card {
position: relative;
height: var(--card-h);
aspect-ratio: 5 / 7;
border-radius: calc(var(--card-h) * 0.075);
box-shadow: 0 16px 34px rgba(0, 0, 0, 0.45);
transition: box-shadow 200ms ease;
}
.card.face { background: var(--card); color: var(--card-ink); }
.card.face.red { color: var(--card-red); }
.card .corner {
position: absolute;
display: flex;
flex-direction: column;
align-items: center;
line-height: 1;
font-family: system-ui, -apple-system, "Segoe UI", Roboto, sans-serif;
font-weight: 800;
font-size: calc(var(--card-h) * 0.13);
letter-spacing: -0.04em;
}
.card .corner svg { width: calc(var(--card-h) * 0.09); height: calc(var(--card-h) * 0.09); margin-top: 3px; }
.card .tl { top: 7%; left: 8%; }
.card .br { bottom: 7%; right: 8%; transform: rotate(180deg); }
.card .center {
position: absolute;
inset: 22% 18%;
display: grid;
place-items: center;
}
.card .center svg.big { width: 72%; height: auto; }
.card .center.court { inset: 25% 23%; }
/* Court cards: an original framed monogram instead of traditional portraits. */
.card .crest {
width: 100%;
height: 100%;
display: grid;
place-items: center;
border: 2px solid currentColor;
border-radius: 10px;
outline: 1px solid currentColor;
outline-offset: 3px;
opacity: 0.95;
}
.card .crest span {
font-family: system-ui, -apple-system, "Segoe UI", Roboto, sans-serif;
font-weight: 800;
font-size: calc(var(--card-h) * 0.3);
line-height: 1;
}
.card .crest svg { width: calc(var(--card-h) * 0.11); height: calc(var(--card-h) * 0.11); margin-top: -6px; }
.card.back {
border: 5px solid var(--card);
background:
repeating-linear-gradient(45deg, rgba(255, 255, 255, 0.11) 0 2px, transparent 2px 11px),
repeating-linear-gradient(-45deg, rgba(255, 255, 255, 0.11) 0 2px, transparent 2px 11px),
linear-gradient(160deg, #7656ff, #33267f);
display: grid;
place-items: center;
}
.card.back svg { width: 46%; height: auto; color: var(--card); opacity: 0.92; }
.deck .card.back {
box-shadow: 3px 3px 0 #2b2070, 6px 6px 0 #221a5a, 0 18px 34px rgba(0, 0, 0, 0.5);
}
.table[data-result="correct"] #current { box-shadow: 0 0 0 4px var(--up), 0 16px 34px rgba(0, 0, 0, 0.45); }
.table[data-result="push"] #current { box-shadow: 0 0 0 4px var(--amber), 0 16px 34px rgba(0, 0, 0, 0.45); }
.table[data-result="wrong"] #current { box-shadow: 0 0 0 4px var(--down), 0 16px 34px rgba(0, 0, 0, 0.45); }
@keyframes deal {
from { transform: perspective(700px) translateX(28%) rotateY(-80deg); opacity: 0.35; }
to { transform: none; opacity: 1; }
}
.card.flip { animation: deal 300ms cubic-bezier(0.2, 0.8, 0.2, 1); }
.status {
margin: 0;
min-height: 2.6em;
max-width: 440px;
text-align: center;
font-size: 14px;
line-height: 1.4;
color: var(--dim);
}
.table[data-result="correct"] .status .verdict { color: var(--up); }
.table[data-result="push"] .status .verdict { color: var(--amber); }
.status .verdict { font-weight: 700; color: var(--ink); }
.calls { display: flex; gap: 12px; width: min(100%, 420px); }
.call {
flex: 1;
min-height: 56px;
display: flex;
align-items: center;
justify-content: center;
gap: 8px;
border-radius: 14px;
border: 2px solid;
background: rgba(10, 14, 28, 0.6);
color: var(--ink);
font-size: 17px;
font-weight: 700;
cursor: pointer;
}
.call svg { width: 18px; height: 18px; }
.call.up { border-color: rgba(74, 222, 128, 0.7); }
.call.up svg { color: var(--up); }
.call.down { border-color: rgba(255, 122, 138, 0.7); }
.call.down svg { color: var(--down); }
.call:active { transform: translateY(1px); }
.call:disabled { opacity: 0.45; cursor: default; }
.call .odds { font-size: 13px; font-weight: 600; color: var(--dim); }
.tracker {
list-style: none;
margin: 0;
padding: 0;
display: grid;
grid-template-columns: repeat(13, minmax(0, 1fr));
gap: 4px;
width: min(100%, 460px);
}
.tracker li { display: flex; flex-direction: column; align-items: center; gap: 4px; font-size: 11px; color: var(--dim); }
.tracker li.is-current .rk { color: var(--ink); font-weight: 700; }
.pips { display: grid; grid-template-columns: repeat(2, 5px); gap: 2px; }
.pips i { width: 5px; height: 5px; border-radius: 50%; background: var(--violet); }
.pips i.gone { background: transparent; box-shadow: inset 0 0 0 1px rgba(154, 168, 200, 0.5); }
.sr {
position: absolute;
width: 1px; height: 1px;
overflow: hidden;
clip: rect(0 0 0 0);
white-space: nowrap;
}
.overlay {
position: absolute; inset: 0;
display: grid; place-items: center;
background: rgba(6, 9, 19, 0.84);
padding: 24px;
text-align: center;
}
.overlay[hidden] { display: none; }
.panel { max-width: 430px; }
.panel h1 { margin: 0 0 6px; font-size: 30px; letter-spacing: 0.02em; }
.panel .tag { color: var(--violet); font-size: 12px; letter-spacing: 0.18em; text-transform: uppercase; }
.panel p { color: var(--dim); font-size: 14px; line-height: 1.6; margin: 14px 0; }
.panel kbd {
background: rgba(154, 168, 200, 0.16);
border: 1px solid var(--line);
border-radius: 6px;
padding: 2px 7px;
font-family: inherit;
font-size: 12px;
color: var(--ink);
}
.play {
margin-top: 8px;
background: var(--amber);
color: #20140a;
border: 0;
border-radius: 12px;
padding: 13px 30px;
font-size: 16px;
font-weight: 700;
cursor: pointer;
}
.final { font-size: 44px; font-weight: 800; color: var(--amber); margin: 6px 0; }
.hint { font-size: 12px; color: var(--dim); margin-top: 16px; }
@media (max-height: 430px) {
:root { --card-h: clamp(92px, 27vh, 150px); }
.tracker { display: none; }
}
@media (prefers-reduced-motion: reduce) {
.card.flip { animation: none; }
.card, .call { transition: none; }
}
</style>
</head>
<body>
<div id="stage">
<div class="hud">
<div class="scores">
<div class="score-box">
<div class="label">Streak</div>
<div class="value" id="score">0</div>
</div>
<div class="score-box">
<div class="label">Best</div>
<div class="value" id="best">0</div>
</div>
</div>
<div class="controls">
<button id="oddsBtn" type="button" aria-label="Show odds" aria-pressed="false">%</button>
<button id="pauseBtn" type="button" aria-label="Pause">
<svg viewBox="0 0 24 24" fill="currentColor" aria-hidden="true"><rect x="6" y="5" width="4" height="14" rx="1"/><rect x="14" y="5" width="4" height="14" rx="1"/></svg>
</button>
<button id="restartBtn" type="button" aria-label="Restart">
<svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2.2" stroke-linecap="round" stroke-linejoin="round" aria-hidden="true"><path d="M20 11a8 8 0 1 0-2.3 5.7"/><path d="M20 5v6h-6"/></svg>
</button>
<button id="muteBtn" type="button" aria-label="Mute">
<svg class="on" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2.2" stroke-linecap="round" stroke-linejoin="round" aria-hidden="true"><path d="M4 9v6h4l5 4V5L8 9H4z" fill="currentColor" stroke="none"/><path d="M16 9a4 4 0 0 1 0 6"/></svg>
<svg class="off" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2.2" stroke-linecap="round" stroke-linejoin="round" aria-hidden="true"><path d="M4 9v6h4l5 4V5L8 9H4z" fill="currentColor" stroke="none"/><path d="M16 9l5 6M21 9l-5 6"/></svg>
</button>
</div>
</div>
<main id="game" class="table" data-result="">
<div class="cards" id="cards">
<figure class="slot">
<div id="current" class="card back" role="img" aria-label="Face-down card"></div>
<figcaption>Showing</figcaption>
</figure>
<figure class="slot deck">
<div class="card back" aria-hidden="true">
<svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2.4" stroke-linecap="round" stroke-linejoin="round"><path d="M7 10l5-5 5 5"/><path d="M7 14l5 5 5-5"/></svg>
</div>
<figcaption><b id="left">51</b> left<span id="pushOdds"></span></figcaption>
</figure>
</div>
<p class="status" id="status" role="status">Deal to start.</p>
<div class="calls">
<button class="call up" id="higherBtn" type="button" disabled>
<svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="3" stroke-linecap="round" stroke-linejoin="round" aria-hidden="true"><path d="M5 15l7-7 7 7"/></svg>
Higher <span class="odds" id="oddsHigher" hidden></span>
</button>
<button class="call down" id="lowerBtn" type="button" disabled>
<svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="3" stroke-linecap="round" stroke-linejoin="round" aria-hidden="true"><path d="M5 9l7 7 7-7"/></svg>
Lower <span class="odds" id="oddsLower" hidden></span>
</button>
</div>
<ul class="tracker" id="tracker" aria-label="Cards left in the deck, by rank"></ul>
</main>
<div class="overlay" id="startOverlay">
<div class="panel">
<div class="tag">Card game</div>
<h1>Higher or Lower</h1>
<p>
One card is face up. Call whether the next card off the deck ranks
higher or lower. Aces are high and suits don't count. The same rank
again is a push: no point, no loss. One wrong call ends the run.
</p>
<p>
<kbd>↑</kbd> or <kbd>H</kbd> higher <kbd>↓</kbd> or <kbd>L</kbd> lower<br />
or tap the buttons, or swipe up or down on the cards
</p>
<button class="play" id="startBtn" type="button">Deal</button>
<div class="hint">The dots count what is left in the deck · O shows odds · P pauses · R restarts · M mutes</div>
</div>
</div>
<div class="overlay" id="overOverlay" hidden>
<div class="panel">
<div class="tag" id="overTag">Run over</div>
<h1 id="overTitle">Wrong call</h1>
<div class="final" id="finalScore">0</div>
<p id="overReveal"></p>
<p id="overBest">Best 0</p>
<button class="play" id="againBtn" type="button">Deal again</button>
</div>
</div>
<div class="overlay" id="pauseOverlay" hidden>
<div class="panel">
<div class="tag">Paused</div>
<h1>Higher or Lower</h1>
<button class="play" id="resumeBtn" type="button">Resume</button>
</div>
</div>
</div>
<script>
// ---- Rules (pure, no DOM) -------------------------------------------
// A 52-card deck, aces high, suits ignored. Same rank is a push.
window.HiLo = (() => {
"use strict";
const SUITS = ["spades", "hearts", "diamonds", "clubs"];
const RANKS = [2, 3, 4, 5, 6, 7, 8, 9, 10, 11, 12, 13, 14];
const NAMES = { 11: "Jack", 12: "Queen", 13: "King", 14: "Ace" };
function makeDeck() {
const deck = [];
for (const suit of SUITS) for (const rank of RANKS) deck.push({ rank, suit });
return deck;
}
// Fisher-Yates on a copy. rng() returns a number in [0, 1).
function shuffle(cards, rng) {
const out = cards.slice();
for (let i = out.length - 1; i > 0; i--) {
const j = Math.min(i, Math.floor(rng() * (i + 1)));
const swap = out[i];
out[i] = out[j];
out[j] = swap;
}
return out;
}
// How `next` ranks against `current`.
function compare(current, next) {
if (next.rank > current.rank) return "higher";
if (next.rank < current.rank) return "lower";
return "same";
}
function judge(call, current, next) {
const relation = compare(current, next);
if (relation === "same") return "push";
return relation === call ? "correct" : "wrong";
}
// Counts over the cards the player has not seen yet.
function odds(current, unseen) {
let higher = 0, lower = 0, same = 0;
for (const card of unseen) {
if (card.rank > current.rank) higher++;
else if (card.rank < current.rank) lower++;
else same++;
}
return { higher, lower, same, total: unseen.length };
}
function percent(count, total) {
return total > 0 ? Math.round((count * 100) / total) : 0;
}
function countByRank(cards) {
const counts = {};
for (const rank of RANKS) counts[rank] = 0;
for (const card of cards) counts[card.rank]++;
return counts;
}
function rankLabel(rank) {
return rank <= 10 ? String(rank) : "JQKA"[rank - 11];
}
function cardName(card) {
return (NAMES[card.rank] || String(card.rank)) + " of " + card.suit;
}
function sameCard(a, b) {
return a.rank === b.rank && a.suit === b.suit;
}
// A fresh shuffled deck, minus the card already on the table.
function freshDeck(rng, onTable) {
return shuffle(makeDeck().filter((card) => !onTable || !sameCard(card, onTable)), rng);
}
function createRound(rng) {
const deck = shuffle(makeDeck(), rng);
const current = deck.pop();
return { deck, current, score: 0, over: false, reshuffles: 0 };
}
// Draw the next card and score the call. Returns null when the call
// cannot be made (run over or an unknown call).
function guess(round, call, rng) {
if (round.over || (call !== "higher" && call !== "lower")) return null;
if (round.deck.length === 0) {
round.deck = freshDeck(rng, round.current);
round.reshuffles++;
}
const previous = round.current;
const next = round.deck.pop();
const outcome = judge(call, previous, next);
round.current = next;
if (outcome === "correct") round.score += 1;
else if (outcome === "wrong") round.over = true;
let reshuffled = false;
if (!round.over && round.deck.length === 0) {
round.deck = freshDeck(rng, round.current);
round.reshuffles++;
reshuffled = true;
}
return { call, outcome, previous, next, reshuffled };
}
return {
SUITS, RANKS, makeDeck, shuffle, compare, judge, odds, percent,
countByRank, rankLabel, cardName, createRound, guess,
};
})();
</script>
<script>
(() => {
"use strict";
const HiLo = window.HiLo;
// ---- Score bridge (the host page keeps the best score) ------------
const BRIDGE = {
hello: "upload-arcade:hello",
best: "upload-arcade:best",
score: "upload-arcade:score",
};
let best = 0;
window.addEventListener("message", (event) => {
const data = event.data;
if (data && data.type === BRIDGE.best && typeof data.best === "number") {
best = data.best;
bestEl.textContent = String(best);
}
});
function reportScore(score) {
window.parent.postMessage({ type: BRIDGE.score, score }, "*");
}
// ---- Audio (Web Audio beeps; starts after the first tap or key) ----
let audioCtx = null;
let muted = false;
function beep(freq, dur, type, gain) {
if (muted) return;
try {
audioCtx = audioCtx || new (window.AudioContext || window.webkitAudioContext)();
if (audioCtx.state === "suspended") audioCtx.resume();
const osc = audioCtx.createOscillator();
const amp = audioCtx.createGain();
osc.type = type || "sine";
osc.frequency.value = freq;
amp.gain.value = gain || 0.05;
amp.gain.exponentialRampToValueAtTime(0.0001, audioCtx.currentTime + dur);
osc.connect(amp).connect(audioCtx.destination);
osc.start();
osc.stop(audioCtx.currentTime + dur);
} catch {
/* audio is best-effort */
}
}
// ---- DOM ----------------------------------------------------------
const $ = (id) => document.getElementById(id);
const table = $("game");
const cardsEl = $("cards");
const currentEl = $("current");
const scoreEl = $("score");
const bestEl = $("best");
const leftEl = $("left");
const pushOddsEl = $("pushOdds");
const statusEl = $("status");
const higherBtn = $("higherBtn");
const lowerBtn = $("lowerBtn");
const oddsHigherEl = $("oddsHigher");
const oddsLowerEl = $("oddsLower");
const trackerEl = $("tracker");
const startOverlay = $("startOverlay");
const overOverlay = $("overOverlay");
const pauseOverlay = $("pauseOverlay");
const muteBtn = $("muteBtn");
const oddsBtn = $("oddsBtn");
// Original suit shapes (24x24).
const SUIT_PATHS = {
hearts: '<path fill="currentColor" d="M12 21.2s-7.6-4.7-9.7-9.3C.8 8.4 2.9 4.4 6.7 4.4c2.2 0 3.8 1.3 5.3 3.3 1.5-2 3.1-3.3 5.3-3.3 3.8 0 5.9 4 4.4 7.5-2.1 4.6-9.7 9.3-9.7 9.3z"/>',
diamonds: '<path fill="currentColor" d="M12 2.4l7.4 9.6-7.4 9.6L4.6 12z"/>',
spades: '<path fill="currentColor" d="M12 2.4s8 6.2 9 10.1c.8 2.9-1.3 5.6-4.1 5.6-1.6 0-2.9-.8-3.6-2l.9 5.5H9.8l.9-5.5c-.7 1.2-2 2-3.6 2-2.8 0-4.9-2.7-4.1-5.6 1-3.9 9-10.1 9-10.1z"/>',
clubs: '<g fill="currentColor"><circle cx="12" cy="7" r="4.2"/><circle cx="6.9" cy="13.4" r="4.2"/><circle cx="17.1" cy="13.4" r="4.2"/><path d="M10.6 13h2.8l1.5 8.6H9.1z"/></g>',
};
const RED_SUITS = new Set(["hearts", "diamonds"]);
function suitSvg(suit, className) {
return '<svg class="' + (className || "") + '" viewBox="0 0 24 24" aria-hidden="true">' + SUIT_PATHS[suit] + "</svg>";
}
function renderCard(el, card) {
const label = HiLo.rankLabel(card.rank);
const corner = '<span>' + label + "</span>" + suitSvg(card.suit);
const court = card.rank >= 11 && card.rank <= 13;
const center = court
? '<div class="crest"><div><span>' + label + "</span><br>" + suitSvg(card.suit) + "</div></div>"
: suitSvg(card.suit, "big");
el.className = "card face " + (RED_SUITS.has(card.suit) ? "red" : "black");
el.dataset.rank = String(card.rank);
el.dataset.suit = card.suit;
el.setAttribute("aria-label", HiLo.cardName(card));
el.innerHTML =
'<div class="corner tl" aria-hidden="true">' + corner + "</div>" +
'<div class="center' + (court ? " court" : "") + '" aria-hidden="true">' + center + "</div>" +
'<div class="corner br" aria-hidden="true">' + corner + "</div>";
}
function flip(el) {
el.classList.remove("flip");
void el.offsetWidth; // restart the deal animation
el.classList.add("flip");
}
// Deck tracker: one column per rank, one dot per card still unseen.
const trackerCols = {};
for (const rank of HiLo.RANKS) {
const li = document.createElement("li");
li.innerHTML =
'<span class="rk" aria-hidden="true">' + HiLo.rankLabel(rank) + "</span>" +
'<span class="pips" aria-hidden="true"><i></i><i></i><i></i><i></i></span>' +
'<span class="sr"></span>';
trackerEl.appendChild(li);
trackerCols[rank] = li;
}
// ---- State --------------------------------------------------------
let state = "start"; // start | playing | paused | over
let round = null;
let showOdds = false;
function name(card) {
return "the " + HiLo.cardName(card);
}
function setStatus(verdict, rest) {
statusEl.innerHTML = "";
if (verdict) {
const strong = document.createElement("span");
strong.className = "verdict";
strong.textContent = verdict;
statusEl.appendChild(strong);
statusEl.appendChild(document.createTextNode(" "));
}
statusEl.appendChild(document.createTextNode(rest));
}
function updateDeckInfo() {
const deck = round ? round.deck : HiLo.makeDeck();
const counts = HiLo.countByRank(deck);
leftEl.textContent = String(deck.length);
for (const rank of HiLo.RANKS) {
const li = trackerCols[rank];
const pips = li.querySelectorAll("i");
pips.forEach((pip, index) => pip.classList.toggle("gone", index >= counts[rank]));
li.classList.toggle("is-current", Boolean(round && round.current.rank === rank));
li.querySelector(".sr").textContent =
HiLo.rankLabel(rank) + ": " + counts[rank] + " left";
}
if (round && showOdds) {
const o = HiLo.odds(round.current, round.deck);
oddsHigherEl.textContent = HiLo.percent(o.higher, o.total) + "%";
oddsLowerEl.textContent = HiLo.percent(o.lower, o.total) + "%";
pushOddsEl.textContent = " · push " + HiLo.percent(o.same, o.total) + "%";
} else {
pushOddsEl.textContent = "";
}
oddsHigherEl.hidden = !(round && showOdds);
oddsLowerEl.hidden = !(round && showOdds);
}
function setCallsEnabled(enabled) {
higherBtn.disabled = !enabled;
lowerBtn.disabled = !enabled;
}
// ---- Flow ---------------------------------------------------------
function startGame() {
round = HiLo.createRound(Math.random);
state = "playing";
startOverlay.hidden = true;
overOverlay.hidden = true;
pauseOverlay.hidden = true;
table.dataset.result = "";
scoreEl.textContent = "0";
renderCard(currentEl, round.current);
flip(currentEl);
setStatus("", "Higher or lower than " + name(round.current) + "?");
setCallsEnabled(true);
updateDeckInfo();
beep(440, 0.08, "triangle", 0.045);
}
function call(direction) {
if (state !== "playing" || !round) return;
const result = HiLo.guess(round, direction, Math.random);
if (!result) return;
renderCard(currentEl, result.next);
flip(currentEl);
scoreEl.textContent = String(round.score);
table.dataset.result = result.outcome;
if (result.outcome === "wrong") {
endGame(result);
return;
}
const shuffled = result.reshuffled ? " The deck ran out, so a fresh one was shuffled." : "";
if (result.outcome === "correct") {
if (round.score > best) reportScore(round.score);
setStatus("Correct.", capital(name(result.next)) + " is " + direction + ". Streak " + round.score + "." + shuffled);
beep(620 + Math.min(round.score, 20) * 18, 0.09, "square", 0.04);
beep(930 + Math.min(round.score, 20) * 18, 0.12, "triangle", 0.03);
} else {
setStatus("Push.", capital(name(result.next)) + " is the same rank. Your streak is safe at " + round.score + "." + shuffled);
beep(520, 0.1, "sine", 0.04);
}
updateDeckInfo();
}
function capital(text) {
return text.charAt(0).toUpperCase() + text.slice(1);
}
function endGame(result) {
state = "over";
setCallsEnabled(false);
const score = round.score;
reportScore(score);
const isBest = score >= best && score > 0;
if (isBest) best = score;
$("finalScore").textContent = String(score);
$("overReveal").textContent =
capital(name(result.next)) + " is " + HiLo.compare(result.previous, result.next) +
" than " + name(result.previous) + ".";
$("overBest").textContent = (isBest ? "New best · " : "Best ") + best;
$("overTitle").textContent = isBest ? "New best!" : "Wrong call";
$("overTag").textContent = isBest ? "Personal record" : "Run over";
setStatus("Wrong call.", "Streak " + score + ".");
updateDeckInfo();
overOverlay.hidden = false;
beep(150, 0.35, "sawtooth", 0.05);
}
function togglePause() {
if (state === "playing") {
state = "paused";
pauseOverlay.hidden = false;
setCallsEnabled(false);
} else if (state === "paused") {
state = "playing";
pauseOverlay.hidden = true;
setCallsEnabled(true);
}
}
function toggleMute() {
muted = !muted;
muteBtn.classList.toggle("muted", muted);
muteBtn.setAttribute("aria-label", muted ? "Unmute" : "Mute");
}
function toggleOdds() {
showOdds = !showOdds;
oddsBtn.setAttribute("aria-pressed", String(showOdds));
oddsBtn.setAttribute("aria-label", showOdds ? "Hide odds" : "Show odds");
updateDeckInfo();
}
// ---- Input --------------------------------------------------------
$("startBtn").addEventListener("click", () => { startGame(); higherBtn.focus(); });
$("againBtn").addEventListener("click", () => { startGame(); higherBtn.focus(); });
$("resumeBtn").addEventListener("click", () => { togglePause(); higherBtn.focus(); });
$("pauseBtn").addEventListener("click", togglePause);
$("restartBtn").addEventListener("click", startGame);
muteBtn.addEventListener("click", toggleMute);
oddsBtn.addEventListener("click", toggleOdds);
higherBtn.addEventListener("click", () => call("higher"));
lowerBtn.addEventListener("click", () => call("lower"));
window.addEventListener("keydown", (event) => {
if (event.repeat || event.ctrlKey || event.metaKey || event.altKey) return;
const key = event.key;
const onButton = Boolean(event.target && event.target.tagName === "BUTTON");
if (key === "ArrowUp" || key === "h" || key === "H") {
event.preventDefault();
call("higher");
} else if (key === "ArrowDown" || key === "l" || key === "L") {
event.preventDefault();
call("lower");
} else if ((key === " " || key === "Enter") && !onButton) {
event.preventDefault();
if (state === "start" || state === "over") startGame();
else if (state === "paused") togglePause();
} else if (key === "p" || key === "P" || key === "Escape") {
togglePause();
} else if (key === "r" || key === "R") {
startGame();
} else if (key === "m" || key === "M") {
toggleMute();
} else if (key === "o" || key === "O") {
toggleOdds();
}
});
// Swipe up or down on the cards to call.
let swipeY = null;
cardsEl.addEventListener("pointerdown", (event) => { swipeY = event.clientY; });
cardsEl.addEventListener("pointerup", (event) => {
if (swipeY === null) return;
const dy = event.clientY - swipeY;
swipeY = null;
if (Math.abs(dy) > 40) call(dy < 0 ? "higher" : "lower");
});
cardsEl.addEventListener("pointercancel", () => { swipeY = null; });
// ---- Boot ---------------------------------------------------------
updateDeckInfo();
window.parent.postMessage({ type: BRIDGE.hello }, "*");
})();
</script>
</body>
</html>