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>
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;
}
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);
}
2. Zero Build Step = Instant Feedback
# The entire dev loop:
edit index.html ā Cmd+R in browser
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'
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;
}
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
)
);
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();
}
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)));
}
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.
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)