TinyGames · how it works
A worked example for the Nanonauts assignment, built from the same sprite pack every intern receives. It exists to answer a question the brief deliberately leaves open: once the runner works, what does a second pass look like?
Open index.html. No build step, no dependencies, no install.
overdrive/
index.html markup and the side panel
style.css layout and chrome
js/engine.js reusable core - knows nothing about Nanonauts
js/game.js the entities: player, coins, hazards, scenery
js/director.js the run: spawn logic, scoring, HUD, screens
assets/ copied from the assignment asset pack
assets/zoe/ Zoe Ruijten's eclipse parallax set
An endless runner about Perseus, cursed by Hermes into sandals he cannot stop running in, dodging rival runners and sheep. What is worth borrowing is that the sheep are not obstacles at all - you run past them and you ride them.
Here a sheep runs the same direction you do, at 45-68% of your speed, so it drifts back toward you rather than charging at you. Running into one does nothing. Land on its back and you mount it: 4.5 seconds at 35% more speed, untouchable.
That is the whole mechanic, and it deliberately has no button. An earlier draft let you pick a sheep up and throw it through robots, which read well on paper and added a control for something the player only wanted to do occasionally. It came out again. One verb per object is worth more than one more verb.
The canvas is the page: 100% width, 100% height, no letterbox and no chrome around it.
That only works without stretching because the backing store is cut to the viewport's
own aspect ratio rather than a fixed one being scaled to fit. fitToViewport() keeps
a design height of 540 and derives the width from the screen's shape, so gravity, jump
height and the ground line never change - a wider window simply shows more track ahead.
On a window taller than it is wide the same rule runs the other way: the width is held
at a playable minimum and the height grows, giving a portrait player more sky rather
than a squashed picture. GROUND_Y is always HEIGHT - GROUND_GAP, and the parallax
layers position themselves as fractions of the height, so both follow automatically.
Rotating the device re-cuts the canvas and rebuilds the scenery.
Measured: 0% distortion at 1920x1080, -0.03% at 844x390 landscape, -0.008% at 375x812 portrait - all rounding, nothing visible.
The character never moves along the ground - the only decision is when to leave it - so
the control is a single circular JUMP pad on the right, translucent enough to see the
track through it, sized clamp(96px, 19vh, 148px) where a thumb rests. Overdrive sits directly
above it, the same width, offset by exactly the circle's height - both under the same
thumb. Pause, restart, settings and fullscreen run along the top. Tapping the board also
works.
Overdrive stays dimmed and inert until the meter is actually full, then lights gold and pulses. A control you cannot use should not look like one you can, and it means the meter filling and the button arming are one event rather than two things to notice. It disarms again the moment you spend it.
Portrait does not run. A 16:9 runner in a tall window is the wrong shape, not a layout problem, so portrait pauses the game behind a blocking overlay and asks for a rotation. Rotating back resumes automatically - but only if the pause was ours; a run you paused yourself stays paused.
Landscape goes fullscreen where the browser allows it: the Fullscreen API is requested
on the first touch, since browsers only grant it from a user gesture. iPhone Safari has no
Fullscreen API at all, so the overlay there points at Add to Home Screen, which is the
only way to lose Safari's bars on that device - the page carries the
apple-mobile-web-app-capable meta so it launches chrome-free once added.
One bug worth recording, because it is easy to repeat. This was wrong:
height: 100dvh;
height: 100vh; /* "fallback" */
The later declaration wins, so 100vh beat 100dvh everywhere. On iOS 100vh is the
height with the browser bars hidden, so while they were showing the layout overflowed
and the bottom of the game was cut off. The fallback has to be declared first.
Two parallax sets ship with the example, switchable from the panel and remembered in
localStorage:
| Style | Layers | Source |
|---|---|---|
| Dojo | 2 | The background.png from the assignment asset pack |
| Meadow | 5 | Kenney.nl, CC0 - flat silhouettes recoloured into a depth ramp |
| Eclipse | 4 | Art by Zoe Ruijten — Parallax Eclipse background, 01, 02, 04, drawn for Nanonauts at 1920×1080 |
Zoe's set is a night scene: a starfield behind an eclipse with an orange corona, jagged mountain silhouettes, and a stone viaduct running along the bottom. Her layers are 1920×1080, exactly double the 960×540 canvas, so they scale by 0.5 with no distortion.
Themes are data, not code — a Themes entry names its layers, their scroll factors and
the ground colours, and Game.setTheme rebuilds the scenery without touching the run.
Adding a third set means adding images to boot() and one entry to Themes.
The eclipse sky uses scroll factor 0, which the Parallax class treats as "paint once
and leave it": a centred eclipse cannot tile, because you would watch a second one slide
in behind the first.
Added to the home screen this runs as a standalone app, and iOS will happily keep
serving the copy it cached the day you added it. There is no build system and no service
worker, so this mirrors the version.json convention the Angular apps use:
{ "name": "Nanonauts: Overdrive", "version": "1.1.0", "buildDate": "..." }
index.html carries the same version in a <meta name="app-version"> and appends ?v=
to every script and stylesheet, so a new version can never be handed a stale file. At
runtime the page polls version.json - at boot, every five minutes, and whenever you
return to a backgrounded app - and offers a Reload when the deployed version differs from
the running one. Reloading navigates to a changed URL, because that is what actually
escapes the document cache.
Deploying is therefore two steps:
python bump-version.py # or: python bump-version.py 1.2.0
copy the folder
The running build is printed on the portrait screen, where play is already stopped:
v1.1.0 · 2026-08-27.
Two, switchable from the panel and remembered:
| Character | Clips | Source |
|---|---|---|
| Nanonaut | one 7-frame run cycle | assignment asset pack |
| Spaceman | run (6), jump (5), duck (6) | art by Zoe Ruijten |
| Adventurer | run (8), jump (10), slide (5) | GameArt2D.com, CC0 |
A character is a set of clips, not one strip. Which clip plays follows from the physics, so the extra art costs the player no extra controls:
feet on the ground run
moving upward jump
falling faster than 260 px/s duck
The nanonaut only ever had a run cycle, so all three of its clips point at the same sheet and it behaves exactly as before.
Zoe's three cycles have different proportions from each other - 150x190, 144x210 and
168.33x190 - so the draw width is derived per clip from its own cell rather than fixed
once for the character. Two of her sheets do not divide into whole pixels; Sheet takes
the cell size from the image, so that costs nothing.
Her seven-frame meteor became a hazard of its own: it enters on a diagonal rather than straight down and throws a shadow that tightens as it closes, which is the tell. Riding a sheep or spending Overdrive shatters one for points instead of taking the hit.
| Idea | Taken from | Where it lives |
|---|---|---|
| Engine / game separation, layered rendering | Jeroen Suurmond | engine.js throughout |
| Fixed-timestep accumulator | Jeroen Suurmond | Engine.#frame |
| Six-layer parallax with mirrored tiling | Jeroen Suurmond | Parallax |
| Touch controls on pointer events | Jeroen Suurmond | Input.bindButton |
| Decaying score multiplier driving a D–S rank | Matthijs Matinu | Game.bumpCombo, Game.rank |
| Stomp gated on downward velocity | Benjamin van Driel | Robot.hit, Sheep.hit |
| Named localStorage leaderboard | Dean den Dunnen | Leaderboard |
| A meter that fills and cashes out | Alex Wisse (Rush) | Game.triggerOverdrive |
| Depth-sorted scenery | Timo & Jorn Stroetenga | Bush, layer choice in #spawn |
| Eclipse parallax art, scenery props | Zoe Ruijten | assets/zoe/, Themes.eclipse |
| Spaceman character and meteor | Zoe Ruijten | Characters.spaceman, Meteor |
The two 2023 builds (Rowan van der Wel and Jari du Puy — the same program) contributed the shape of the collision helper, which every later build inherited in some form.
Tristan Vermeulen built the same game in Delphi (NanoDelphiRunner, with a companion
NanoDelphiRunnerServer). Nothing from it is copied here — it is worth reading as the
counterpart: the same brief solved in a compiled, form-based language rather than in a
canvas.
Each of these is a real defect found in at least one archived build. The code carries a
TRAP: comment at the site of each one.
| # | Trap | Fix here |
|---|---|---|
| 1 | Unclamped frame delta — alt-tab away and the accumulator queues thousands of physics steps into one frame, freezing the tab | Engine.MAX_FRAME_SECONDS caps elapsed time at 0.25 s |
| 2 | Removal queue never emptied, leaking every object ever destroyed and re-scanning it forever | #sweep() clears #pending after the splice |
| 3 | Space scrolls the page while you play |
preventDefault on the keys the game actually uses |
| 4 | Held keys stick after the window loses focus | blur and visibilitychange both release everything |
| 5 | Sprite-sheet cell width typed by hand — 908 ÷ 5 is 181.6, not 181 or 182 | Sheet derives cell size from the image |
| 6 | Pixel art drawn at fractional coordinates with smoothing on | imageSmoothingEnabled = false, destination rounded |
| 7 | Canvas squashed out of ratio by height + max-width beating aspect-ratio |
One dimension driven by min(), ratio derives the other |
| 8 | HUD text at 1.2:1 contrast against the sky | Every readout stroked before it is filled |
| 9 | Animation tied to frame count, so it runs 2.4× fast at 144 Hz | Animator advances on elapsed time |
| 10 | Unguarded JSON.parse on localStorage kills the game at the moment it saves |
Store.get catches and falls back |
| 11 | Config set before init() silently overwritten by it |
Entities take their configuration in the constructor |
| 12 | Restart racing a setTimeout and building two of everything |
engine.clear() is synchronous |
| 13 | Score mutated inside the draw call | All state changes happen in Game.tick |
| 14 | Leftover debug = true and console.log shipped to production |
Neither exists in this build |
Full write-ups of two of the builds these came from:
proto.stroetenga.nl/Nanonauts/matthijs-nanonauts/claude-analysis.html and
.../jeroen-nanonauts/claude-analysis.html.
Not oversights — these are the obvious next exercises:
Engine.advance() exists but nothing uses it yet. Because the timestep is fixed,
it makes the game deterministic and therefore testable — a real test suite would be a
good assignment on its own.engine.js — Engine, then Entity. About 250 lines and it is the whole
architecture.game.js — Player first, then one hazard. Every entity has the same four hooks.director.js — Game.tick and #spawn. This is where difficulty lives.The engine has no idea Nanonauts exists. If you want to build something else with it,
delete game.js and director.js and keep the rest.
Advertisement