LASTLIGHT — Project Story

Inspiration

The starting point was Neo Scavenger: a turn-based survival game where the interesting decision is never "which button do I press in this fight", it is "do I open one more building, or do I turn back now while I still can".

Most mobile survival games answer that question for you with a timer. A bar empties at a fixed rate, and difficulty is a number the designer turned up. I wanted the opposite — difficulty as a property of the map, something the player can look at and reason about before committing.

That became the one mechanic I most wanted to validate: scarcity by distance. Every container's yield falls off with how far from your starting point you found it. For a position $p$, spawn $s$ and tower $t$:

$$\lambda(p) = \operatorname{clamp}!\left(\frac{\lVert p - s \rVert - d_{\text{near}}}{d_{\text{far}} - d_{\text{near}}},\ 0,\ 1\right), \qquad d_{\text{near}} = 10, \qquad d_{\text{far}} = 1.25 \cdot \max!\left(20,\ \lVert t - s \rVert\right)$$

which then bends every loot roll:

$$P_{\text{empty}} = e_0 + 0.42\,\lambda \qquad\qquad P_{\text{item}} = p_0\left(1 - 0.62\,\lambda\right)$$

$d_{\text{far}}$ is scaled to that seed's own journey rather than a flat tile count. An early version used a fixed ramp, and because the tower lands anywhere from 22 to 36 tiles out, one seed's whole run sat in comfortable territory while another's was half-starved before it began.

The result is a difficulty curve the player draws themselves. The nearest radio part is a warm-up. The furthest is a multi-day expedition into ground that has already been picked clean, and the game never tells you to go there — you decide that, and you decide it with the map in front of you.

What it does

You wake at a relay beacon with the radio torn out of it. Three parts are scattered across a seeded county. Find them, get back, call for help — before the cold, the hunger or the dead finish you.

One thumb, portrait, offline. Turn-based, fifteen in-game minutes a move. Four clocks run against you — health, warmth, food, water — and every container is a bet against them. One session is one run: won or lost, nothing carried over.

How I built it

One HTML file, no assets, no network. The hard constraint I set myself was that the game must run from a clean unzip on a machine with no internet. That rules out more than a CDN link:

  • No image files. Every texture is drawn into a <canvas> at boot and uploaded as a GPU texture.
  • No fonts. System stacks only.
  • No audio files. All sound is synthesised through the Web Audio graph — rain is three filtered noise layers, each swung by its own LFO at 0.083, 0.127 and 3.1 Hz, plus a slow cutoff drift, because a single flat noise bed sounds exactly like static and nothing like weather.
  • No icons. Inline SVG and canvas.

The whole game is a single index.html of 1.3 MB alongside a vendored copy of three.js (r144) — 532 KB zipped, which is the entire submission. Nothing else is fetched at any point. During development the build step greps its own output for http:// and https:// and refuses to produce a file if it finds one, so the constraint can't quietly rot.

Behind that, the source is 16 modules totalling 26,390 lines, concatenated at build time. The architectural rule that mattered most: the turn engine is pure — no DOM, no three.js, no window anywhere in it. That let me load the entire simulation into a headless Node sandbox and drive it without a browser, which is what made everything below possible.

Two verification harnesses, doing different jobs. A self-test suite runs 107 checks — world connectivity, loot sufficiency, save migration, event determinism — plus a greedy AI player that has to win at least 12 of 24 seeds. That last one is the floor the whole design is calibrated against, and it fails the build. Separately, a balance harness plays 96 complete games and reports distributions rather than pass/fail: win rate, cause of death, day of first part, share of turns spent indoors. I deliberately kept that one out of the build gate, because a balance figure that can fail a build is a figure you learn to game.

Challenges I ran into

The bug that taught me the most was a sign. Objective markers pointed at the top or the sides of the screen but never the bottom. The cause is that projecting a 3D point to the screen is only meaningful in front of the camera. Normalised device coordinates come from the perspective divide

$$\mathbf{x}{\text{ndc}} = \frac{\mathbf{x}{\text{clip}}}{w}, \qquad w = -z_{\text{view}}$$

so for anything behind the camera, $z_{\text{view}} > 0$, therefore $w < 0$, and the divide mirrors the point through the origin. My camera sits 8.43 tiles south of the player and 10 up, so any objective more than about twenty tiles south was behind it. Measured directly: a target 18 tiles south projected to screen $y = +9353$ (clamps to the bottom — correct), and 21 tiles south to $y = -37801$ (clamps to the top — exactly backwards).

Fixing the sign was not the end of it. The markers then swam while the player walked, and it took three more rounds of measurement to find out why. The direction was being computed between two projected points — the player's screen position and the target's. The camera eases in behind the player rather than snapping, and that lag displaces those two points by different amounts, because perspective is not affine. Walking one tile past a target $R = 40$ tiles away turns the true bearing by

$$\Delta\theta = \arctan\frac{1}{R} = \arctan\frac{1}{40} \approx 1.43°$$

but the projected-difference method reported 4.34° — a 3× inflation, all of it camera lag. Taking the heading from the world and rotating it into the camera's frame has no positional dependence at all. Worst-case marker travel per tile walked went from 37.2 px to 12.4 px.

Along the way I tested a hypothesis that felt obviously right — read the bearing one tile out along the heading, rather than at the target, to dodge the compression near the horizon — and it changed the numbers by nothing, to three significant figures. A straight line on the ground projects to a straight line on screen, so both give the identical direction.

Other things measurement caught that reasoning did not:

  • 65.8% of lockpick uses paid nothing. A pick could snap on a door and leave you outside, snap on a container and not open it, or open a locked container that then rolled empty. Every locked container is defined as never empty by default, so the empties were coming entirely from the distance-scarcity term — the same formula that makes the game interesting was quietly making its most expensive consumable worthless. A locked container may now never come up empty: 237 of 360 uses paid nothing before, 0 after.
  • Texel density ran from 55 to 4492 texels per world unit. A box mesh emits $0..1$ UVs regardless of its size, so a large flat roof stretched a 64 px tile across twelve world units while a crate got the same texture at 1:1. Fixed by scaling UVs by actual box dimensions.
  • A "leftover hordes" count of 13 looked like a leak and was a transient — 13 at turn 900, 5 at 1500, 2 at 2400, 0 at 3600. I rewrote the check to measure at two lengths and assert that it drains, rather than assert a number.

What I learned

Measure the thing you are about to change, then measure it again. Almost every fix above began as a confident hypothesis that turned out to be wrong or incomplete. My first attempt at improving the rain sound made it three times louder (RMS 0.0035 → 0.0100), which I only knew because I rendered it offline and measured it instead of listening once and calling it done.

A pure simulation core is worth the discipline it costs. Keeping the turn engine free of DOM and three.js felt pedantic early on. It is the only reason I could play 96 complete games in a few minutes and get real distributions instead of vibes.

Tests should assert behaviour, not shape. Several of mine grepped the source for exact code strings and broke the moment the code was refactored — three times on the same test. Each one got restated to assert what the code does.

Constraints are generative. "No asset files" sounded like a limitation and turned out to be the reason the whole thing is 532 KB, boots instantly, and works on a plane.

What's next

A larger region, and a claimed building that becomes a real base — survivors who need feeding and can be sent out on errands of their own, so that the decision about how far to walk becomes a decision about who to send.

Built With

Share this project:

Updates

Submission history