Back to projects

Software · Shipped 2026

Balloon TD

A tower-defense game rebuilt from a college team project. I replaced the engine after finding the movement, damage, and economy systems were each quietly broken, then grew it into a full game.

Timeline
  1. 2025 Original team project at Olin
  2. 2026 Solo engine rewrite and expansion
Role
Solo rewrite of a 3-person team project
Stack
Python, Pygame, pygbag, WebAssembly, unittest, NumPy, Pillow
Balloon TD on the Park Path map, round 59 of 60, towers lining the spiral route

Overview

Balloon TD is a tower-defense game. Balloons follow a fixed route across the map, popping them pays out money, and you spend that money on towers placed beside the route before the next round sends something harder.

It began as a three-person software-systems project at Olin College in spring 2025, where I wrote the tower and interface code alongside Hong Zhang and Jackson Gamache. In 2026 I came back to it alone meaning to add content, and found that the parts I had assumed worked did not.

So I rewrote the engine. What shipped is a different game: seven towers with branching upgrade paths, fourteen balloon types with damage-type immunities, three maps traced from their own artwork, rounds authored through 40 and generated procedurally past that, saved run records, and a browser build.

See it running

Three clips, one per map, each from a run that cleared every round.

Sprint Track, the last round of forty. The numbers painted on the track are the three laps: balloons run the outside lane, move in a lane on each pass, and leave from the inside. The clip ends on the clear screen, which is where the run stats come from.
Monkey Meadow, round 60, against a finished defence. The blimps are worth watching: one pops into ceramics, those into rainbows, and so on down. Overkill damage carries into a single child rather than the whole layer, which is what keeps a large hit from deleting the stack.
Park Path, the spiral. Nothing here was placed by hand: the route was traced from the painted artwork, and the balloons hold the centre of the stone path the whole way in. This is the map that exposed the corner-cutting described below.

How it’s built

The entire runtime dependency list is pygame. Nothing else is imported when the game runs, and that is deliberate: pygbag compiles a pygame program to WebAssembly for the browser, and every extra native dependency is one more thing that has to exist as a WASM wheel first. The heavier libraries stayed in offline tooling instead, where NumPy and Pillow trace the maps and compress the art. They run once on my machine and ship nothing.

About 6,200 lines sit under btd/, arranged so the simulation never needs a window. Run._step takes a time delta and advances spawning, movement, targeting, and collisions without touching a drawing surface; rendering is a separate pass over the result. That is what lets the tests play entire rounds headless, including winning and losing them: 117 tests run against SDL’s dummy video driver and finish in about half a second.

Three decisions shape most of the rest:

  • The track is parameterised by distance, not by waypoint index. A balloon stores how far along it has travelled in pixels and advances by speed × dt. Placement asks a spatial index over the path how far a point sits from the track, and tower targeting queries a grid rebuilt over live balloons each tick, so neither has to scan everything.
  • There is exactly one damage model. Hit points come off, the balloon pops at zero, pays its own reward once, and is replaced by its children.
  • The simulation runs on a fixed timestep. Elapsed real time is accumulated and spent in whole 1/60-second ticks, so fast-forward runs more ticks per frame instead of raising the frame-rate cap.

Wave design is tested rather than only eyeballed: that difficulty ramps across a whole run, that every round up to 120 is defined and non-empty, and that no round takes an absurd length of time to play.

Three bugs that were invisible until measured

The interesting part was not adding content. It was that the original had bugs nobody noticed, because nothing measured them.

Three of the six balloon types moved at identical speeds. Movement advanced a waypoint index by int(speed) each frame, so 1.0, 1.4 and 1.8 all truncated to one step. The entire early-game difficulty curve did not exist. I replaced waypoint indexing with an arc-length parameterised path: a balloon stores how far it has travelled in pixels and advances by speed × dt, so any speed is representable.

The economy paid out several times over. Popping a balloon spawned a weaker one that inherited the parent’s reward, and towers paid that reward on every downgrade, so one pink balloon paid five times. Damage was worse: every balloon carried a health field that was never decremented, while a second, contradictory system did tier-index downgrades instead. I replaced both with a single rule where rewards are paid once per layer destroyed.

