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
-
- 2025 Original team project at Olin
- 2026 Solo engine rewrite and expansion
- Role
- Solo rewrite of a 3-person team project
- Stack
- Python, Pygame, pygbag, WebAssembly, unittest, NumPy, Pillow
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.
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.

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 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.