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

Everything Snapped to a Grid and Started Glowing

The last couple of posts were about deleting things, waves and tower stat tables. This one is about the opposite: td-survivors spent two days getting a face. The background stopped being a flat green field, the whole world snapped to a rigid grid, towers click into place like Lego, the buttons tilt and glow, and a post-process shader glazes the entire thing in candy. Nothing about the gameplay changed. Everything about how it feels did.

The grass learned to bend around the path

The old background was one repeating grass tile with the path drawn on top. It looked like graph paper. The new one is a marching-squares auto-tiler, and it's the piece I'm happiest with.

The trick is to stop thinking about cells and start thinking about corners. Every grid cell has four corners; each corner is either "path" or "grass" depending on whether it's within pathHalfWidth of the path polyline. Four corners, path-or-not, is a 4-bit number from 0 to 15, and that number indexes straight into a 16-entry lookup table that says which tile to draw and how far to rotate it:

// bit 0 = bottom-left corner, bit 1 = bottom-right, bit 2 = top-right, bit 3 = top-left
const TABLE = [
  { category: 'ground', rotationSteps: 0 }, // 0000: no path corners, pure grass
  { category: 'corner', rotationSteps: 0 }, // 0001: path pokes in at one corner
  ...
  { category: 'path',   rotationSteps: 0 }, // 1111: all four corners path
]

Five tile categories cover every case: full grass, full path, linear (a straight grass-to-path edge), corner (path curling around an outside corner), and inner (grass biting into an inside corner). Rotate those five by 0/90/180/270 degrees and you can render any path shape with soft, hand-drawn-looking transitions instead of a hard aliased line. The two "saddle" cases (opposite corners are path, the other two grass) are ambiguous, so they just fall back to solid path.

The classification is pure and TDD'd (lib/autotile.ts), and the render layer only resolves categories to actual PNGs, picking a random art variant per cell with a seeded RNG so a field of grass doesn't visibly tile. There was a genuinely nasty corner bug hiding in the geometry: at a path elbow, the outer diagonal grid corner sits exactly sqrt(0.5) = 0.707 from the centerline, so if pathHalfWidth is 0.7 that corner reads as grass and every convex corner turns into an ugly bitten inner tile. Bumping the half-width to 0.75, just above the diagonal, fixes it without widening the straight path at all. The kind of bug you only find by staring at one wrong pixel for ten minutes.

The whole world snapped to 1080p

While rebuilding the background I also nailed the grid down for good. The canvas now renders at a fixed 1920x1080, every stage is exactly 30x16 world units, and the camera scale is 1920 / 30 = 64 pixels per unit. That means every background tile draws at exactly 64x64 pixels, never resampled, never blurry. The path is authored on integer coordinates running down the centers of cells, so it's always cleanly one block wide.

The fun wrinkle: I didn't want to author UI in 1080p numbers. So all the UI is still written in a comfy 1280x720 design space, and a single boundary module (uiScreen.ts) scales design coordinates up on the way to the screen and scales mouse input down on the way back. Every draw and hit-test in the game speaks 720p; only the final blit knows about 1080p. Placement got the same rigidity: towers now snap to an 8px grid and occupy square 64x64 footprints, so two towers either fit side by side or they don't, with no fuzzy overlap math.

Then the buttons grew a third dimension and everything started glowing

Presentation is nothing without juice, so the UI got two coats of it.

First, a proper button renderer. Every button is now drawn as a single tilted quad in one raw 2D canvas pass, layering a drop shadow, a gradient body, a rim light, a specular glare, and an accent glow, all sharing the same perspective so it reads as a physical, slightly-3D face instead of a flat rectangle. The pure geometry (the tilt quad, where the glare sits, the hover reaction) lives in a tested lib file; the canvas plumbing is the untested boundary. Buttons lean, catch light, and bloom on hover.

Second, one post-process shader over the entire composited frame, a deliberately over-the-top "candy arcade" grade: threshold bloom so orbs and explosions and the glowing buttons bleed light, a warm contrast-and-saturation color grade to tie the palette together, a soft vignette to pull your eye to the center, a whisper of corner chromatic aberration, and a little film grain to kill gradient banding. Every knob is a plain constant at the top of the file with a comment, so tuning the entire look of the game is editing a handful of numbers and letting Vite hot-reload.

Two days, zero new gameplay, and the game finally looks like something you'd want to click on. Next up: the upgrade cards still need the two-numbers-and-a-line scaling treatment I promised, so back to deleting things.