The game ran at double speed on a high-refresh display. Movement was per-frame, and “2× speed” simply raised the frame-rate cap. The simulation now runs on a fixed timestep (real elapsed time accumulated and consumed in whole 1/60-second ticks), with tests asserting that ten 0.1-second frames produce the same state as a hundred 0.01-second ones.

Deriving maps from their artwork

Maps are the part I would show someone first. A tower-defense map has to know exactly where enemies walk, and hand-placing those points drifts off the painted track.

Instead a tool reads the artwork: it classifies track pixels by colour, distance-transforms the mask so every pixel knows how far it sits from the track edge, then runs a weighted Dijkstra search that hugs the centre line. It reports how far the resulting curve strays from what it traced and warns when that becomes a significant share of the track width, which is how I caught balloons cutting corners on a spiral map before anyone played it.

Two problems needed measurement rather than intuition:

  • Balloons swerved around every leaf painted across the path, because decorative overgrowth reads as a dent in the track edge. The obvious fix, morphologically closing the mask, welded neighbouring arms of the spiral together and let the search cut a third off the route. Blurring the cost field instead cannot change connectivity, and fixed it.
  • The running-track map needed three laps, one per lane. Offsetting a traced centre line sideways self-intersected on the curves. But a running track is a stadium curve by construction, so the lanes are exactly derivable (shared centre, one radius each) and I measured those parameters off the image.

Sprint Track at round 40 of 40. The painted lane numbers mark the three laps, and blimps are entering on lane one

Making the art impossible to get wrong

Character art arrives at whatever size and orientation it was exported at. The sprites I was working from filled between 37% and 100% of their canvases across two different sheet sizes, so scaling by canvas put the same character on screen at wildly different sizes.

Scaling to the bounding box fixes that, then breaks on protrusions: the sniper’s diagonal rifle made its box half again as tall as anything else, so the monkey rendered at half size. Matching visible pixel area ignores thin protrusions, and every tower now lands within a few percent of the others. Sprites that face the wrong way are corrected by a one-line table rather than sent back for re-export.

What I learned

Playable is not the same as correct. All three engine bugs shipped in a project that demoed fine and got a grade. Nobody caught the speed collapse, because the game still got harder round to round. Nobody caught the payout bug, because money still felt tight. They only became visible once something measured them, which is the real argument for being able to step a simulation by hand in a test.

Prefer the fix that cannot break what you are not looking at. Balloons were swerving around leaves painted across the path, and the obvious repair was to morphologically close the mask. It looked right, and it quietly welded two arms of the spiral together and cut a third off the route. Blurring the cost field instead solves the same problem and is structurally incapable of changing connectivity. Given two fixes that look equivalent, the one with the smaller blast radius is not just safer, it stays easier to reason about later.

Domain constraints beat general algorithms when you actually have them. Offsetting a traced centre line sideways to make three running-track lanes kept self-intersecting on the curves. But a running track is a stadium curve by construction, so the lanes are exactly derivable from a shared centre and one radius each. The general-purpose tool was simply the wrong tool.

Build the pipeline so bad input cannot get through. Art arrives at whatever size and rotation it was exported at, and no amount of asking nicely changes that. Normalising on visible pixel area, with a small table for the handful of sprites that face the wrong way, means a bad export is corrected on load rather than becoming a bug someone has to notice.

Where it stands

Done, for now. Seven towers, fourteen balloon types, three maps, 117 passing tests, and a 2.3 MB WebAssembly bundle built with pygbag. Every run is scored and saved.

The Monkey Meadow clear screen: 60 of 60 rounds, 220,124 balloons popped, no lives lost

The run above cleared all 60 rounds, popped 220,124 balloons across 39 towers, and lost no lives.

One caveat worth stating rather than hiding: the browser bundle builds correctly and contains the right files, but it has only been opened in a sandboxed browser so far, where the pygbag runtime stalls partway through fetching its WebAssembly wheels. The desktop build is the one I would put in front of someone today.

Source is on GitHub.