On this page
The game that gave me the idea
Many people know the small dinosaur that runs in Chrome when the network is down. That game came out in September 2014. Edward Jung from the Chrome team was its main programmer. Sebastien Gabriel and Alan Bettes worked on the idea and the design.
My game is a free take on that idea, in my own style. The robot, the bugs and the packets are my own drawings, and I wrote all the code myself. I made it in some free time, when I was bored. It took me a few hours, and I spent them this way a few days before my birthday.
What I wanted
I wanted a small game for a page of my site. You jump over things, the speed grows, and you try to beat your best score. It is the kind of game that a browser used to show when the network was down.
I gave myself some rules before I wrote any code:
- The game does nothing until the visitor clicks, taps or presses a key.
- It uses no library, and it loads no file for pictures or sound.
- It sends nothing anywhere.
- It can take its colors from the page, so it fits a light theme and a dark theme.
The result is one JavaScript file and some CSS. The game draws on a canvas. This post walks through the parts that needed thought. The numbers come from small scripts that I ran, and you can run them too.
The loop and the time step
A browser calls the function that you give to requestAnimationFrame (opens in a new tab) just before each repaint. In that function I find the time since the last frame, update the game, draw it, and ask for the next frame. Every move is multiplied by that time, so the game runs at the same speed on a 60 Hz screen and on a 144 Hz screen.
Two limits protect the step. It is never below zero, and it is never above 50 milliseconds. Without the upper limit, a tab that was away for a minute could return with a huge step, and the runner would fly through the first bug.
The lower limit comes from a real bug. My first test crashed. On the first frame, the time that the browser passed to my function was 3 milliseconds before my own start time. The step was negative, the game clock went below zero, and the animation of the legs took the remainder of a negative number. In JavaScript the remainder of a negative number is negative, so the index was -1 and the picture was undefined. This file shows it:
// The bug that the first test found: on the first frame the time can be a little smaller than the start time.
// Run: node time-step.mjs
const LEGS = [['legs A'], ['legs B']];
/** The old code: a negative time gives a negative remainder, and LEGS[-1] is undefined. */
function legsOld(time) {
return LEGS[Math.floor(time * 12) % 2];
}
/** The fix: the step is never negative, and one frame never takes more than 50 milliseconds. */
function step(now, last) {
return Math.max(0, Math.min((now - last) / 1000, 0.05));
}
const first = (1000.0 - 1003.0) / 1000; // the frame time is 3 ms before the start time
console.log('old: time after the first frame =', first);
console.log('old: legs =', legsOld(first));
console.log('fixed: step =', step(1000.0, 1003.0));
console.log('fixed: a long pause of 3 s gives step =', step(4000, 1000));old: time after the first frame = -0.003
old: legs = undefined
fixed: step = 0
fixed: a long pause of 3 s gives step = 0.05The jump
A jump is a start speed upward and a gravity that pulls the speed down at every frame. The update has two lines: first the speed changes, then the height changes with the new speed. With a start speed of 720 pixels a second and a gravity of 2400, the exact peak is 720 squared divided by 2 times 2400, which is 108 pixels. The jump lasts 0.6 seconds.
One more rule makes the game feel good. When the player lets go of the key early, gravity becomes 1.9 times stronger. A short tap gives a short hop, and a long press gives the full jump.
// The jump of the runner: the same numbers and the same update step as the game.
// Run: node jump-arc.mjs
const GRAVITY = 2400; // pixels per second squared
const JUMP_V = 720; // pixels per second, upward
const DT = 1 / 60; // one frame at 60 frames per second
/** Simulates one jump. Releasing the key early makes gravity 1.9 times stronger. */
function jump(releaseEarly) {
let y = 0;
let vy = JUMP_V;
let peak = 0;
let frames = 0;
while (y > 0 || frames === 0) {
const g = releaseEarly ? GRAVITY * 1.9 : GRAVITY;
vy -= g * DT;
y += vy * DT;
peak = Math.max(peak, y);
frames++;
}
return { peak: peak.toFixed(1), seconds: (frames * DT).toFixed(2), frames };
}
console.log('full jump :', jump(false));
console.log('short hop :', jump(true));
console.log('exact peak, full :', (JUMP_V ** 2 / (2 * GRAVITY)).toFixed(1));
console.log('exact peak, short :', (JUMP_V ** 2 / (2 * GRAVITY * 1.9)).toFixed(1));full jump : { peak: '102.0', seconds: '0.58', frames: 35 }
short hop : { peak: '51.0', seconds: '0.30', frames: 18 }
exact peak, full : 108.0
exact peak, short : 56.8The simulated peak is 102 pixels, a little under the exact 108. The reason is that the simulation takes 60 separate steps a second. In play the jump feels the same, and the exact numbers are good enough to plan the obstacles.
Obstacles and fair gaps
After each obstacle, the game waits for a random distance before it makes the next one. The smallest distance is the speed times 0.62, plus 90 pixels. A jump keeps the runner in the air for 0.6 seconds, and during that time the runner moves forward with the world speed. If the gap were shorter than that distance, the runner would land on the next bug. The 0.62 is a little more than 0.6, and the 90 pixels give a margin.
The second question is whether a jump clears the widest obstacle. The runner must stay above the height of the obstacle for as long as it takes to cross the obstacle and its own width. This script solves the height equation and prints the spare room in pixels:
// Can the player always clear the obstacles? The same numbers as the game (pixel look).
// Run: node gaps.mjs
const GRAVITY = 2400;
const JUMP_V = 720;
const PLAYER_W = 39;
/** Seconds that a full jump stays above a height. Solves JUMP_V * t - GRAVITY / 2 * t * t = height. */
function timeAbove(height) {
const d = JUMP_V ** 2 - 2 * GRAVITY * height;
return (2 * Math.sqrt(d)) / GRAVITY; // the time going up plus the time coming down
}
const obstacles = [
{ name: 'bug', w: 33, h: 30 },
{ name: 'two bugs', w: 69, h: 30 },
{ name: 'tall bug', w: 33, h: 39 },
];
const airSeconds = (2 * JUMP_V) / GRAVITY;
console.log('speed min gap distance in the air room over the widest obstacle');
for (const speed of [300, 400, 500, 600, 760]) {
const minGap = speed * 0.62 + 90; // the smallest gap that the game allows
const air = speed * airSeconds;
const worst = Math.min(
...obstacles.map((o) => speed * timeAbove(o.h) - (o.w + PLAYER_W)),
);
console.log(
String(speed).padEnd(7),
minGap.toFixed(0).padEnd(9),
air.toFixed(0).padEnd(21),
worst.toFixed(0),
);
}speed min gap distance in the air room over the widest obstacle
300 276 180 45
400 338 240 96
500 400 300 147
600 462 360 198
760 561 456 280At the start speed of 300 pixels a second, the widest obstacle leaves 45 pixels to spare. The room grows with the speed, because the runner covers more ground during the same jump. The gap is always longer than the distance in the air, in every row of the table.
The test for a hit uses boxes that are a little smaller than the pictures. A near miss then looks like a miss to the player. The margins are between 2 and 6 pixels. I chose them by looking at the game.
// The test for a hit. The boxes are a little smaller than the pictures, so a near miss is not a hit.
// Run: node collision.mjs
function hits(p, o) {
return (
p.x + 6 < o.x + o.w - 4 &&
p.x + p.w - 6 > o.x + 4 &&
p.y + 4 < o.y + o.h - 3 &&
p.y + p.h - 2 > o.y + 3
);
}
const GROUND = 168;
const player = (lift) => ({ x: 64, y: GROUND - lift - 39, w: 39, h: 39 });
const bug = (x) => ({ x, y: GROUND - 30, w: 33, h: 30 });
console.log('bug right in front, on the ground :', hits(player(0), bug(90)));
console.log('bug touches by 3 pixels :', hits(player(0), bug(100)));
console.log('player 40 px up, bug below :', hits(player(40), bug(70)));
console.log('bug far away :', hits(player(0), bug(300)));bug right in front, on the ground : true
bug touches by 3 pixels : false
player 40 px up, bug below : false
bug far away : falsePictures from text
The pixel look has no image files. Each picture is a list of text rows. A # is the main color, a k is the dark color, and a dot is empty. The code reads the rows and draws one square for each mark. One picture pixel is 3 by 3 canvas pixels. The score and the words GAME OVER use the same idea, with a letter map of 5 by 7 marks for each letter.
// A picture is a list of text rows. '#' is the main color, 'k' the dark color, '.' is empty.
// Run: node sprites.mjs
const BUG = [
'..#.....#..',
'...#...#...',
'.#########.',
'###########',
'##kk###kk##',
'##kk###kk##',
'###########',
'.#########.',
'..#.#.#.#..',
'..#.....#..',
];
const S = 3; // one picture pixel is 3 by 3 canvas pixels
/** The squares to draw: x, y, size and which color. */
function squares(rows) {
const list = [];
rows.forEach((row, j) => {
for (let i = 0; i < row.length; i++) {
if (row[i] !== '.') {
list.push({ x: i * S, y: j * S, size: S, dark: row[i] === 'k' });
}
}
});
return list;
}
const list = squares(BUG);
console.log(BUG.join('\n'));
console.log('size in canvas pixels:', BUG[0].length * S, 'x', BUG.length * S);
console.log('squares to draw:', list.length, '(dark:', list.filter((q) => q.dark).length + ')');..#.....#..
...#...#...
.#########.
###########
##kk###kk##
##kk###kk##
###########
.#########.
..#.#.#.#..
..#.....#..
size in canvas pixels: 33 x 30
squares to draw: 72 (dark: 8)One bug is 72 squares. The canvas keeps a fixed size of 720 by 200, and the browser makes it bigger without smoothing, so the edges stay sharp. I also set imageSmoothingEnabled (opens in a new tab) to false on the drawing context. The other look, which I call classic, draws smooth shapes and scales the canvas to the width of the page.
Sound without files
The sounds come from the Web Audio API (opens in a new tab). An oscillator makes a square wave. A gain node fades the volume to almost zero, so the tone ends without a click. The jump is a short rise from 440 to 900 Hz. A point gives two quick beeps. The crash is a falling tone.
const osc = audio.createOscillator();
const gain = audio.createGain();
osc.type = 'square';
osc.frequency.setValueAtTime(440, now);
osc.frequency.exponentialRampToValueAtTime(900, now + 0.13);
gain.gain.setValueAtTime(0.05, now);
gain.gain.exponentialRampToValueAtTime(0.001, now + 0.13);
osc.connect(gain);
gain.connect(audio.destination);
osc.start(now);
osc.stop(now + 0.15);The fade ends at 0.001 because an exponential ramp cannot reach zero. Browsers also keep audio off until the visitor does something on the page. Because of these autoplay rules (opens in a new tab), the game creates the audio context at the first sound, which always comes after a click or a key. A mute button turns the sound off, and the choice stays in the browser.
Colors from the page
The game can sit in a dark window with fixed colors. It can also run with no frame, and then it uses the colors of the page. The page keeps its colors in CSS variables, so the game asks for them with getComputedStyle (opens in a new tab). A canvas wants a color it understands, and a CSS value can come in many forms. A small trick turns any valid value into a standard one: assign it to fillStyle and read it back.
function norm(value) {
ctx.save();
ctx.fillStyle = '#000000';
ctx.fillStyle = value; // an invalid color keeps the old value
const out = ctx.fillStyle;
ctx.restore();
return out;
}When the visitor switches between the light and the dark theme, the page changes an attribute on the root element. A MutationObserver (opens in a new tab) sees it, the game reads the colors again, and it draws a new frame. The clouds and the ground use a mix of the page colors, so they are quiet in both themes.
Small things that matter
- The keys work only while the game has focus. The space bar still scrolls the rest of the page.
- The right click menu is off on the game only. The rest of the page keeps its menu.
- The loop stops when the tab is hidden, when the game is out of view, and when the About box is open. The page uses the
visibilitychange(opens in a new tab) event and an IntersectionObserver (opens in a new tab). - The best score and the mute choice live in
localStorage(opens in a new tab). Every read and write sits in atryblock, because a browser can block storage. - A hidden line with
aria-livetells a screen reader when the game starts and ends, and what the score was. - The About box is a
dialog(opens in a new tab) element. The browser gives it the Escape key and the focus trap, and a form withmethod="dialog"closes it even without JavaScript.
How I tested it
The numbers live in small scripts, like the ones above. They run in Node and print what they find. The game itself needs a browser. I wrote a test that starts Chrome with no window and controls it over the DevTools Protocol. The test presses the space bar, waits until the runner crashes, and checks the status line and the saved score. Then it restarts the game, turns the sound off and on, opens and closes the About box, loads the page at the width of a phone, and counts script errors. After that I played it on my own phone.
That test found the time bug from the start of this post. It also checks that a keypress outside the game does not start it.
What I would still change
I tuned the speed and the gaps by feel. I tried the game on my phone, and the touch controls work well.
If you build something like this, start with the time step and the jump. Once those two feel right, everything else is drawing.
Read more
- Wikipedia: Dinosaur Game (opens in a new tab), the game that gave me the idea
- MDN: requestAnimationFrame (opens in a new tab)
- MDN: Web Audio API (opens in a new tab)
- MDN: Autoplay guide for media and Web Audio APIs (opens in a new tab)
- MDN: imageSmoothingEnabled (opens in a new tab)
- MDN: HTMLDialogElement (opens in a new tab)
Was this post helpful?