Dev.to WebDev 🛠 Dev 👁 1 📖 5 min read

I built a site with 50 browser games — here are 3 things I learned the hard way

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

I built a site with 50 browser games — here are 3 things I learned the hard way

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.

Mini Game Fun homepage — 50 free browser games
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:

  1. One duck per row
  2. One duck per column
  3. One duck per color region
  4. No two ducks adjacent (including diagonals)

It's Sudoku meets graph coloring.

Duckdoku — 4 rules
The 4 rules of Duckdoku.

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.

Duckdoku generation flow
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.

Performance comparison
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:

  1. Reads dist/index.html (the Vite output)
  2. For each route, replaces <title>, <meta>, and injects an <article> into <div id="root">
  3. 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

  1. Start with a monorepo — I wasted 2 weeks on a two-repo setup before merging.
  2. Add analytics from day one — I didn't, and now I'm retroactively trying to figure out which games are popular.
  3. Build SEO infrastructure first — I added prerendering after launch, and had to wait for Google to reindex.
  4. 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

  1. What's your approach to SEO on SPAs? I've been considering Astro for marketing pages.
  2. Do you prerender or SSR? Curious how others balance DX and SEO.
  3. 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.

📰 Read the original article on Dev.to WebDev

Originally published by Dev.to WebDev. Aggregated on AIWithGhost for educational purposes — full credit and traffic to the original publisher.