Don't look for a game — just start one. A browser arcade in plain HTML, CSS and JavaScript: 24 games, zero frameworks, zero build step, zero dependencies. AI ranging from pattern scoring to WebAssembly, multiplayer over D1 and polling, narration streamed from an LLM, all deployed to Cloudflare Pages in one command.
Play: happygames.pages.dev · Source: github.com/zhoujungis/happy_games
The web does not lack game portals; it lacks a sample project that can be read end to end and reproduced in one click. Happy Games is positioned explicitly as:
wrangler command ships it.Read this if you want a starting scaffold for a browser game, a real case of organising a large front-end without a framework, a minimal full stack on the Cloudflare stack (Pages / Functions / D1), or simply ten minutes of play.
| Dimension | Key fact |
|---|---|
| Games | 24 across four sections: board games 6 / puzzles 6 / casual 6 / Chinese history 6 |
| Constraints | Native HTML / CSS / JS (ES Modules), no framework, no build, no dependencies; double-click an HTML file to run locally |
| AI | Pattern scoring (gomoku) → alpha-beta pruning (Chinese chess) → GNU Go compiled to WebAssembly (Go) → three difficulty tiers (jungle chess / reversi) |
| Multiplayer | Aeroplane chess for 2–4 players in real time, rooms with passwords, D1 state, polling sync, AI filling empty seats |
| Narration | Life simulation and the history brawl call OpenRouter; the server proxies SSE to the browser |
| Backend | Cloudflare Pages Functions (file path = route) plus D1 (SQLite in the cloud) |
| Tests | node --test: 25 files, 288 cases (pure functions and engines); Playwright smoke tests on desktop and mobile |
| Deploy | npm run deploy → wrangler pages deploy .; layered caching configured in _headers |
| Design rules | Self-contained folders, pure-function isolation, a data-driven home page, offline-capable |
| Section | Theme | Six games |
|---|---|---|
| Board games | Strategy on a small board | Gomoku, Chinese chess, Go (GNU Go WASM), jungle chess, reversi, aeroplane chess (multiplayer) |
| Puzzles | Logic and deduction | Rule blocks, light refraction, sudoku, maze escape, echo walker, sokoban |
| Casual | Pick up and play | Spark survivors, life simulation (AI narration), flappy bird, hiking sim, jump jump, stack tower |
| Chinese history | Historical figures and institutions | Seven days of decree, Chinese merchant, Tang magistrate, guess the emperor, history brawl (AI judge), imperial exam |
| Game | One-line rule | Hard part |
|---|---|---|
| Gomoku | 15×15 against the computer | AI scores eight pattern weights (five in a row 100000, open four 10000, four-three 1000 …); three difficulties map to different search depths |
| Chinese chess | Human vs machine | Port of the open-source itlwei/Chess engine: move generation with piece rules, nine piece-square value tables, alpha-beta search; the server also offers a GLM-4.7-Flash move proxy as a fallback |
| Go | Human vs machine | GNU Go compiled to WebAssembly (~8.7 MB), running in a Web Worker with Komi 7.5; the main thread talks to it over {cmd:'init'/'play'/'genmove'}, with colour normalisation and a crash move log |
| Jungle chess | River and trap rules | Elephant beats lion, rat beats elephant, rivers and traps; three AI tiers; animal emoji pieces |
| Reversi | Eight-direction flipping | Three AI tiers plus undo |
| Aeroplane chess | The only multiplayer game | 2–4 players in real time, classic roll/jump/fly rules, AI filling empty seats so a game can start any time |
| Game | One-line rule | Highlight |
|---|---|---|
| Rule blocks | Push words to rewrite rules ("wall is stop", "water is sink") | Inspired by Baba Is You; live rule panel, multiple levels, undo and reset; the goal is to make any "you" meet any "win" |
| Light refraction | Rotate mirrors and splitters to light crystals | Dark grid, 45° rotation, a physics puzzle built on laser splitting |
| Sudoku | Classic 9×9 | Three difficulties, timed challenge, pencil marks, conflict highlighting |
| Maze escape | Timed traversal of a random maze | Higher difficulty means larger, twistier mazes; a new map every round |
| Echo walker | Every six steps, your old path becomes an echo chasing you | Shadow mechanic: collect seals while avoiding your past self; almost no tolerance for a bad route |
| Sokoban | 15 hand-made levels | Gradual difficulty, warm retro floor palette |
| Game | One-line rule | Highlight |
|---|---|---|
| Spark survivors | Vampire-Survivors-like | Auto-firing, experience drops, three-choice upgrades (fire rate, projectiles, armour …); the build decides how many waves you survive |
| Life simulation | Text life sim | Allocate talents (intellect, physique, fortune …); the AI streams six chapters from childhood to a final verdict, ending with a 0–100 life score |
| Flappy bird | Tap to fly through pipes | Blue sky, green grass, classic feel |
| Hiking sim | Three real routes: Aotai traverse, Gongga loop, Wusun trail | Terrain, supplies, altitude and weather — a hiking simulator in the browser |
| Jump jump | Hold to charge, release to jump | Perfect centre landings give combo bonuses; the subtle physics is the point |
| Stack tower | Drop swinging blocks precisely | Better alignment means a taller tower; overhang trimming and rising speed |
| Game | One-line rule | Highlight |
|---|---|---|
| Seven days of decree | Complete the imperial decree in seven days | Three actions per day across seven resources — silver, grain, public support, order, gentry, reputation, intelligence; report after three actions, settle on day seven |
| Chinese merchant | Trade across famous cities | Compete for wealth with three AI merchants; prices and rival strategies shift |
| Tang magistrate | Run a county for twelve seasons | Farms, markets and random events; balance tax, support, granary and order |
| Guess the emperor | Identify one of 200+ emperors | Unlimited guesses; early guesses give conventional clues, from the fifth onward a 50% chance to reveal an attribute comparison |
| History brawl | Two players draft, an AI judges | Random dynasties, ten posts to fill with historical figures; the AI judges by dynasty fit > role fit > actual ability > lineup synergy, in a storyteller's voice |
| Imperial exam | Seven stages, three multiple-choice questions each | Classics, history and ancient institutions; one wrong answer ends the run |
The AI here is never "pick a random move"; it spans four technical levels.
(1) Pattern scoring — gomoku
Eight directions scan the shapes and score them:
| Pattern | Score | Meaning |
|---|---|---|
| Five | 100000 | Already won |
| Open four | 10000 | Four with both ends open — forced win |
| Four / open three | 1000 | Four blocked at one end / three open at both |
| Three / open two | 100 | Three blocked at one end / two open |
| Two | 10 | |
| Single | 1 |
Difficulties control search depth: easy looks one move ahead and can slip; hard looks several ahead and prefers blocking your open three over completing its own four. Everything is a pure function, so node --test runs it directly.
(2) Alpha-beta pruning — Chinese chess
Ported from itlwei/Chess (MIT), keeping the engine core: move generation per piece rule (horse legs, elephant eyes, palace diagonals), piece-square value tables, and alpha-beta search with position evaluation. Board logic (xiangqi/logic.js) is fully separated from the engine (xiangqi/engine.js). A server endpoint /api/xiangqi-ai offers a GLM-4.7-Flash move proxy as a fallback when the local engine cannot produce a move in extreme positions.
(3) GNU Go → WebAssembly — Go
The hardest path in the project: the open-source GNU Go engine compiled to WebAssembly (weiqi/gnugo.js, about 8.7 MB), running in a Web Worker with a message protocol:
main → worker: { cmd: 'init', size, seed } start a game (Komi 7.5)
main → worker: { cmd: 'play', i, j } player move
main → worker: { cmd: 'genmove' } engine move
worker → main: { ok, cmd, board, last } board snapshot (colour-normalised)
The worker also does three important things: colour normalisation (the player's colour maps to 1, the other to 2, independent of GNU Go's internal constants, so rendering is always consistent); a move log so a crashed game can be replayed; and stdout/stderr capture through Module.print and printErr for diagnosis.
A world-class board engine running in the browser without blocking the main thread is a complete demonstration that front ends can host heavy AI.
(4) Three difficulty tiers — jungle chess and reversi
Both split AI by search depth into three tiers; reversi adds undo. Same pure-function approach, fully unit-testable.
The only multiplayer game among the 24. Three layers:
create/join → state advance (roll/move/start) → polling sync
│ │ │
▼ ▼ ▼
/api/feixingqi/create /api/feixingqi/roll client polls every 1.5s (in game)
/api/feixingqi/join /api/feixingqi/move / 2s (lobby); the server runs tick() to
/api/feixingqi/start advance AI turns before responding
POST /api/feixingqi/create creates a password-protected room and POST /api/feixingqi/join joins one; room state serialises to JSON in a single D1 row (code / password / state(JSON) / created_at / updated_at)./api/feixingqi/state; before responding the server runs tick() to advance AI turns, so players always see the newest state with everyone else's moves applied.D1 is both database and cache here — good enough, and it avoids the operational cost of WebSockets.
Life simulation and the history brawl bring an LLM into the game, with the server proxying SSE.
Life simulation (/api/life/narrate)
{attributes};inclusionai/ling-3.0-flash:free) and forwards the SSE stream verbatim as text/event-stream;Prompt constraints:
You are the randomiser AI of a Chinese life simulator. Output exactly six headings, one per line:
[Childhood] [Youth] [Adulthood] [Middle age] [Later life] [Verdict]
The first five are 130-180 characters each, and must include concrete events, their impact,
and how they change the next stage. The final section must begin with "Life score: NN." (NN 0-100).
History brawl (/api/battle/analyze)
Both are lightweight "game + LLM" patterns: the server only authenticates, rate-limits and forwards the stream, never storing model state; the client consumes standard SSE and a failure does not break the game loop.
POST /api/register|login|logout; passwords live in the D1 users table, sessions use a random token in the hg_token cookie with a 30-day expiry check;GET /api/me lets the front end know the login state; home shows "username · logout", and the admin account unlocks a hidden console at /adminPages/;functions/_middleware.js: unauthenticated HTML page requests 302 to /login.html, while static assets and /api/* pass; admin paths additionally require the admin username or return 403;GET /api/admin/users and GET /api/admin/game-stats._headers:
.css/.js/.mjs → max-age=300, stale-while-revalidate=86400;.wasm → max-age=604800, immutable — the GNU Go engine loads once and is cached for a week;max-age=86400, stale-while-revalidate=604800;shared/game-runtime.js owns lifecycle, score and event reporting, and a failure there never blocks the game loop.| Layer | Technology | Notes |
|---|---|---|
| Frontend | Native HTML / CSS / JS (ES Modules) | No framework, no build, no dependencies; shared/theme.css defines the whole visual vocabulary (ink, vermilion, gold, jade) |
| Board rendering | Hand-written SVG / Canvas | Per game; pure-function previews exported from shared/landing.js |
| AI | GNU Go → WebAssembly (worker) / alpha-beta / pattern scoring / minimax | A world-class engine inside the browser |
| Cloud AI | OpenRouter (streamed narration) / Moark (GLM-4.7-Flash chess proxy) | SSE passthrough |
| Backend | Cloudflare Pages Functions | File path becomes the route, no configuration |
| Database | Cloudflare D1 | Seven tables: users / sessions / battle_rooms / feixingqi_rooms / game_sessions / game_events / game_scores |
| Tests | Node's built-in runner (25 files, 288 cases) plus Playwright smoke | Pure functions plus browser E2E |
| Deploy | Cloudflare Pages | wrangler.toml binds Pages and D1 |
| Caching | _headers plus _routes.json | Fine-grained control, static assets skip Functions |
index.html home: section cards + daily picks + random game
/board /puzzle /fun /history section pages with that section's cards
shared/landing.js ★ single source of truth: sections + game config + SVG previews
shared/game-runtime.js lifecycle, score and feedback event queue
shared/theme.css / cards.css / game.css
each game = one self-contained folder
<game>/index.html page shell plus rules dialog
<game>/<game>.js interaction and rendering (may include a worker)
<game>/<game>.css its own styles
<game>/ai.js pure AI, no DOM, unit-testable
<game>/engine.js pure logic engine (moves, rules, state machine)
<game>/logic.js board rules (captures, liberties)
<game>/levels.js level data (e.g. 15 sokoban levels)
Three design rules.
shared/theme.css defines the CSS variables (--ink, --accent-red, --accent-gold, --border, --surface, --shadow-soft …), shared/cards.css styles the cards, shared/game.css the in-game layout and state feedback. Every game reuses one visual language instead of inventing colours.shared/landing.js is the only data source: sections defines four sections × six games with id / name / description / href / accent / previewSvg, renderHome() draws the four entry cards and renderSection(id) draws that section's six cards. Recommendations, the random button and counts all derive from this — adding a game touches one place.Shared runtime (shared/game-runtime.js) creates a session per game, records key events, syncs score snapshots and submits results on finish or page leave: createGameRuntime({gameId, autoStart}) generates a sessionId, start() posts /api/game/sessions, setScore() deduplicates and pushes a score_snapshot, finish(outcome) posts /api/game/finish and flushes. Events batch with MAX_QUEUE=60 / FLUSH_SIZE=6 / FLUSH_DELAY=1600ms and an idempotent eventId; a network failure requeues instead of blocking the loop. A MutationObserver watches #score, [data-score], .score-value and #status to infer results, custom events (happy-game-score, happy-game-event) let a game report directly, and pagehide uses navigator.sendBeacon so scores are not lost.
The philosophy: the domain layer must not know about DOM, canvas, network or database. Pages consume domain snapshots, the runtime owns lifecycle and feedback, and
ai.js/engine.jsstay pure so Node can test them.docs/architecture.mdfixes the boundary:page → shared UI/runtime → game domain → API client → Pages Functions → D1.
Everything under functions/api/; the file name is the route.
| Endpoint | Method | Purpose |
|---|---|---|
/api/register | POST | Create a user in D1 |
/api/login | POST | Sign in, issue the hg_token cookie (30 days) |
/api/logout | POST | Sign out, destroy the session |
/api/me | GET | Current session ({username} or 401) |
/api/xiangqi-ai | POST | Chess move proxy (FEN in, move out, via Moark) |
/api/life/narrate | POST | Streamed life story from attributes (SSE) |
/api/battle/create join pick state | POST/GET | History brawl rooms, drafting and state |
/api/battle/analyze analysis | POST/GET | AI judge, round by round, streamed |
/api/feixingqi/create join start roll move state | POST/GET | Aeroplane chess rooms, rolling, moving, polling |
/api/admin/users | GET | Admin: user list |
/api/admin/game-stats | GET | Admin: sessions and score statistics |
/api/game/sessions | POST | Start a game session |
/api/game/events | POST | Batch key events (idempotent event_id, whitelisted types) |
/api/game/finish | POST | Settle and write the final score |
Shared helpers live in functions/api/feixingqi/_shared.js (json(), parseCookie(), whoami(), loadRoom(), saveRoom()) and functions/api/game/_shared.js. Business logic sits in functions/services/game-service.js, D1 access in functions/repositories/game-repository.js — Functions are transport adapters, services own the workflow, repositories own the SQL.
schema.sql defines seven tables; incremental changes live in migrations/. New databases run the whole schema; existing ones apply migrations in order.
users -- username PK, password, created_at
sessions -- token PK, username, expires_at (idx_sessions_username)
battle_rooms -- brawl rooms: code PK / password / state(JSON) / timestamps
feixingqi_rooms -- aeroplane rooms: same shape, state holds seats/pieces/turn/dice/log
game_sessions -- one row per run: id, game_id, username, anonymous_id, started_at,
-- ended_at, status, score, duration_ms, metadata(JSON)
game_events -- whitelisted events: event_id UNIQUE, session_id, type, score, payload
game_scores -- final scores: session_id PK, game_id, username, score, outcome, duration
Room state is one JSON row (simple enough), multiplayer uses client polling plus a server state machine, and D1 is both database and cache. Indexes cover the hot queries: game_sessions(game_id, started_at), game_events(session_id, created_at), game_scores(game_id, score DESC).
Requires Node.js 18+ (both wrangler and node --test need it).
git clone https://github.com/zhoujungis/happy_games.git
cd happy_games
npm install # only wrangler and playwright, no frontend dependencies
npm run dev # static assets plus Pages Functions
# open http://localhost:8788
wrangler pages dev .reads the D1 binding fromwrangler.toml. Local D1 must be created or synced first:npx wrangler d1 create happy-games-db # put the database_id into wrangler.toml npx wrangler d1 execute happy-games-db --local --file=schema.sql # or, for a remote database you already created in the dashboard: npx wrangler d1 execute happy-games-db --remote --file=schema.sql
npm test # Node's test runner over tests/*.test.js (25 files, 288 cases)
npm run check # tools/check.mjs: JS syntax plus HTML charset/viewport checks
npm run test:e2e # Playwright smoke: desktop and mobile home, canvas games, overflow
Testing in Node works because every AI and engine is a DOM-free pure function (gomoku/ai.js, xiangqi/engine.js, weiqi/logic.js, feixingqi/engine.js …): importing is enough to assert.
The site is published directly to Cloudflare Pages (pages_build_output_dir = ".") with no build step.
Step 1: create the D1 database. In the Cloudflare dashboard → Workers & Pages → D1 SQL Database → Create database, name it happy-games-db, then put the id into wrangler.toml:
name = "happy-games"
compatibility_date = "2025-01-01"
pages_build_output_dir = "."
[[d1_databases]]
binding = "DB"
database_name = "happy-games-db"
database_id = "your-database-id"
Then create the tables and verify:
npx wrangler d1 execute happy-games-db --remote --file=schema.sql
for f in migrations/*.sql; do npx wrangler d1 execute happy-games-db --remote --file="$f"; done
npx wrangler d1 execute happy-games-db --remote --command "SELECT name FROM sqlite_master WHERE type='table';"
Step 2: environment variables. In the Pages project → Settings → Variables and Secrets, add OPENROUTER_KEY if you want the life-simulation narration; without it that feature returns 503 and nothing else is affected. A separate key is needed if you use the Moark chess proxy (see the comment at the top of functions/api/xiangqi-ai.js).
Step 3: deploy.
npm run deploy
# equivalent to npx wrangler pages deploy .
The first run creates the happy-games Pages project; later runs publish incrementally, with Cloudflare handling the domain and HTTPS. Caching comes from _headers, and _routes.json keeps static assets out of Functions so only /api/* and HTML hit the worker:
{
"version": 1,
"include": ["/*"],
"exclude": ["/*.css","/*.js","/*.mjs","/*.wasm","/*.png","/*.svg","/robots.txt","/rss.xml"]
}
Common traps
| Symptom | Cause | Fix |
|---|---|---|
/api/me 401 while signed in | The hg_token cookie is not sent | Check credentials: 'same-origin' and that you are on the Pages domain, not file:// |
| Life simulation 503 | OPENROUTER_KEY missing | Add the variable, then retry the deployment |
| D1 "no such table" | schema.sql never ran remotely | Run wrangler d1 execute --remote --file=schema.sql again |
Slow .wasm load | Cache miss | Confirm _headers marks .wasm immutable; a second visit should come from cache |
Thanks to the data-driven design, adding a game takes two steps.
1. Create the folder (mirroring gomoku/):
<game>/
├── index.html # shell: header + board/canvas + rules dialog, imports shared CSS
├── <game>.css # game-specific styles
├── <game>.js # interaction and rendering (may include a worker)
└── ai.js # pure AI, testable with node --test
The HTML needs <meta charset>, <meta viewport>, a board container, score and status elements (#score, #status for the runtime's auto-snapshot), and a rules dialog; a module script imports <game>.js and calls createGameRuntime().
2. Register it in the matching section's games array in shared/landing.js:
{
id: 'mygame', // folder name and path segment
name: 'My game',
description: 'One-line summary',
href: '/mygame/',
accent: '#2980b9',
previewSvg: `<svg viewBox="0 0 120 120">…</svg>`,
}
That is all: the home section cards, section pages, daily picks and the random button all include the new game without touching another file. That is what a single source of truth buys.
Constraints from
docs/architecture.md: keep existing URLs and static deployment stable; migrate a representative game before batch migration; keep pure-logic tests green; add browser coverage for lifecycle, score, offline and responsive behaviour.
├── index.html home: sections + daily picks + random game
├── login.html login and registration
├── adminPages/ admin console (owner only)
├── board/ puzzle/ fun/ history/ section pages
├── shared/
│ ├── theme.css global variables and base styles
│ ├── cards.css card styles
│ ├── game.css in-game shared styles
│ ├── game-runtime.js ★ lifecycle, score and events
│ └── landing.js ★ the single data source
├── functions/
│ ├── _middleware.js login guard and admin auth
│ ├── services/ game-service.js
│ ├── repositories/ game-repository.js
│ └── api/
│ ├── register.js login.js logout.js me.js
│ ├── xiangqi-ai.js
│ ├── life/narrate.js
│ ├── battle/*.js brawl rooms and AI judge
│ ├── feixingqi/*.js multiplayer aeroplane chess
│ ├── game/*.js sessions, events, settlement
│ └── admin/ admin endpoints and statistics
├── <game>/ 24 self-contained game folders
├── schema.sql seven tables
├── migrations/ incremental D1 migrations
├── tests/ 25 files, 288 cases
├── e2e/ Playwright smoke tests
├── tools/check.mjs syntax and HTML meta checks
├── wrangler.toml Pages and D1 configuration
├── _headers layered cache policy
├── _routes.json static assets skip Functions
├── assets/ README preview images exported from landing.js
└── docs/architecture.md boundaries and migration rules
Not nostalgia — a trade-off:
ai.js and engine.js depend on nothing; Node imports them directly, and moving to a mini-program, Electron or a server would only require rewriting the rendering layer._headers caching makes repeat visits nearly offline.Zero frameworks does not mean zero conventions. The variable system in shared/theme.css, the single data source in landing.js, the lifecycle contract in game-runtime.js and the layering in functions/services form an invisible framework — agreed upon, not depended upon.
Happy Games tries to prove that even in the age of heavy front-end frameworks, native technology can still deliver a complete, maintainable, deployable full-stack product — as long as the boundaries are drawn (domain separate from rendering, data-driven home, layered backend), AI is written as pure functions, multiplayer as a state machine, and deployment handed to Cloudflare.
Each of the 24 games is its own small world: strategist on a board, escapee in a maze, merchant on a trade route, courtier under a seven-day decree, scholar in an imperial exam. Don't look for a game — start one.
Further reading: github.com/zhoujungis/happy_games · happygames.pages.dev · GNU Go · Cloudflare Pages · Cloudflare D1
Written against the
masterbranch ofhappy_gamesas of September 2026; endpoints and tables followschema.sqlandwrangler.tomland may drift with later versions.
Along the same chain: NanoBrowser: hand-writing an in-process CDP and Halo Music: a multi-source music app.