TinyGames · how it works
A field with no edges. Shoot the rocks, do not fly into them, and remember that pointing somewhere is not the same as going there.
Open index.html. No build step to play it and no dependencies.
asteroids/
index.html markup, and the data-render switch
style.css the frame around the canvas
js/engine.js copied unchanged from Overdrive - the twentieth game on it
js/ads.js the shared between-play ad panel
js/rocks.js the rules: wrapping, splitting, respawning
- no pixels, no engine, no DOM
js/asteroids.js the playable layer, both renderers
assets/ Kenney Space Shooter Redux (CC0), 43 KB
- optional: the vector renderer needs none of it
test-rocks.js node test-rocks.js
This is the first game in the set written in TypeScript. The source lives outside
Public/ in src/asteroids/, and build/build.py compiles and minifies it. What ships is
ordinary JavaScript loaded by an ordinary script tag, exactly like every other game — the
build step is for the author, never for the player.
It was the first of the twenty to be written this way; the other nineteen followed.
Everything wraps. A rock at x = 5 and a ship at x = 955 on a 960-wide field are ten
apart, not 950, and the distance function has to know it:
function shortestDelta(a: number, b: number, size: number): number {
let d = wrap(b - a, size);
if (d > size / 2) d -= size; // going the other way round is shorter
return d;
}
Get this wrong and things pass through each other along the seam — a bug that is invisible in the middle of the screen and constant at the edges, which is the worst way for a bug to behave.
The tests do not take the formula's word for it. They compare it against a deliberately naive reference that tries all nine copies of the field and keeps the smallest answer: slow, obviously correct, and a completely independent way of getting the same number.
$ node test-rocks.js
54 passed, 0 failed
Drawing has the same problem and the same fix: every rock and the ship are drawn nine times, once per neighbouring copy of the field. Without it a rock straddling the seam is missing from one side while visible on the other, and it reads as a rendering fault even though the collision underneath is right.
A large rock becomes two mediums, a medium becomes two smalls, and only a small actually leaves. So clearing one large rock costs seven shots, not one — itself, two mediums, four smalls — and a wave's real size is nothing like the number of rocks it starts with.
| wave | rocks | shots to clear |
|---|---|---|
| 1 | 4 | 28 |
| 5 | 8 | 56 |
| 9+ | 11 (capped) | 77 |
The test measures that by actually clearing a rock and counting, rather than multiplying seven by something in a comment.
Children are thrown apart rather than inheriting the parent's course. Children that keep the parent's velocity travel as a clump, and a split then reads as one rock quietly getting smaller — which loses the entire point of splitting. There is a test that says so: over 400 splits, the two children always leave with meaningfully different velocities.
This is the classic unfair death in Asteroids. The original put you back in the centre of the screen, and would happily drop you inside a rock you had no way to avoid — a death with no decision anywhere in it.
bestSpawn samples the field and returns the emptiest point it can find, together with how
empty it actually is, so the caller can decide to wait rather than being told a comfortable
lie:
function bestSpawn(rocks, width, height, samples = 24): Spawn
// -> { x, y, clearance } clearance is negative when the point is inside a rock
The ship waits up to three seconds for somewhere with 95px of room, then takes the best available and comes back with two seconds of mercy. In practice the wait is rarely visible.
Three things are checked, and the last is the one that matters:
And a negative control, because otherwise the above proves nothing: a field packed solid with 121 large rocks correctly reports that there is nowhere clear at all.
The compiler settings are strict on purpose — noUncheckedIndexedAccess,
exactOptionalPropertyTypes, noImplicitOverride, noUnusedLocals and the rest — and
noEmitOnError means a type error emits nothing at all rather than a half-built game.
What it caught, at a time when the other nineteen were still plain JavaScript and had no
defence against it: the shared Entity base class exposes left, top, right and
bottom as read-only getters, and assigning to
one of them is a silent no-op in JavaScript. That cost a debugging session in Redoubt. In
src/shared/engine.d.ts they are declared readonly, so it is now a compile error.
What it did not catch, and could not: whether the rocks are dodgeable, whether a wave is
clearable, whether the respawn is fair. Those are the expensive questions, and they are what
test-rocks.js is for. Types and tests answer different questions and neither substitutes for
the other.
| source | compiled | minified | |
|---|---|---|---|
rocks |
8,025 | 6,728 | 1,932 |
asteroids |
19,772 | 19,243 | 9,449 |
The test suite runs against the minified file, not the readable build, so a mangling that broke something would be caught here rather than by a player.
On minifying and "protecting" the source. It genuinely shrinks the file, and that is worth having. It does not protect anything: the code must run in the browser, so anyone who wants it can read it back, and mangled names are an inconvenience rather than a barrier. Treat it as a footprint measure, which it is, and not as a security measure, which it cannot be.
The game ships with both a sprite renderer and the drawn-at-runtime vector one, and the choice is an attribute on the canvas rather than a compile-time constant:
<canvas id="screen" data-render="sprite"> <!-- or "vector" -->
Change it and reload. No rebuild, and no toolchain needed to do it — which is the point: the switch stays usable by anyone who can edit an HTML file.
| sprite | vector | |
|---|---|---|
| background | tiled starfield | flat |
| rocks | four large variants, two each for medium and small | generated silhouettes |
| ship | Kenney hull with a drawn exhaust | outline |
| files needed | 11, about 43 KB | none |
Which artwork a rock uses is derived from the rock's own silhouette rather than stored on it, so a rock keeps the same picture for its whole life without the rules module ever having to know that artwork exists.
If the artwork fails to load, the game falls back to vectors on its own.
loadImages resolves even for files that failed, so a missing asset arrives as an
image with no width — that is the only reliable signal it is not there. Rather
than draw nothing, the game logs what is missing and draws outlines, which need
no files at all. That is the argument for making this a runtime choice rather
than a compiled-in one: a compiled-in choice cannot fall back.
Artwork is Kenney's Space Shooter Redux, CC0 — free for commercial use, credit
appreciated rather than required. assets/license.txt ships alongside it.
| ← → or A D | turn |
| ↑ / W | thrust |
| Space | fire |
| Esc | pause |
| R | new game |
On a touch screen the ship turns towards wherever you are holding, thrusts while you hold, and fires on its own. That is a deliberate difference from the keyboard scheme rather than an emulation of it: turn, thrust and fire as three separate touch controls needs three thumbs.
Four shots on screen at a time, as in the original. Ammo discipline is most of the skill, and a fire button with no limit removes the game.
| Large rock | 20 |
| Medium rock | 50 |
| Small rock | 100 |
| Clearing a wave | 250 |
| Extra ship | every 10,000 |
Three ships. The small ones are worth the most because they are the hardest to hit, which is also why breaking a large rock into pieces you then miss is the fastest way to lose.
bestSpawn already knows where the safe places are, so a fair hyperspace would be a
few lines — which is arguably an argument against having one.Advertisement