The classic four-in-a-row, with a bitboard AI opponent and a self-playing welcome screen.
A from-scratch Connect 4 rendered entirely on an HTML5 canvas, with a bitboard-based negamax AI offering three difficulty profiles, a polished welcome modal with an attract self-play demo running behind it, and full keyboard navigation throughout.
Opponent modes
| Mode | Behavior |
|---|---|
| Easy | Heuristic only — always blocks an immediate loss, always takes an immediate win, otherwise weighted-random with a center bias. Plays like a casual human. Runs in-process. |
| Medium | Depth-7 iterative-deepening negamax with α-β + transposition table + killer-move heuristic. ~800ms budget. Occasionally picks the 2nd-best move when the gap is genuinely small, so it doesn't feel mechanical. Runs in a Web Worker. |
| Hard | Same engine, uncapped depth (up to MAX_MOVES) with a ~2.5s budget. Iteratively deepens within the budget; positions with few moves remaining are fully solved — the score is the true game-theoretic value. Heuristic understands Connect 4's parity / zugzwang principle. Runs in a Web Worker. |
Attract self-play — Two easy-difficulty bots play each other behind the welcome modal as ambient motion, pausing on each result to let the win pulse play before resetting.
Win sequence — When the winning move lands, the four pieces light up one by one (staggered fade-to-white) before the end-of-game banner appears, so the result has time to register.
Keyboard navigation — Toggle hints with K. Digit keys 1–7 drop into columns; Esc opens the in-game menu; ← → cycle segmented controls in modals; Enter activates focused controls.
Accessibility — Visually-hidden buttons mirror the canvas-painted ones for screen readers, segmented controls implement the WAI-ARIA radiogroup pattern with roving tabindex, prefers-reduced-motion respected throughout.
The AI runs a classic two-player game search on a bitboard representation of the position. Medium and hard run inside a Web Worker so the UI keeps animating during multi-second searches; easy bypasses the worker (microseconds — round-trip would dominate).
Board ──► fromGameBoard() — 49-bit Pascal-Pons encoding
│
▼
chooseMoveAsync() — main thread, returns Promise<col>
│
▼
Web Worker (medium / hard only)
│
▼
Iterative deepening (depth 1, 2, … until budget or full solve)
└── Each pass seeds the next pass's move ordering via the TT
│
▼
Negamax + α-β at each ply
├── Transposition table (canonical 98-bit key: position | mask << 49n)
├── Move ordering: TT-best → killer moves → center-out static
├── Immediate-win shortcut (mate-soon score)
└── Recurse into legal children
│
▼
Leaf nodes: evaluatePosition() heuristic
├── 69 precomputed winning-line bitmasks
├── Threat density (3-in-a-row = 50, 2 = 10, 1 = 1)
├── Parity-aware threat overlay — favored row +30, off-parity +6
│ (Connect 4's zugzwang rule: first player wants threats on odd
│ rows from the bottom; second player wants even rows)
└── Center-column bonus
│
▼
Difficulty wrapper
├── Easy → no search; heuristic + weighted random; in-process
├── Medium → depth-7 cap, 800ms budget; tighter sandbagging
└── Hard → unbounded depth, 2.5s budget; fully solves late game
All move generation, win detection, and evaluation happen on BigInt bitboards (49 bits = 7 columns × (6 rows + 1 sentinel)), so winning-line checks are four shift-and-AND operations rather than a quadruple loop.
Worker cancellation. When the player resets mid-search, the only reliable way to interrupt a synchronous negamax loop in a worker is worker.terminate() — web workers are single-threaded, so a cancel postMessage can't fire while the search is running, and SharedArrayBuffer (which would let the main thread set an abort flag via Atomics) needs COOP/COEP HTTP headers that GitHub Pages can't set. The client terminates and respawns; the in-flight promise is orphaned and GC'd, and the existing AI generation counter discards the result if it ever did arrive. Worker creation is ~10ms on respawn — invisible against the AI's minimum-think-time floor.
The renderer is a pure-canvas paint loop driven by requestAnimationFrame. Layers composite in order:
background vignette → hole back-shadows → pieces → support feet
→ yellow board face with even-odd hole cutouts
→ hole rims → top-arch highlight → hover ghost
→ "CONNECT4" title → in-game MENU button → end banner
Pieces are drawn before the board face; the board's even-odd fill then punches holes through the yellow, revealing the pieces underneath. Drop animations use a custom easing (gravity-then-damped-bounce) tracked in a separate AnimState that lives outside React.
npm install
npm run dev # dev server at http://localhost:3000/react-connect4/
npm run lint # eslint over src/
npm run build # type-check + production bundle in ./dist
npm run preview # serve the production bundle locallyVite's base is set to /react-connect4/ in vite.config.ts so assets resolve correctly on GitHub Pages. The same base path applies to the dev server URL — use /react-connect4/, not /.
- React 19 — mounts the canvas, owns the modal layer
- Vite 6 — dev server, build, GitHub Pages base path
- TypeScript 5 — strict mode, project references
- Zustand 5 — game store + subscribe-driven AI scheduler
- Canvas 2D — everything board-related is canvas-painted
- BigInt bitboards — 49-bit Pascal-Pons encoding for the AI engine
- Poppins + Inter — display and body type via Google Fonts
src/
├── App.tsx Mount canvas, run rAF loop, orchestrate AI + attract
├── main.tsx React 19 createRoot entry
├── store.ts Zustand game store (state + actions)
├── constants.ts Board dimensions + shared types
├── helpers/index.ts checkGameBoard + isBoardFull
│
├── ai/
│ ├── bitboard.ts 49-bit Pascal-Pons encoding + primitives
│ ├── eval.ts Threat-density heuristic with parity overlay
│ ├── search.ts Negamax + α-β + TT + iterative deepening
│ ├── engine.ts Difficulty profiles (easy / medium / hard)
│ ├── worker.ts Web Worker entry: runs negamax off-main-thread
│ ├── engineClient.ts Main-thread async client; terminate-to-cancel
│ └── attractDemo.ts Self-playing demo behind the welcome modal
│
├── canvas/
│ ├── layout.ts All measurements + hit-boxes from canvas size
│ ├── animations.ts Mutable AnimState + easing + win-sequence timing
│ ├── input.ts Mouse / touch / keyboard wiring
│ └── scene.ts Paint pipeline (background → pieces → board → banner)
│
└── components/
├── WelcomeModal.tsx Pre-game setup with arrow-key segmented controls
├── ResetConfirmModal.tsx Mid-game "return to setup?" confirmation
├── KeyboardHintsToggle.tsx Viewport-fixed keyboard-hints toggle (desktop only)
└── GithubAttribution.tsx Bottom-right author + source link
CI is wired up via GitHub Actions:
.github/workflows/ci.yml— lint + build on every pull request and feature branch push..github/workflows/deploy.yml— builds and publishes to GitHub Pages on every push tomaster.
The deploy workflow uses the modern actions/deploy-pages flow (no gh-pages branch). One-time setup on the repo:
- Settings → Pages → Build and deployment → Source: select GitHub Actions.
- Push to
master(or run the workflow manually from the Actions tab).
If you fork the repo, also update the base value in vite.config.ts to match your repo name.