TL;DR — Over the last few months I built Mini Game Fun — 50 classic browser games (Snake, Sudoku, Tetris, 2048, Minesweeper, and some originals). Free, no ads, no signup.
Tech stack: React 19 + TypeScript + Vite, Django + DRF, PostgreSQL, Nginx, Rocky Linux VPS. Nothing fancy — the interesting parts are the three problems below.

The homepage — 50 games, all playable on desktop, tablet, and mobile.
1. Generating puzzles with guaranteed unique solutions
I built Duckdoku — a logic puzzle where you place ducks on a colored grid:
- One duck per row
- One duck per column
- One duck per color region
- No two ducks adjacent (including diagonals)
It's Sudoku meets graph coloring.
The hard part isn't generating a valid solution. It's proving it's the only one.
My approach: propagate first, backtrack second
I implemented 4 constraint-propagation rules:
- R1: If a color has only one remaining candidate → fix it there
- R2: If all candidates for a color are in one row/column → that color owns it
- R3: If a row/column has only one color's candidates → that color owns it
- R4: Generalized Naked Subset — if N rows have candidates totaling exactly N colors, that subset is locked
Here's R1 simplified:
def propagate(candidates, chosen, N):
changed = True
while changed:
changed = False
for color in COLORS:
if color in chosen: continue
if len(candidates[color]) == 1:
r, c = next(iter(candidates[color]))
chosen[color] = (r, c)
# Remove (r,c) from all other colors
for other in COLORS:
if other != color:
candidates[other] = {
(rr, cc) for rr, cc in candidates[other]
if rr != r and cc != c
}
# Remove neighbors (rule 4)
for dr in (-1, 0, 1):
for dc in (-1, 0, 1):
if dr == 0 and dc == 0: continue
nr, nc = r + dr, c + dc
if 0 <= nr < N and 0 <= nc < N:
for other in COLORS:
candidates[other].discard((nr, nc))
changed = True
break
return True
After propagation, if not all colors are fixed, fall back to backtracking. Count solutions — accept the puzzle only if exactly one exists.

How unique solutions are guaranteed: propagate first, backtrack if needed.
Why this matters
Without R1–R4, backtracking on a 10×10 board takes seconds. With propagation, most puzzles solve in under 5ms, and I only need ~100 attempts to find a puzzle that's both unique and visually balanced.

400× speedup: constraint propagation makes 10×10 puzzles solvable in 5ms.
Lesson: propagation first, backtracking second. Always.
2. SEO for a SPA without Next.js
React SPAs are invisible to Google — it sees an empty <div id="root">.
I tried three approaches:
| Approach | Result |
|---|---|
react-helmet for meta tags |
❌ Google saw nothing |
| Puppeteer prerendering at build time | ⚠️ 400MB of dependencies for a static site |
| Custom Node prerender script | ✅ 60 lines, zero dependencies |
The winning solution was embarrassingly simple. Since each route only needs a title, description, and intro paragraph, I wrote a build-time script that:
- Reads
dist/index.html(the Vite output) - For each route, replaces
<title>,<meta>, and injects an<article>into<div id="root"> - Writes to
dist/game/{slug}/index.html
// scripts/prerender.mjs (simplified)
import fs from 'node:fs';
import path from 'node:path';
const seoData = JSON.parse(fs.readFileSync('src/seo-data.json', 'utf8'));
const template = fs.readFileSync('dist/index.html', 'utf8');
for (const [slug, {title, description, intro, path: route}] of Object.entries(seoData)) {
let html = template
.replace(/<title>.*<\/title>/, `<title>${title}</title>`)
.replace(
/<div id="root"><\/div>/,
`<div id="root"><h1>${title}</h1><p>${description}</p><article>${intro}</article></div>`
);
const out = route === '/'
? 'dist/index.html'
: path.join('dist', route.slice(1), 'index.html');
fs.mkdirSync(path.dirname(out), { recursive: true });
fs.writeFileSync(out, html);
}
Then Nginx serves prerendered HTML to crawlers:
location / {
try_files $uri/index.html $uri $uri/ /index.html;
}
The $uri/index.html must come first — otherwise Nginx returns 301 to add a trailing slash, and Google sees inconsistent URLs.
Result: 11 pages indexed within 3 days of submitting sitemaps.
Lesson: You don't need Next.js for a static-ish SPA. A 60-line build script does 95% of what you need.
3. Async cache pool without thundering herd
Generating puzzles takes ~200ms each. I cache pre-generated puzzles per board size, but the first request used to block while generating 30 of them.
Fix: async replenishment with a soft threshold.
POOL_SIZE = 30
TRIGGER_LOW = 15
REPLENISH = 15
def _get_from_pool(size):
with _locks[size]:
if not _pools[size]:
item = _generate_one(size) # synchronous fallback
remaining = 0
else:
item = _pools[size].pop()
remaining = len(_pools[size])
# Async refill in background thread
if remaining <= TRIGGER_LOW:
_async_replenish(size, REPLENISH)
return item
A per-size lock prevents duplicate refill jobs. Background workers use a ThreadPoolExecutor(max_workers=2).
Result: p99 response time for /api/games/generate/ is under 10ms.
Lesson: precompute, refill asynchronously, never block the request path.
Two smaller gotchas
Cloudflare real IP — request.META['REMOTE_ADDR'] is Cloudflare's IP, not the user's. Fix in Nginx:
set_real_ip_from 173.245.48.0/20; # Cloudflare ranges
real_ip_header CF-Connecting-IP;
real_ip_recursive on;
And in Django:
SECURE_PROXY_SSL_HEADER = ('HTTP_X_FORWARDED_PROTO', 'https')
Nginx add_header inheritance — nested location blocks override parent add_header rules. My security headers disappeared from /assets/* until I noticed this. Fix: put shared headers in a snippet file and include it in every location.
What I'd do differently
- Start with a monorepo — I wasted 2 weeks on a two-repo setup before merging.
- Add analytics from day one — I didn't, and now I'm retroactively trying to figure out which games are popular.
- Build SEO infrastructure first — I added prerendering after launch, and had to wait for Google to reindex.
- Write fewer games, but better — 50 games is impressive, but some are rough. 20 polished games would probably engage users more.
Try it
mini-game.fun — no download, no signup, no ads.
Games I'm most proud of:
- 🦆 Duckdoku — the logic puzzle with the propagation engine above
- 🐍 Snake — canvas-based, with auto-acceleration
- 🎮 Tetris, 2048, Sudoku, Minesweeper — classic, polished
- Plus 44 more
Questions for the community
- What's your approach to SEO on SPAs? I've been considering Astro for marketing pages.
- Do you prerender or SSR? Curious how others balance DX and SEO.
- Any tips for constraint propagation in puzzle generators? I'd love to see more sophisticated rules than my R1–R4.
Happy to answer technical questions in the comments. 🦆
If you enjoyed this, follow me for more posts on backend architecture, side projects, and constraint solving.

Top comments (0)