Doug's Game Dev Log
About
2026-08-05

The Waves Went on a Diet

The wave files in td-survivors had gotten fat. Not "needs a refactor someday" fat. Twelve thousand lines fat. Today they went on a diet, and the diet worked so well I'm slightly worried it'll come back to haunt me.

How waves used to work

Every stage had a *.waves.json that spelled out every single spawn by hand. Enemy type, exact timestamp, one object per creature. stage1.waves.json alone was 1,270 lines of this:

{ "type": "normie", "time": 1 },
{ "type": "normie", "time": 5 },
{ "type": "speedy", "time": 5 },
{ "type": "normie", "time": 9 },

Multiply that by eleven stages, each with dozens of waves, and you get a folder where the enemies outnumber the actual code. Want to make wave 6 spawn one more goblin? Find the right array, count the timestamps, hope you didn't fat-finger a time and stack two enemies on the same frame. It was less "game design" and more "data entry with extra steps."

How waves work now

A whole stage's difficulty curve now fits on a postcard:

{
  "initialQuantity": 10,
  "initialDuration": 10,
  "ramp": { "xp": 1.1, "hp": 1.1, "speed": 1.02, "quantity": 1.1, "duration": 1.09 },
  "bosses": { "10": "juggernaut", "25": "necrolord", "50": "wyrm" },
  "waves": [
    ["normie"],
    ["speedy"],
    ["normie", "speedy"],
    ["splitter"]
  ]
}

That's it. Each wave is just a pool of enemy types. A wave doesn't list its spawns anymore; it declares which enemies are allowed, and the engine derives the rest.

The pool format also made something nice fall out for stage 1: it doubles as a tutorial. Look at the sequence, ["normie"], ["speedy"], ["normie", "speedy"], ["splitter"], ["normie", "speedy", "splitter"]. Every time a new enemy is about to join the party, it gets a solo wave first, all by itself, before it's ever mixed in with the others. So the player meets the splitter alone and gets to watch it split without a dozen other things clawing for attention, then the next wave folds it into the chaos. No tooltips, no pop-ups, just "here's the new guy, figure him out" followed by "okay, now deal with him for real." The whole onboarding is encoded in the shape of the pool list. Wave n spawns initialQuantity * quantity^n enemies, spread evenly over initialDuration * duration^n seconds, walking the pool round-robin so a ["normie", "speedy"] wave alternates the two automatically. HP, XP, and speed each climb on their own compounding ramp. Bosses are one shared waveNumber -> type map instead of a boss field copy-pasted into every wave. And because the pools just loop past the end of the list, endless mode falls out for free: no special case, the ramps simply keep compounding (capped so a debug jump to wave 9999 doesn't hand enemies infinite HP and NaN the renderer, a lesson I learned the fun way).

The numbers, because deleting code is the best feeling in software

The refactor was 615 lines added, 12,457 deleted. Net: almost twelve thousand lines of hand-authored enemy spawns, gone, replaced by five numbers and some ramps. The pure wave math (waveSpawns, waveQuantity, waveDuration, waveMultipliers) is tiny and TDD'd, which is the correct way to treat the one part of the codebase that decides whether wave 40 is beatable.

Tuning a stage went from "open a 1,300-line file and pray" to "nudge ramp.hp from 1.1 to 1.12 and feel like a difficulty wizard." I can rebalance an entire stage's power curve by editing a single float. This is either excellent engineering or the moment I've given myself enough rope to make wave 12 accidentally unwinnable with one typo. Probably both.

(Yesterday's warm-up act, since I'm here: the enemies got new directional sprites that actually face the way they're walking, drawn with crisp pixel scaling instead of the old blurry upscale. The blobs have opinions about which direction is forward now.)

Next time I'll find out whether "everything is a multiplier" survives contact with a stage that wants a genuinely weird wave, the scripted set-piece kind. For now, the waves are lean, the JSON is readable, and my git diff is 12,000 lines lighter. I'm going to enjoy this before the ramps humble me.