TinyGames · how it works

Redoubt

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 acceleration is not a setting

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 bug this design invites

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.


The shields are material, not hit points

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.


Three bugs worth recording

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 balance was measured

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.


Drawn, not loaded

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.


The mystery ship is worth what you have earned

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 march is the swarm

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.


Can the bombs be dodged?

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.


Controls

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.


Scoring

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.


Things deliberately left undone