Go to Home Page

Minigames: BlackJack

I’ve always liked adding small touches to my portfolio that show off more than “here’s another landing page.” One fun way to do that is with a mini-game tab at the top. It’s unexpected, interactive, and still technical enough to show how you think as an engineer.

For this walkthrough, I’ll show you how I put together a minimalist Blackjack game in React. No chips, no bets. Just a clean game loop that works well as a portfolio flex.

Step 1: Deck and Utilities

Any card game worth building starts with a proper deck utility. I keep this logic dead simple:

Create 52 cards (suits × ranks).
  export type Suit = React.ReactNode;
  export type Rank = "A" | "2" | "3" | "4" | "5" | "6" | "7" | "8" | "9" | "10" | "J" | "Q" | "K";
  export type Card = { suit: Suit; rank: Rank };
  export type Deck = Card[];

  // used lucide-react for suite icons
  const SUITS: Suit[] = [
    <Spade key="spade" />,
    <Club key="club" />,
    <Diamond key="diamond" />,
    <Heart key="heart" />,
  ];

  const RANKS: Rank[] = ["A", "2", "3", "4", "5", "6", "7", "8", "9", "10", "J", "Q", "K"];

Shuffle once per round.
function shuffle<T>(array: T[]): T[] {
  const arr = [...array];
  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;
}

function makeDeck(): Deck {
  const d: Deck = [];
  for (const s of SUITS) {
    for (const r of RANKS) d.push({ suit: s, rank: r });
  }
  return shuffle(d);
}
Draw cards off the top
function draw(deck: Deck, n = 1): { cards: Card[]; deck: Deck } {
  return { cards: deck.slice(0, n), deck: deck.slice(n) };
}

function cardValue(rank: Rank): number {
  if (rank === "A") return 11; // handle totals in helper function
  if (["K", "Q", "J"].includes(rank)) return 10;
  return parseInt(rank, 10);
}

This makes it trivial to reuse across Blackjack (and other card games too).

I also wrote a handTotals() helper that handles Aces as 1 or 11. Blackjack is basically just math with edge cases, so nailing that helper early saves you bugs later.

handTotals()
function handTotals(cards: Card[]): { total: number; soft: boolean } {
  // Start by counting all aces as 11, then reduce by 10 while busting
    let total = 0;
    let aces = 0;
    for (const c of cards) {
      if (c.rank === "A") aces++;
      total += cardValue(c.rank);
    }
    while (total > 21 && aces > 0) {
      total -= 10; // make one ace count as 1 instead of 11
      aces--;
    }
    const soft = cards.some((c) => c.rank === "A") && total <= 21 && aces > 0; // at least one ace counted as 11
    return { total, soft };
}

Step 2: Game State Machine

Most state management can be handled within React. A simple reducer gets the job done:

idle → player → dealer → resolve
type Phase = "idle" | "player" | "dealer" | "resolve";

type State = {
  deck: Deck;
  player: Card[];
  dealer: Card[];
  phase: Phase;
  revealDealerHole: boolean;
  result: "win" | "lose" | "push" | null;
  rounds: number;
};

const initialState: State = {
  deck: makeDeck(),
  player: [],
  dealer: [],
  phase: "idle",
  revealDealerHole: false,
  result: null,
  rounds: 0,
};

type Action =
  | { type: "DEAL" }
  | { type: "HIT" }
  | { type: "STAND" }
  | { type: "DEALER_TURN" }
  | { type: "RESOLVE" }
  | { type: "NEW_ROUND" };

function reducer(state: State, action: Action): State {
  switch (action.type) {
    case "DEAL": {
      let deck = state.deck;
      if (deck.length < 15) deck = makeDeck();
      const a = draw(deck, 2);
      const b = draw(a.deck, 2);
      return {
        ...state,
        deck: b.deck,
        player: a.cards,
        dealer: b.cards,
        phase: "player",
        revealDealerHole: false,
        result: null,
        rounds: state.rounds + 1,
      };
    }
    case "HIT": {
      if (state.phase !== "player") return state;
      const { cards, deck } = draw(state.deck, 1);
      const player = [...state.player, ...cards];
      const { total } = handTotals(player);
      if (total > 21) {
        return { ...state, deck, player, phase: "resolve", revealDealerHole: true, result: "lose" };
      }
      return { ...state, deck, player };
    }
    case "STAND": {
      if (state.phase !== "player") return state;
      return { ...state, phase: "dealer", revealDealerHole: true };
    }
    case "DEALER_TURN": {
      if (state.phase !== "dealer") return state;
      const { total } = handTotals(state.dealer);
      if (total >= 17) {
        return { ...state, phase: "resolve" };
      }
      const { cards, deck } = draw(state.deck, 1);
      return { ...state, deck, dealer: [...state.dealer, ...cards] };
    }
    case "RESOLVE": {
      const pt = handTotals(state.player).total;
      const dt = handTotals(state.dealer).total;
      let result: State["result"] = null;
      if (pt > 21) result = "lose";
      else if (dt > 21) result = "win";
      else if (pt > dt) result = "win";
      else if (pt < dt) result = "lose";
      else result = "push";
      return { ...state, phase: "resolve", result };
    }
    case "NEW_ROUND": {
      return {
        ...state,
        player: [],
        dealer: [],
        phase: "idle",
        revealDealerHole: false,
        result: null,
      };
    }
    default:
      return state;
  }
}

That’s it. No spaghetti if/else, no scattered refs. Each phase maps to obvious UI behavior:

The nice thing about a reducer is you can add keyboard shortcuts (D for Deal, H for Hit, S for Stand, R for Reset) without worrying about your state getting messy.

Step 3: UI Decisions

I wanted a minimalist card UI. no textures, no poker table backgrounds. Just:

It looks clean in both light and dark themes, and it matches the rest of my portfolio styling.

The dealer’s second card stays hidden with a face-down placeholder until you stand, which feels surprisingly “game-like” even without animations.

Step 4: Dealer AI (if you can call it that)

Dealer logic is almost laughably simple:

React’s useEffect with a setTimeout handles this nicely so it feels like the dealer is “thinking” instead of instantly snapping cards into place.

Step 5: Result Banner

Finally, when everything resolves, a banner pops up:

You win! Dealer wins. Push (tie).

That’s it. Clean and obvious.

Why This Works in a Portfolio

This game isn’t about showing off React wizardry. It’s about showing that you can:

Final Thoughts

Could you make it fancier? Sure. Animations, sounds, scoring history, all possible. But in the context of a portfolio header mini-game, less is more. Clean, interactive, and out of the way.

👉 Next up, I might write about how I built the Memory Game to sit next to Blackjack in the same tab container. Both share utilities, but the gameplay logic is wildly different and makes for a neat comparison.