TinyGames · how it works

Nanonauts: Overdrive

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

Where the design came from

Jetpack Joyride

Sheep Happens

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.

Filling the screen

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.

On a phone

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.


Art styles

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 RuijtenParallax 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.


Versioning and updates

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.


Characters

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.


What each intern build contributed

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.


Traps this build is written to avoid

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.


Things deliberately left undone

Not oversights — these are the obvious next exercises:


Reading order, if you are new to this

  1. engine.jsEngine, then Entity. About 250 lines and it is the whole architecture.
  2. game.jsPlayer first, then one hazard. Every entity has the same four hooks.
  3. director.jsGame.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.