Code — how this page is built

Under the hood

The actual source of the components on this page, read straight from the repo. Copy it, read it, or open it on GitHub.


The whole game. Holds the deck, current card index, streak and best-streak. On a guess it reveals the next card, compares values (Ace high), updates the streak (a tie is a push), and persists the best streak to localStorage. The deck reshuffles when it runs out.

'use client';

import * as React from 'react';

import { PlayingCard } from '@/components/games/PlayingCard';
import { Win98Button } from '@/components/ui/win-98-button';
import { shuffledDeck, type Card } from '@/lib/cards';

import styles from './hilo.module.css';

const BEST_KEY = 'kaspirius:hilo:best';

type Result = 'win' | 'lose' | 'push' | null;

interface GameState {
  deck: Card[];
  idx: number; // index of the current (face-up) card
  streak: number;
  best: number;
  result: Result;
}

const STATUS: Record<Exclude<Result, null>, string> = {
  win: 'correct — keep going',
  lose: 'wrong — streak reset',
  push: 'tie — push, guess again',
};

export function HiLoGame() {
  const [game, setGame] = React.useState<GameState | null>(null);

  // Build the deck after mount so the server/client markup matches (the deck is
  // randomised, so it can't be created during render).
  React.useEffect(() => {
    const best = Number(localStorage.getItem(BEST_KEY)) || 0;
    setGame({ deck: shuffledDeck(), idx: 0, streak: 0, best, result: null });
  }, []);

  React.useEffect(() => {
    if (game) localStorage.setItem(BEST_KEY, String(game.best));
  }, [game?.best]); // eslint-disable-line react-hooks/exhaustive-deps

  const guess = (dir: 'higher' | 'lower') => {
    setGame((prev) => {
      if (!prev) return prev;
      let { deck } = prev;
      const nextIdx = prev.idx + 1;
      if (nextIdx >= deck.length) deck = [...deck, ...shuffledDeck()];

      const current = deck[prev.idx];
      const next = deck[nextIdx];

      let result: Result;
      if (next.value === current.value) result = 'push';
      else {
        const isHigher = next.value > current.value;
        result = (dir === 'higher') === isHigher ? 'win' : 'lose';
      }

      const streak = result === 'win' ? prev.streak + 1 : result === 'lose' ? 0 : prev.streak;
      return { deck, idx: nextIdx, streak, best: Math.max(prev.best, streak), result };
    });
  };

  const current = game ? game.deck[game.idx] : null;
  const remaining = game ? game.deck.length - game.idx - 1 : 0;
  const ringClass =
    game?.result === 'win'
      ? styles.win
      : game?.result === 'lose'
        ? styles.lose
        : game?.result === 'push'
          ? styles.push
          : '';

  return (
    <div className={styles.game}>
      <div className={styles.streaks}>
        <div>
          <div className={styles.streakNum}>{game ? game.streak : 0}</div>
          <div className={styles.streakLabel}>streak</div>
        </div>
        <div>
          <div className={styles.streakNum}>{game ? game.best : 0}</div>
          <div className={styles.streakLabel}>best</div>
        </div>
      </div>

      <div className={`${styles.cardSlot} ${ringClass}`}>
        <PlayingCard card={current} faceDown={!game} />
      </div>

      <div className={styles.status}>
        {game?.result ? STATUS[game.result] : 'higher or lower than this card?'}
      </div>

      <div className={styles.buttons}>
        <Win98Button
          onClick={() => guess('higher')}
          disabled={!game}
          className="h-9 min-w-24 px-5"
        >
          ▲ Higher
        </Win98Button>
        <Win98Button
          onClick={() => guess('lower')}
          disabled={!game}
          className="h-9 min-w-24 px-5"
        >
          ▼ Lower
        </Win98Button>
      </div>

      <div className={styles.meta}>{game ? `${remaining} cards until reshuffle` : 'shuffling…'}</div>
    </div>
  );
}

