DEV Community

Devin
Devin

Posted on

18 Games, 617 Assertions: What a One-Person Static Arcade Taught Me About Testing

I wanted games that open the moment you click. No install, no sign-in, no loading screen, no cookie banner. Tap a link on a mediocre phone and you are already playing. That sounds like a small constraint. It ended up deciding almost every technical choice in the project.

The site now has 18 games — board games, puzzles, card games. All free, no login, each one a single file, no external dependencies, no trackers. Plain HTML, CSS and JavaScript. No framework, no build step. The boards and pieces are inline SVG I drew by hand.

This is not a story about clever architecture. It is a story about how I convinced myself the games actually work, and about two measurement mistakes that nearly made me ship broken pages.

Why static, and why hand-drawn

A static site is the only shape that satisfies the constraints. Every game is a file you can open from disk or put on any static host. There is no server that can be down, no database to migrate, nothing to keep warm. On a weak connection the whole game is one HTML document, loaded once.

No framework because a board game is a state machine. A position, a list of legal moves, and an event handler. Vanilla JavaScript expresses that directly. A framework would add a payload larger than the game itself, plus a build step and a dependency tree that has to stay current. I would rather spend that budget on tests.

Hand-drawn SVG because it keeps the promise of a single file. Raster images mean extra requests and resolution decisions; an SVG board scales to any screen and stays sharp. Inline SVG also gives one source of truth for a piece: the same shape can be filled, highlighted and animated. And there is no image licensing to audit.

One rule follows from all of this: no game may depend on another game, a shared runtime, or a CDN. If one page breaks, the other 17 are unaffected, and the broken page is still readable as source.

The hard part: testing without looking

Testing a game is not like testing a form. You cannot look at a board and decide whether it is correct. A position can look plausible and be wrong two moves deeper. So most of the suite is built around things you can count.

The core tool is perft: walk the move generator to a fixed depth and count leaf nodes. The counts are exact. If one edge case in move generation is wrong, the number diverges immediately.

For Chinese chess (xiangqi), perft at depths 1, 2 and 3 gives 44, 1,920 and 79,666. Those match the standard published values.

For international chess, perft(1/2/3) is 20, 400 and 8,902. On top of that, 37 targeted tests cover the rules that perft alone does not isolate: 11 for castling, 7 for en passant, 7 for promotion, 3 for checkmate and 5 for stalemate.

Go scoring is checked against three fixed boards with known results: 45:36, 42:39 and 40.5:40.5. The half-point case is the useful one. It forces the scoring code to keep fractional territory correct — a mistake that is invisible when you eyeball a board.

The 24 game, where you make 24 from four numbers, has a different failure mode: a deal can be generated correctly and still be unsolvable, and a solver can be too eager and claim a solution that does not exist. So the test deals 1,000 hands in a row and independently verifies every one is solvable, covering 669 distinct combinations. That catches both duplicate deals and a solver that overreports.

Dou Dizhu is tested by self-play: 200 AI-versus-AI games, 0 illegal plays, landlord win rate 63.0%. The first number is a rules-engine regression test. The win rate is a sanity check that the AI is not reaching its result by breaking the rules. I am not claiming the AI is strong — only that it obeys the rules and wins at a plausible rate.

Connect Four gets a relative check: the harder AI plays the simple AI 20 times and wins 20. That says the stronger setting is actually stronger. It says nothing about how strong it is against a human.

All of it runs with one command:

node games/tests/run-all.js
Enter fullscreen mode Exit fullscreen mode

17 test files, 617 assertions. The single command matters more than the count. If running the tests is a chore, they rot. If it is one command that prints pass or fail, I run it after every change.

There is one more check that unit tests cannot see: a crawl of the whole site. It currently covers 108 pages and 112 internal links, and finds 0 404s. That is where a broken relative path gets caught — not in the game logic, but in the way the pages point at each other.

A note on what these tests do not prove. Perft proves that move generation matches a known count at a shallow depth. It does not prove the UI renders the move correctly, that the AI is pleasant to play against, or that the game is fun. Those still need a person. The tests exist to keep the parts that a person cannot check by eye from silently drifting.

Multiplayer: where the server earns its place

Multiplayer runs on Cloudflare Workers. A Durable Object per room handles signaling; voice is WebRTC mesh, capped at 4 players.

The Durable Object is there because signaling is stateful. A room has a membership list, join and leave events, and offer/answer routing between peers. In a stateless function you would have to invent a store and handle concurrent updates yourself. A Durable Object is one object per room, and requests to it are serialized. That removes a class of race conditions by construction.

For three of the board games the server is authoritative: the Durable Object validates moves, so a modified client cannot make an illegal move. In single-player that is a nicety. In multiplayer it is the difference between a game and a free-for-all, because the other players cannot see your screen.

The mesh is a deliberate ceiling. With 4 players, each peer sends audio to 3 others — 6 connections in total. At 10 players it would be 45, and uplink becomes the bottleneck. So 4 is a hard cap, not an arbitrary number.

Two mistakes that lied to me

The first was the 404 fallback. Static hosts often serve the index page for unknown paths, so a typo returns 200 with the home page attached. My first link checker asked, "did this URL return 404?" It reported no problems while links were broken. The fix was to stop trusting the status code and ask whether the page that came back is the page that was requested — compare title and content, not the status.

The second was "both deployments succeeded". The site ships to Cloudflare Pages and GitHub Pages. A successful deployment does not mean identical bytes: build order, caching and line endings can all differ. The only claim I trust is an md5 comparison of the same path fetched from both hosts. Right now every page is byte-identical. That is a much stronger statement than two green checkmarks, and it is the one I put in the README.

Both mistakes share a shape: I measured a proxy for correctness instead of correctness itself. A status code is not a page. A green deploy is not the same artifact.

Limits I would rather state

The mesh voice only makes sense at 4 players. There are no accounts and no match history. Some of the AIs are heuristics, not search — they play legally and reasonably, and they will not beat a strong human. The site has no analytics, which also means I cannot tell you which game people like most.

That is the honest shape of a one-person static project: a small surface, a lot of counting, and a few places where the only reliable test is a byte comparison.

If you want to try it, the games are here: https://hhfa008.github.io/games/

Top comments (0)