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

Lightning That Lights the Whole Board

Nightfall is the stage that takes your vision away: the board sits in near-darkness and a tower can only shoot what it can see. This week I reworked how that darkness behaves. Lightning strikes now light the whole battlefield for a third of a second before it fades back to night, towers stopped lighting a radius that grew with their level, the placement cursor carries its own lantern, and the decorative props learned to keep out of the way. Small things, all of them, but Nightfall went from "annoying map" to the one I keep replaying.

Thunder in three phases

The old lightning flash was one beat: hold pure white for 50ms, fade the white overlay out over 300ms, back to darkness. It read as a camera flash, not as weather, and worse, it told you nothing. You got blinded and then the board was exactly as dark as before.

The flash now runs a three-phase timeline:

  1. Hold, 0.05s of pure white over everything.
  2. Reveal, 0.35s where the white overlay fades to zero while the scene ambient is pushed up to daylight, so for a moment you actually see the entire board: every enemy, every prop, every gap in your coverage.
  3. Dim, 0.9s fading that ambient back down to the stage's night color, so the darkness returns gradually instead of slamming shut.

The trick is that phase two and three are not an overlay at all, they write lightSystem.ambientColor directly, blending between the night ambient the stage JSON declares and a LIT_AMBIENT of roughly (0.85, 0.85, 0.95). The light system multiplies the scene by ambient plus lights, so raising ambient is exactly "turn the sun on for a moment."

Strikes recur at a random 45 to 90 second interval and there are three thunder samples now (thunder1.ogg through thunder3.ogg, credited in CREDITS.md). All of it is cosmetic: it runs on wall-clock time and LittleJS's own rand, never the seeded gameplay RNG, so co-op stays deterministic no matter who sees which storm. Same rule as everything else in the determinism work. The flash also advances its own timeline inside the draw pass rather than the update pass, so opening a menu mid-strike does not freeze the screen at full white.

Towers see a fixed distance now

Every tower on Nightfall casts a light, and that light used to have the tower's attack range as its radius. That sounded elegant when I wrote it ("you see what you can shoot") and fell apart the moment a mortar showed up. A mortar's range is huge, and a leveled mortar's range is enormous, so a single one flooded the entire map with light and deleted the stage's whole premise.

Vision is now a constant, TOWER_VISION_RADIUS, defined as a fraction of the cursor lantern's radius. It started at half and playtested up to three quarters (2.25 world units against the lantern's 3.0). Vision and range are now two different things, which means you can have a tower that shoots far past what it can see and needs a neighbor to light the way. That is a much more interesting placement problem than "put the mortar anywhere."

The lantern towers themselves got a small buff in the same pass, since they are the answer to the problem the mortar was accidentally solving for free.

The cursor carries a lantern

While you are placing a tower, a light now follows the cursor. Before, you dragged a ghost tower across a black rectangle and hoped. Now the placement lantern shows you the ground you are about to commit to, which matters a lot on a map where the terrain under your feet decides whether the tower is useful.

Scenery stops growing under towers

Nightfall shares a scattering system with every other stage: after the terrain snapped to a grid, each ground cell rolls a chance to receive a decorative prop, with a jitter, a flip and a scale from the same seeded stream. Two bugs came out of playtests: props clumped into little piles, and a tower placed on top of one left the art poking out through the base.

Both are now geometry problems with pure solutions in lib/scenery.ts:

  • propRect gives a prop an axis-aligned footprint from its native art size times its rolled scale.
  • edgeDistance returns the shortest edge-to-edge distance between two of those rects (zero when they overlap), and a candidate prop is rejected unless it sits at least MIN_PROP_EDGE_GAP (0.5 world units, 32px) from every already-accepted prop.
  • pointToRect gives the distance from a point to the nearest edge of a rect, which is how a tower (modeled as a disc with its footprint radius) decides at runtime that it is standing on a prop and clears it.

The scattering pass now takes the per-variant art sizes instead of just a variant count, because you cannot check spacing without knowing how big the things are. Fully tested, fully deterministic, no engine objects involved.

Right-click steps the speed back

Tiny one, but it had been annoying me for weeks: the speed button cycled forward only, so overshooting 3x meant wrapping all the way around. Right-clicking it now steps back one tier. Because game speed is a replicated action, this went in as a proper intent (applyIntent handles the new step-back the same as the step-forward) rather than a local UI hack, so it behaves identically for a co-op host.

Next up: enemies that come apart properly when they die.