No React. No Phaser. No Pixi.js. No npm install. Just <canvas>, vanilla JS, and the Web Audio API.
🎮 Play it here · 📖 Full dev log
What I Built
Arcane Bastion is a crystal tower defense game with:
- 5 elemental towers(Fire / Ice / Lightning / Vine / Arcane)× 3 upgrade tiers
- 10 enemy types with status effects(freeze, burn, poison)
- AI‑generated art — every sprite and map background
- Procedural audio — every sound synthesized from oscillators, zero audio files
- Dynamic tower rotation — smooth tracking with recoil feedback
The entire thing is 9 script files loaded via <script> tags in dependency order. Old school, but it works.
The Architecture
arcane-bastion/
├── js/
│ ├── sprites.js ← async image loader (18 assets)
│ ├── map.js ← 20×14 tile grid + AI backgrounds
│ ├── tower.js ← 5 types, targeting, smooth rotation
│ ├── enemy.js ← 10 types, waypoint pathfinding
│ ├── projectile.js ← homing missiles, chain lightning
│ ├── audio.js ← Web Audio synthesis (~400 lines)
│ ├── ui.js ← HUD, panels, bestiary overlay
│ ├── game.js ← 60fps loop, wave state machine
│ └── demo-mode.js ← scripted auto-demo + MediaRecorder
The Hardest Bug: Chrome's Autoplay Policy 🔇
This one wasted hours. My audio engine worked perfectly in local testing, but was completely silent on Chrome after deployment.
Root cause: Creating an AudioContext before the first user gesture puts it in suspended state — permanently. My Game.init() was calling AudioEngine.init() on page load.
The fix:
// ❌ BAD: Creates AudioContext on page load
function init() {
ctx = new AudioContext(); // suspended forever!
}
// ✅ GOOD: Lazy-create on first user interaction
function init() {
document.addEventListener('click', () => {
if (!ctx) {
ctx = new AudioContext();
// now it works because user clicked first
}
});
}
And crucially — every sound function must call ensureContext() before the if (!ctx) return guard, not after. Get the order wrong and the context never gets created.
AI Art: Not Plug-and-Play
I generated all 18 visual assets(5 towers, 10 enemies, 3 maps)using AI image generation. Sounds easy, right?
The reality:
- Consistency — Every prompt needed the same style anchor: “dark fantasy, pixel art, top-down, glowing magical effects”
- Transparency — Early sprites had visible rectangular backgrounds. Each needed manual cleanup
- Contrast — Sprites that looked great on a white canvas disappeared against dark map backgrounds. Had to add glow auras and brightness boosts
- Scale — A beautiful 512×512 sprite becomes a blurry 50px blob at game scale
AI art saved weeks of illustration work, but the integration “last mile” still required significant manual effort.
Procedural Audio: Math → Sound
The most satisfying system to build. Every sound in the game is synthesized from OscillatorNode waveforms:
// Frost tower attack: crystalline "ping"
function frostShoot() {
playTone(1200, 0.15, 'sine', 0.12); // high shimmer
playTone(800, 0.2, 'triangle', 0.08); // harmonic
playNoise(0.08, 0.1); // ice crackle
}
Fire, ice, lightning, vine, arcane — each element has a unique audio signature created by combining oscillator types(sine, triangle, sawtooth, square)at different frequencies and durations.
The background music is a multi‑oscillator ambient drone that evolves over time. The total audio system is ~400 lines — zero bytes of audio assets loaded.
Three Changes That Made the Biggest Visual Difference
- AI map backgrounds replacing procedural tiles — went from “programmer art” to “atmospheric”
- Glow auras on tower sprites(radial gradient circles behind each tower)— instant depth
- Smooth tower rotation with
Math.atan2+ lerp interpolation — made everything feel alive
None of these changed gameplay. All of them changed how the game feels.
Final Stats
| Metric | Value |
|---|---|
| Lines of JS | ~3,200 |
| Code modules | 9 |
| AI-generated assets | 18 |
| External dependencies | 0 |
| Audio files | 0 |
| Build tools | 0 |
I wrote a much deeper technical breakdown covering grid systems, pathfinding, chain lightning implementation, tower synergies, and how I recorded the demo video with audio using the browser's MediaRecorder API.
Top comments (0)