TinyGames · how it works
Hold the line. Five rows of eleven come down the screen, you have one cannon and four shields, and you may only have one shot in the air at a time.
The swarm gets faster the more of it you kill. Nothing in the code sets that speed.
Open index.html. No build step, no dependencies, no images — every sprite is drawn from
an ASCII grid at boot.
redoubt/
index.html markup
style.css the frame around the canvas
js/engine.js copied unchanged from Overdrive - the sixth game on it
js/invaders.js the swarm and the shields - no pixels, no engine, no DOM
js/redoubt.js the playable layer
test-invaders.js node test-invaders.js
bump-version.py stamps version.json, the meta tag and the cache busters
The swarm is never moved as a block. One alien steps per tick, in a fixed order — bottom row first, left to right — and a sweep across the field is finished only when every survivor has had its turn.
That is the whole mechanism. A full formation of fifty-five takes fifty-five ticks to advance one step. Forty survivors take forty. The last one takes one. The swarm accelerates as you shoot it and no line of code anywhere computes a speed.
In the 1978 original this was an accident of the hardware — fewer sprites left to draw meant a shorter frame — and it was kept because it turned out to be the entire game. It is reproduced here on purpose, and measured rather than asserted:
| survivors | ticks to advance one step |
|---|---|
| 55 | 55 |
| 40 | 40 |
| 27 | 27 |
| 11 | 11 |
| 1 | 1 |
The test checks the whole curve, not just the ends.
The felt speed-up is smaller than 55×, and it is worth being precise about why. Timed across the actual field, a full swarm crosses in 13.9 seconds and a lone alien in 1.3 — about eleven times, not fifty-five. The step rate really is 55×; the last alien simply has the whole width to cover, where fifty-five of them shuffle back and forth in the ~150px their formation leaves spare.
The reversal at the wall is deliberately not immediate. An alien reaching the edge only raises a flag; the swarm turns and drops at the start of the next sweep, once every survivor has finished this one.
Reverse the moment the flag goes up and the swarm stutters against the wall, dropping a row every frame it stays in contact, and lands on the player in about a second. The test asserts that no two drops ever happen closer together than a full sweep.
There is a second trap in the same place. The wall test has to use the aliens that are actually alive, not the formation's nominal width — otherwise, once you shoot the left-hand columns away, the swarm stops short of a wall that is no longer there and the field quietly shrinks as the game goes on.
Each shield is a grid of cells built from an ASCII picture, and it is eaten away from both sides. The hole you blast to shoot through is the same hole the bombs come down. That is the entire tactical content of the game: with one shot in the air at a time, every shot is a decision about which of the four holes you are firing through.
A shot crosses several shield cells per frame, so the sweep steps in half-cells rather than testing a point — a point test lets a shot pass clean through a two-pixel wall and kill whatever was sheltering behind it. It is cross-checked against a marcher that walks the same line in 20,000 slices and shares no code with it: over 1,500 shots at a shield already pitted by 25 hits, nothing passed through material and every stop was at the same cell.
$ node test-invaders.js
136 passed, 0 failed
Erosion is also asserted to be monotone — material may only ever leave a shield, never come back — and a separate test fires thirty shots at one spot, confirms a channel opens clean through, and confirms the rest of the shield is untouched.
The last alien could not be caught. A lone survivor moves at STEP_X × ALIEN_STEP_HZ =
600 px/s. The cannon moved at 420. Given twenty seconds and a single alien to finish off, a
bot never once landed the shot — it simply could not get underneath something half again its
own speed, and with one shot in the air at a time you cannot spray and hope. Ten trials, ten
failures.
The fix is not to clamp the 1/n law, which is the best thing in the game. It is to raise the cannon above the swarm's ceiling, and that number now carries a comment saying it must stay there. Ten trials afterwards: ten kills.
The view could not use its own edge names. Field extends Entity, and Entity already
defines left, top, right, bottom, width and height as read-only getters for its
hitbox. Assigning this.left throws. This is the second game in the set to walk into it —
the fields are wallL, wallR, ceilY, floor.
The test suite was flaky and nearly shipped that way. The shield tests fire random shots at random places, and one threshold sat close enough to the edge that the suite passed about five times in six. It was caught by a run that failed straight after an edit to a completely different file, which is the only reason it was noticed at all.
That is the worst possible state for a test to be in: a real regression would have been indistinguishable from the noise, and the natural reaction — run it again, it passes, carry on — is exactly wrong. The generator is now seeded, and eight consecutive runs give identical output.
The first cut had a two-minute opening wave that a bot survived for seven minutes without ever being in danger. Most of those two minutes turned out to be the one-shot rule: a 640 px/s shot spends over half a second in the air before the next one is allowed, and fifty-five of those is most of a wave on its own.
Four settings, four games each, a bot that dodges bombs and shoots the nearest alien:
| first wave | waves reached | cannons lost | |
|---|---|---|---|
| as first written | 93–124s | 5–6 | 0–1 |
| faster shot | 55–112s | 5–7 | 0–1 |
| faster shot + angrier swarm | 52–94s | 5–8 | 0–2 |
| harder still | 66–97s | 3–5 | 3 (dead every game) |
The third shipped. With the final cannon speed it gives a median wave of 5, between none and three cannons lost, and two runs in six that ended on wave one.
Bombs come faster as the swarm thins, for the same reason the march does — but unlike the march, that one is a setting, because nothing about dropping a bomb makes it emerge on its own. The source says so where it happens.
Three creatures, a cannon and a mystery ship, all written as ASCII in the source and baked
to small canvases once at boot. Drawn as raw fillRect calls instead, a full swarm would
cost about 4,800 draw calls a frame before anything else happened; as baked sprites the
whole render costs 0.19 ms.
The three creatures differ in silhouette, not only in colour. Six pixels of hue is a poor way to tell a 30-point alien from a 10-point one, and hopeless for anyone who cannot separate the pink from the amber. The shapes carry the difference; the colour is decoration. This was verified by reading the rendered pixels back off the canvas and reconstructing each sprite — three distinct shapes, three distinct colour bands.
Its value was a random pick from a plausible list. That looks identical on screen and quietly deletes the only piece of hidden knowledge the game has.
In the 1978 machine the value comes from a fifteen-entry table indexed by how many shots you have fired — which is why the arcade folklore about it is so exact. It is a real strategy buried in a scoring rule: hold your fire, count, and take the ship on the right shot.
const UFO_TABLE = [100, 50, 50, 100, 150, 100, 100, 50, 300, 100, 100, 100, 50, 150, 100];
function ufoValue(shotsFired) { return UFO_TABLE[shotsFired % UFO_TABLE.length]; }
Writing the test corrected the folklore. The first version asserted the jackpot starts at the 23rd shot; it cannot, because any rule that repeats every fifteen shots and pays at 23 also pays at 8 — and 8 is reachable here, since you can hold fire, wait for the ship and put your eighth shot into it. The cycle is the rule. 23 is just the member of it that got famous.
The test that matters is the distribution: random-from-four pays 300 a quarter of the time, the table pays it one time in fifteen. If anyone ever swaps the rule back, that is what catches it.
The four descending notes are not a soundtrack laid over this game. In the original the loop ran at the alien step rate, so as the formation thinned and sped up the music sped up with it, and you heard the pressure rising before you consciously saw it. It is the most recognisable thing about the game and it is a side effect, not a composition.
So there is no tempo number here either. A note plays once per sweep — and a sweep is one
step for every survivor, advancing the formation one STEP_X — so the note rate is the swarm
speed divided by a constant:
function marchInterval(survivors, stepHz, floorSeconds = 0.07) {
if (survivors <= 0 || stepHz <= 0) return Infinity;
return Math.max(floorSeconds, survivors / stepHz);
}
Measured by counting the notes a running game actually plays, not by reading the formula
back — the game was driven through tick with the swarm held off the bottom line, because a
thinned swarm marches into the cannon in a couple of seconds and the first attempt at this
reported the fast march as the slow one, having ended the game half way through its own sample.
| survivors | predicted | measured in game |
|---|---|---|
| 55 | 2.18 | 2.17 |
| 25 | 4.80 | 4.50 |
| 6 | 14.29 | 13.33 |
| 1 | 14.29 | 13.33 |
A 6.1x acceleration from the first alien shot to the last. The measured figures sit just under the predicted ones at the fast end because a note can only land on a tick: an interval of 0.070 s on a 120 Hz step lands at 0.075 s.
The tests do not take the formula's word for any of that. They step a real swarm and count the ticks a sweep actually takes, then check the interval matches — which is also how the harness turned out to be measuring one tick too many, since the step that reports the wrap has already moved the first alien of the next sweep.
The floor is the point of the whole thing. Without it the last survivor sweeps in a single tick, and at 120 Hz that is not a fast march, it is a 120 Hz tone — arriving exactly when the player most needs to hear a beat. The note is shortened to fit its gap for the same reason: a fixed 110 ms note at fourteen a second overlaps its successor into mush.
Everything is synthesised — oscillators and a little filtered noise. No files, nothing to 404. The context is built on your first keypress or tap, because a browser will not let a page make noise before you have touched it and one created at load time starts suspended and stays that way.
Borrowed from talon in this set: a bomb pattern covering every position the cannon can reach
is not difficulty, it is a coin toss — and it looks identical to a hard one, so playing
will not tell you which you have built.
dodgeableBombs asks, of every x the cannon could reach before the bombs arrive, whether one
of them is clear. Over 6,000 random arrangements the answer is always yes, and the negative
control proves the checker can say no: a solid rank across the whole line is correctly
reported inescapable, while the four bombs the game actually permits never are.
The first version of that measure was wrong in a way worth recording. It skipped samples that fell outside the walls without counting them, quietly mixing "the bomb would hit me there" with "there is off the end of the field" — so a distant bomb, whose reach runs past both walls, scored fewer escapes than a close one purely because more of its samples were thrown away. It is the ratio of in-bounds positions that means anything.
That correction also killed an assertion: a close bomb does not leave less room than a distant one, because at 700 px/s against a 300 px/s bomb the cannon simply outruns a single bomb at any range. Range only starts to matter once the cannon is slow, and the test now says that instead.
| Move the pointer | the cannon follows directly |
| Click or hold | fire |
| ← → or A D | move |
| Space / ↑ / W | fire |
| Esc | pause, and carry on again |
| Space or a tap | start a run from the menu, or another go after one ends |
| H | the high score table |
| M | mute |
The menu, the pause card, the game-over screen and the score table are the shared shell's, not this game's, so they behave the same way here as in the rest of the set. A run is started and restarted from those screens rather than with a key during play.
Holding fires as fast as the one-shot rule allows, so a phone needs one thumb and no buttons.
| Bottom two rows | 10 |
| Middle two rows | 20 |
| Top row | 30 |
| Mystery ship | 50, 100, 150 or 300 — and which one is up to you |
| Clearing a wave | 500 |
Three cannons. Each wave starts lower than the last, up to five waves' worth, and then stops
— the cap exists so a late wave cannot start already on top of the shields. Best score is
kept in localStorage.
Rotating the phone keeps the round, damaged shields and all. Rebuilding the wave was the easy option and it silently threw the game away every time the screen turned.
Advertisement