DEV Community

Yurukusa
Yurukusa

Posted on • Edited on

I Built a Complete Puzzle Game in a Single HTML File (Zero Dependencies)

I shipped a complete browser puzzle game in 1,400 lines of HTML. No bundler, no framework, no dependencies.

Here's what I learned — and why I'd do it again.

The Game

Primal Fuse is an element-merging puzzle. Start with Fire, Water, Earth, Wind. Merge adjacent tiles to discover 30 hidden elements across 4 layers.

šŸŽ® Play it here

The entire game — HTML structure, CSS animations, and game logic — lives in a single index.html file.

Why Single-File?

I'm building a game factory: AI-generated browser games that can be distributed anywhere instantly.

Single-file games have a critical advantage: zero deployment complexity.

  • itch.io? Upload one file.
  • GitHub Pages? Push one file.
  • Share with a friend? Email one file.
  • Embed in a blog? Paste one file.

When your goal is maximum distribution with minimum friction, complexity is the enemy.

The Architecture

The file follows a strict section order:

<html>
  <head>
    <style>   ← All CSS: ~400 lines
    </style>
  </head>
  <body>
    <!-- All HTML elements: ~60 lines -->
    <script>
      // Constants + Data: ~200 lines
      // Game state + DOM refs: ~50 lines
      // Core logic: ~700 lines
    </script>
  </body>
</html>
Enter fullscreen mode Exit fullscreen mode

No classes. No modules. Just functions and state at the top level.

The Surprising Benefits

1. CSS is Co-located with Logic

When I implement a hint-pair cell highlight, I write the CSS and JS in the same mental context. No context-switching between files.

.cell.hint-pair {
  border-color: #ffe082 !important;
  animation: hint-pulse 0.6s ease-in-out infinite;
}
Enter fullscreen mode Exit fullscreen mode
function showHint() {
  const pairs = findMergePairs(); // game logic
  pairs[0].forEach(cell => cell.classList.add('hint-pair'));
  setTimeout(() => pairs[0].forEach(c => c.classList.remove('hint-pair')), 1500);
}
Enter fullscreen mode Exit fullscreen mode

2. Zero Build Step = Instant Feedback

# The entire dev loop:
edit index.html → Cmd+R in browser
Enter fullscreen mode Exit fullscreen mode

No webpack. No npm. No node_modules folder that weighs more than my car.

3. Debugging Is Trivial

// Need to see game state? Just open devtools.
// grid, score, discovered, ELEMENTS — it's all global.
console.log(grid[2][3]); // 'lava'
Enter fullscreen mode Exit fullscreen mode

In a bundled app, you'd need source maps, Redux DevTools, or custom logging. Here, everything is inspectable.

The Real Challenges

Challenge 1: Key Ordering in Lookup Tables

My merge system: select element A, click adjacent element B → lookup result.

function getMergeResult(idA, idB) {
  const key = [idA, idB].sort().join('+');
  return MERGE_RULES[key] || null;
}
Enter fullscreen mode Exit fullscreen mode

The .sort() is critical. 'fire+water' and 'water+fire' must resolve to the same key.

I discovered a bug because I manually wrote 'mud+flood' in my rules instead of 'flood+mud'. Alphabetical order isn't intuitive when you're thinking about element interactions.

Fix: wrote a validation script to confirm all MERGE_RULES keys are in sorted order.

Challenge 2: Grid Initialization

Original code:

grid = Array.from({ length: GRID_SIZE }, () =>
  Array.from({ length: GRID_SIZE }, () =>
    Math.random() < 0.75 ? BASE_ELEMENTS[...] : null
  )
);
Enter fullscreen mode Exit fullscreen mode

Problem: 25% of cells were null at game start, creating visual gaps and reducing available merges.

Fix: 100% fill on init (8% chance of a Layer 2 element for variety).

Caught by running Playwright headless tests against the file.

Challenge 3: Game-Over Without Recovery

When the grid fills with incompatible elements (all high-tier, no basic elements), the player has no moves. Originally: game over, play again.

Better UX: Add a Shuffle button (costs -500 pts) that resets Layer 3+ cells to Layer 1. Progress and discovery are preserved. The game continues.

function shuffleGrid() {
  gameoverModal.classList.remove('show');
  gameOver = false;
  score = Math.max(0, score - 500);
  for (let r = 0; r < GRID_SIZE; r++)
    for (let c = 0; c < GRID_SIZE; c++)
      if (ELEMENTS[grid[r][c]]?.layer >= 3)
        grid[r][c] = BASE_ELEMENTS[Math.floor(Math.random() * 4)];
  renderGrid();
}
Enter fullscreen mode Exit fullscreen mode

Performance

The game renders on every cell interaction. 36 cells, recalculated on each click.

Is this efficient? No.

Does it matter? Also no. The grid is 36 elements. Even doing O(n²) checks on each click runs in <1ms.

Lesson: Don't optimize what doesn't need optimizing. Single-file game logic at this scale is fast by default.

What Surprised Me

Mobile just works. CSS flexbox + flex-wrap + touch-action: none was enough. No special mobile codebase.

localStorage is powerful. The Compendium persists across sessions with 3 lines:

function saveDiscovered() {
  localStorage.setItem('primalfuse_discovered', JSON.stringify(Object.keys(discovered)));
}
Enter fullscreen mode Exit fullscreen mode

Animations are cheap. CSS keyframe animations (hint-pulse, merge-pop, combo-bounce) add huge perceived quality for ~20 lines of CSS.

When to Go Single-File

āœ… Good for: Browser games, interactive demos, tools, prototypes, educational content

āŒ Bad for: Large apps (10k+ lines), teams (merge conflicts), complex state management

The sweet spot: anything under 2,000 lines where you want instant shareability.


Primal Fuse was built with Claude Code as part of a game factory experiment — AI-generated games, distributed everywhere, with zero human-in-the-loop friction. Currently submitting to the AI Browser Game Jam 2026.

Play Primal Fuse →


Free Tools for Claude Code Operators

Tool What it does
cc-health-check 20-check setup diagnostic (CLI + web)
cc-session-stats Usage analytics from session data
cc-audit-log Human-readable audit trail
cc-cost-check Cost per commit calculator

Interactive: Are You Ready for an AI Agent? — 10-question readiness quiz | 50 Days of AI — the raw data

Top comments (0)