A brand-drawn card — paper face, ink pips, red suits in accent-red, Unicode suit glyphs. Pure presentational and reused by every card game.

import type { Card, Suit } from '@/lib/cards';
import styles from './PlayingCard.module.css';

const SUIT_SYMBOL: Record<Suit, string> = {
  spades: '♠',
  hearts: '♥',
  diamonds: '♦',
  clubs: '♣',
};

/**
 * A brand-drawn playing card (paper face, ink pips, red suits in accent-red).
 * Pure presentational — reused across the card games. Sizes to its container.
 */
export function PlayingCard({
  card,
  faceDown = false,
  className,
}: {
  card?: Card | null;
  faceDown?: boolean;
  className?: string;
}) {
  if (faceDown || !card) {
    return (
      <div
        className={`${styles.card} ${styles.back} ${className ?? ''}`}
        aria-label="face-down card"
        role="img"
      />
    );
  }

  const sym = SUIT_SYMBOL[card.suit];
  return (
    <div
      className={`${styles.card} ${card.red ? styles.red : ''} ${className ?? ''}`}
      role="img"
      aria-label={`${card.rank} of ${card.suit}`}
    >
      <div className={`${styles.corner} ${styles.tl}`}>
        <span>{card.rank}</span>
        <span>{sym}</span>
      </div>
      <div className={styles.pip} aria-hidden="true">
        {sym}
      </div>
      <div className={`${styles.corner} ${styles.br}`}>
        <span>{card.rank}</span>
        <span>{sym}</span>
      </div>
    </div>
  );
}
cards (deck + shuffle)view on GitHub ↗

A standard 52-card deck and a Fisher–Yates shuffle. Ace is high so higher/lower reads naturally. Shared across the card games.

/* ──────────────────────────────────────────────────────────────────────────
   lib/cards.ts — a standard 52-card deck + shuffle.

   Shared across the card games (Hi-Lo, Red-Black, Card Dealer…). Pure data +
   functions, no React — importable from client or server. Ace is high (value
   14) so Hi-Lo "higher/lower" reads naturally.
   ────────────────────────────────────────────────────────────────────────── */

export type Suit = 'spades' | 'hearts' | 'diamonds' | 'clubs';
export type Rank = 'A' | '2' | '3' | '4' | '5' | '6' | '7' | '8' | '9' | '10' | 'J' | 'Q' | 'K';

export interface Card {
  rank: Rank;
  suit: Suit;
  /** Comparable value, Ace high: 2–10, J=11, Q=12, K=13, A=14. */
  value: number;
  /** True for hearts/diamonds. */
  red: boolean;
}

export const SUITS: Suit[] = ['spades', 'hearts', 'diamonds', 'clubs'];
export const RANKS: Rank[] = ['2', '3', '4', '5', '6', '7', '8', '9', '10', 'J', 'Q', 'K', 'A'];

const RANK_VALUE: Record<Rank, number> = {
  '2': 2, '3': 3, '4': 4, '5': 5, '6': 6, '7': 7, '8': 8, '9': 9, '10': 10,
  J: 11, Q: 12, K: 13, A: 14,
};

/** A fresh, ordered 52-card deck. */
export function buildDeck(): Card[] {
  const deck: Card[] = [];
  for (const suit of SUITS) {
    for (const rank of RANKS) {
      deck.push({
        rank,
        suit,
        value: RANK_VALUE[rank],
        red: suit === 'hearts' || suit === 'diamonds',
      });
    }
  }
  return deck;
}

/** Fisher–Yates shuffle into a new array (uses Math.random — client/runtime). */
export function shuffle<T>(input: T[]): T[] {
  const arr = [...input];
  for (let i = arr.length - 1; i > 0; i--) {
    const j = Math.floor(Math.random() * (i + 1));
    [arr[i], arr[j]] = [arr[j], arr[i]];
  }
  return arr;
}

/** A freshly shuffled 52-card deck. */
export function shuffledDeck(): Card[] {
  return shuffle(buildDeck());
}
↖ kaspirius