I wanted a games site my whole family could use: free, no sign-up, no backend to babysit, and every game playable three ways — solo, against the computer, or with a friend on the same screen.
The result is PlayTrova: 17 browser games (board games, puzzles, racing and math games) served as a fully static Next.js site on Cloudflare Pages. Here's how it's put together, and a few things I learned along the way.
Transparency note: I built PlayTrova with an AI coding assistant (Claude Code) doing much of the implementation, and this write-up was drafted with its help too. The architecture choices and the lessons below are real — the site is live, so you can poke at all of it.
The core idea: a game is pure logic, a mode is just who fills each seat
Every turn-based game implements the same small contract:
export interface TurnGame<S, M> {
init(cfg: GameConfig, seed: number): S;
current(s: S): Seat; // whose turn
moves(s: S): M[]; // legal moves
apply(s: S, m: M): S; // pure: returns a new state
status(s: S): Status; // playing | win | draw
encode(m: M): string; // moves travel as short strings
decode(x: string): M;
view?(s: S, seat: Seat): S; // hide what a seat must not see (memory cards)
}
No Math.random, no Date.now inside game logic — randomness comes from a seed stored in the state. That one rule pays off everywhere:
- Modes are free. Solo, vs Computer and vs Friend are the same game with different things sitting in the seats: a local human, an AI, or a second local human.
-
Saves, undo and replays are just
{ seed, config, moves[] }. Replay the moves and you get the exact same state. - Daily challenges are the same for everyone. The date is hashed into a seed, so every player in the world gets today's identical puzzle, dice rolls or mosquito swarm.
/** Small, fast, seedable PRNG. */
export function mulberry32(seed: number): () => number {
let a = seed | 0;
return () => {
a = (a + 0x6d2b79f5) | 0;
let t = Math.imul(a ^ (a >>> 15), 1 | a);
t = (t + Math.imul(t ^ (t >>> 7), 61 | t)) ^ t;
return ((t ^ (t >>> 14)) >>> 0) / 4294967296;
};
}
It also makes the logic very testable: the project has 744 Vitest tests, most of them plain "apply these moves, expect this state" checks.
The computer opponents live in a Web Worker
Board-game AI is a generic iterative-deepening alpha-beta search with a time budget, running in a Web Worker so the page never freezes. Each game only supplies an evaluation function.
Difficulty isn't just search depth. Every game has the same four characters — Rookie Robo, Clever Cat, Sly Fox and Grandmaster Owl — and each one is a profile: depth, time budget, how often it blunders, and for real-time games a reaction delay and aim error. Kids care far more about "I beat the fox!" than about "Hard mode".
Online play without a server
Turn-based games can also be played online with an invite link. There's no game server: two browsers connect peer-to-peer over WebRTC (PeerJS handles the signalling), the host's browser is the referee, and both sides validate every move. Because moves are short strings and the logic is deterministic, reconnecting is just "send me the move list again".
Real-time games share the same shell
Not everything is turn-based. Turbo Trails is a pseudo-3D racer, Snap Duel is a reflex party game, and Mosha Zap has you swinging an electric racket at mosquitoes in a Bangladeshi bedroom (load shedding included). These run their own simulation loop with canvas rendering on top, and their daily solo challenges still come from the date seed, so everyone races today's trail or faces today's swarm. The split-screen modes track each finger by its pointer id, so two kids can play on one tablet.
Fully static, on purpose
output: "export" means next build writes plain HTML/JS, and Cloudflare Pages serves it for free. Each game page is a server-rendered page (title, rules, strategy tips, FAQ, JSON-LD) with the playable area loaded lazily on the client. Progress, stars and streaks live in localStorage — no accounts.
Two gotchas worth knowing if you do the same:
1. Unknown dynamic routes in dev. With output: "export", next dev throws a "missing param in generateStaticParams()" error for a mistyped slug instead of rendering your 404 page. The fix is to only export in real builds:
import { PHASE_DEVELOPMENT_SERVER } from "next/constants";
export default function config(phase: string): NextConfig {
return {
output: phase === PHASE_DEVELOPMENT_SERVER ? undefined : "export",
trailingSlash: true,
images: { unoptimized: true },
};
}
With dynamicParams = false on the route, dev now shows the real 404 page, and production still serves 404.html for unknown paths.
2. Don't stamp your sitemap with the build time. My first sitemap set lastModified: new Date() on every URL, so every deploy claimed that every page had changed. Google ignores lastmod when it isn't trustworthy, so now only pages with a real edit date (the guides) carry one.
Shipping the same games to itch.io
I also wanted the original games on itch.io, which prefers games uploaded as self-contained HTML5 zips. Rather than maintain a second codebase, a small Vite config builds a single game straight out of the Next.js source:
-
next/linkandnext/navigationare aliased to tiny shims, so links to the rest of the site open in a new tab instead of navigating the iframe. - A virtual module replaces the "load any game" registry with a loader for just that one game, so each zip only contains what it needs.
-
base: "./"keeps every asset path relative, because itch.io serves the game from a sub-folder.
One npm run itch builds all six originals, zips them with forward-slash paths (itch.io's servers are Linux), and renders a 630×500 cover for each.
Try it
- All games — no sign-up, works on phones and tablets
- Originals worth a look: Tilt Four (four in a row, but you can tilt the whole board once), Lucky Lanterns (push-your-luck dice race to 100) and Math Race (first right answer scores)
I'd love feedback — especially on the AI difficulty curve and how the split-screen modes feel on your devices.
Top comments (2)
The TurnGame contract with a pure
applyis a great design — making "who fills each seat" the only difference between solo / vs-CPU / local multiplayer is really elegant, and it makes replays and testing basically free. Did you hit any issues with static export and per-game OG images, or did you pre-render them all at build time? Also +1 for the transparency note.Thanks Ethan! They're pre-rendered, but outside
next build: a small script (npm run og) draws an SVG per game and turns it into a PNG with sharp inpublic/og/, and the static export just copies them over.One gotcha: a page-level
openGraphin Next replaces the layout's instead of merging, so a couple of my pages lost theirog:imageuntil I added it back.And thanks for the +1 on the note!