The Endless Runner
A small canvas game from the museum's niche, kept here as a teaching example: the complete, annotated source is below the game. If you are a student wondering how a game like this works — read on after playing.
- Hold down any key to jump — the longer you hold, the higher you go.
- You can jump a second time in mid-air.
- Collect as many muffins as you can in 3 minutes.
- Touchscreen works too.
How it works
Everything happens inside one function, tick(), which the
browser calls about 60 times per second. Each tick advances the jump,
maybe spawns a muffin, tests collisions, updates the score line, and
redraws the canvas. That repeated cycle is the game loop — the
heart of nearly every video game.
The jump is real physics. animateJump()
computes the runner's height with the projectile-motion formula
y(t) = y₀ − v₀·t + (g/2)·t² — the same equation you know
from physics class. Releasing the key early cuts the remaining upward
speed to 25% (endJump()), which is why a tap gives a hop and
holding gives a full jump.
Muffins spawn from randomness with rules.
spawnMuffins() only creates a muffin when enough time has
passed, not too many are in flight, and a random roll succeeds. Each
muffin then glides left on a tween while bobbing up and down — the bob's
easing function changes every ten levels, so the movement keeps
surprising you.
Collision is two rectangle checks.
hitboxes.js implements axis-aligned bounding boxes: two
rectangles overlap exactly when their intervals overlap on both the x and
the y axis. catchMuffins() builds one box for the runner and
one for each nearby muffin and asks that question every tick.
Difficulty is a feedback loop. Catch five in a row and
the level jumps by two; miss five more than you catch and it drops by
one (updateLevel()). The game constantly seeks the speed at
which you can just barely keep up — a tiny example of adaptive
difficulty.
The code
Two files, no build step: main.js is the game,
hitboxes.js is the collision helper.
CreateJS provides the stage,
sprites and tweens.
main.js
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
78
79
80
81
82
83
84
85
86
87
88
89
90
91
92
93
94
95
96
97
98
99
100
101
102
103
104
105
106
107
108
109
110
111
112
113
114
115
116
117
118
119
120
121
122
123
124
125
126
127
128
129
130
131
132
133
134
135
136
137
138
139
140
141
142
143
144
145
146
147
148
149
150
151
152
153
154
155
156
157
158
159
160
161
162
163
164
165
166
167
168
169
170
171
172
173
174
175
176
177
178
179
180
181
182
183
184
185
186
187
188
189
190
191
192
193
194
195
196
197
198
199
200
201
202
203
204
205
206
207
208
209
210
211
212
213
214
215
216
217
218
219
220
221
222
223
224
225
226
227
228
229
230
231
232
233
234
235
236
237
238
239
240
241
242
243
244
245
246
247
248
249
250
251
252
253
254
255
256
257
258
259
260
261
262
263
264
265
266
267
268
269
270
271
272
273
274
275
276
277
278
279
280
281
282
283
284
285
286
287
288
289
290
291
292
293
294
295
296
297
298
299
300
301
302
303
304
305
306
307
308
309
310
311
312
313
314
315
316
317
318
319
320
321
322
323
324
325
326
327
328
329
330
331
332
333
334
335
336
337
338
339
340
341
342
343
344
345
346
347
348
349
350
351
352
353
354
355
356
357
358
/*
* The Endless Runner — a small CreateJS game, kept as a teaching example.
*
* The whole game is driven by one function, tick(), which runs ~60 times a
* second. Each tick it advances the jump physics, maybe spawns a muffin,
* tests for collisions, updates the score, and redraws the stage. Everything
* else is setup or bookkeeping around that loop.
*
* Reading order: CONFIG → init() → handleComplete() → input handlers →
* physics → spawning → collision/scoring → tick().
*
* (Started long ago from http://www.createjs.com/Demos/EaselJS/SpriteSheet.)
*/
"use strict";
/* ------------------------------------------------------------------ *
* CONFIG — every tuning knob in one place.
*
* Jump physics use classic projectile motion. Time is measured in units
* of 10 ms, so the constants stay small and friendly.
* ------------------------------------------------------------------ */
const GRAVITY = 0.4; // downward acceleration, px per time-unit²
const JUMP_VELOCITY = 13.2; // upward speed at take-off, px per time-unit
const RELEASE_DAMPING = 0.25; // releasing the key early keeps 25% of the rise
const BOTTOM_PADDING = 160; // distance of the ground line from canvas bottom
const TOP_PADDING = 60; // muffins never spawn above this line
const GAME_DURATION = 180000; // 3 minutes, in ms
const PLAYER_LEFT = 75; // the runner's hitbox is a fixed column …
const PLAYER_RIGHT = 150; // … between these two x coordinates
/* One ease pair per difficulty decade: level 0–9 muffins bob like a sine
* wave, 10–19 overshoot (back), 20–29 bounce, and so on. Replaces what was
* once a 100-line if/else chain with a table lookup. */
const WAVE_EASES = [
["sineOut", "sineIn"], ["backOut", "backIn"], ["bounceOut", "bounceIn"],
["circOut", "circIn"], ["cubicOut", "cubicIn"], ["elasticOut", "elasticIn"],
["quadOut", "quadIn"], ["quartOut", "quartIn"], ["quintOut", "quintIn"],
["sineOut", "sineIn"],
];
/* ------------------------------------------------------------------ *
* GAME STATE
* ------------------------------------------------------------------ */
let stage, w, h; // the CreateJS stage and canvas size
let loader; // preloads the two images
let runner; // the sprite the player controls
let runnerBounds; // its scaled size, measured once
let groundY; // y position where the runner stands
let muffinTemplate; // a Bitmap we clone for every spawned muffin
let muffinBounds; // intrinsic muffin size
let muffinHolder; // container holding all muffins in flight
let pointsHolder; // container holding floating "+points" labels
let pointsTemplate; // a Text we clone for those labels
let hudText; // the status line in the top-left corner
let gameStartTime = 0; // ticker time when the game actually began
let level = 1; // difficulty; rises and falls with your play
let streak = 0; // muffins caught in a row
let ratio = 0; // caught minus missed, since the last level change
let score = 0;
let lastSpawnTime = 0; // when the previous muffin appeared
let jumpCount = 0; // 0 = on the ground, 1 = jumping, 2 = double jump
let jumpStartTime = 0; // take-off moment, in 10 ms units
let jumpStartY = 0; // y position at take-off
let jumpVelocity0 = 0; // take-off velocity for the current arc
let keyIsDown = false; // gate so holding a key fires only one jump
let gameOver = false;
/* ------------------------------------------------------------------ *
* SETUP
* ------------------------------------------------------------------ */
function init() {
stage = new createjs.Stage("gameCanvas");
createjs.Touch.enable(stage);
w = stage.canvas.width;
h = stage.canvas.height;
groundY = h - BOTTOM_PADDING;
loader = new createjs.LoadQueue(false);
loader.addEventListener("complete", handleComplete);
loader.loadManifest([
{ src: "runs.png", id: "runner" },
{ src: "muffin.png", id: "muffin" },
]);
pointsTemplate = new createjs.Text("", "bold 17px Helvetica", "#565656");
}
function handleComplete() {
document.getElementById("loader").className = "";
pointsHolder = stage.addChild(new createjs.Container());
muffinHolder = stage.addChild(new createjs.Container());
// The sprite sheet is a film strip: 11 frames of 400×224 px side by side.
const sheet = new createjs.SpriteSheet({
images: [loader.getResult("runner")],
frames: { width: 400, height: 224, count: 11, regX: 0, regY: 0 },
animations: {
run: [0, 10, "run", 0.5], // loop frames 0–10 at half speed
jump: [7, 7, "jump", 0], // hold frame 7 while airborne
},
});
runner = new createjs.Sprite(sheet, "run");
runner.setTransform(1, groundY, 0.7, 0.7);
runner.framerate = 60;
runnerBounds = runner.getTransformedBounds();
hudText = new createjs.Text("0", "bold 20px Helvetica", "#000");
hudText.x = 10;
hudText.y = 10;
stage.addChild(runner, hudText);
muffinTemplate = new createjs.Bitmap(loader.getResult("muffin"));
muffinBounds = muffinTemplate.getBounds();
// The game clock starts now, not when the page loaded.
gameStartTime = createjs.Ticker.getTime();
createjs.Ticker.timingMode = createjs.Ticker.RAF;
createjs.Ticker.setFPS(60);
createjs.Ticker.addEventListener("tick", tick);
document.addEventListener("keydown", handleKeyDown);
document.addEventListener("keyup", handleKeyUp);
document.addEventListener("touchstart", handleKeyDown);
document.addEventListener("touchend", handleKeyUp);
// If the tab loses focus mid-press we never get the keyup, so re-arm.
window.addEventListener("focus", function () { keyIsDown = false; });
}
/* ------------------------------------------------------------------ *
* INPUT — any key or touch works. The keyIsDown gate turns the browser's
* auto-repeat into a single press-and-release pair, which is what makes
* "hold longer to jump higher" possible.
* ------------------------------------------------------------------ */
function handleKeyDown(e) {
if (e.key === " ") e.preventDefault(); // space must not scroll the page
if (keyIsDown || e.repeat) return;
keyIsDown = true;
startJump();
}
function handleKeyUp() {
keyIsDown = false;
endJump();
}
/* ------------------------------------------------------------------ *
* JUMP PHYSICS — plain projectile motion:
*
* y(t) = y₀ − v₀·t + (g/2)·t²
*
* with t in 10 ms units since take-off. The runner may take off twice
* (double jump); the second press simply restarts the arc mid-air.
* ------------------------------------------------------------------ */
function startJump() {
if (jumpCount <= 1) {
jumpVelocity0 = JUMP_VELOCITY;
runner.gotoAndPlay("jump");
jumpCount++;
jumpStartTime = Date.now() / 10;
jumpStartY = runner.y;
} else {
jumpCount++; // extra presses in the air do nothing, but are counted
}
}
/* Releasing early cuts the remaining rise: we compute the current velocity
* on the arc, and if the runner is still moving up, restart the arc from
* here with only 25% of that velocity. Tap = low hop, hold = full jump. */
function endJump() {
if (jumpCount === 1 || jumpCount === 2) {
const t = Date.now() / 10 - jumpStartTime;
const velocity = jumpVelocity0 - GRAVITY * t;
if (velocity > 0) {
jumpVelocity0 = velocity * RELEASE_DAMPING;
jumpStartTime = Date.now() / 10;
jumpStartY = runner.y;
}
}
}
/* Advance the arc one frame; land when we reach the ground line. */
function animateJump() {
const t = Date.now() / 10 - jumpStartTime;
const nextY = jumpStartY - jumpVelocity0 * t + (GRAVITY / 2) * t * t;
if (nextY >= groundY) {
jumpCount = 0;
runner.y = groundY;
runner.gotoAndPlay("run");
} else {
runner.y = nextY;
}
}
/* ------------------------------------------------------------------ *
* SPAWNING — each tick we MAY spawn one muffin, if all three hold:
* 1. enough time has passed since the last spawn,
* 2. there aren't too many muffins in flight already,
* 3. a random roll succeeds (more often on higher levels).
* The muffin then tweens from the right edge to the left while bobbing
* up and down with the ease pair of the current difficulty decade.
* ------------------------------------------------------------------ */
function spawnMuffins() {
const sinceLast = Date.now() - lastSpawnTime;
const maxInFlight = 5 + level / 10;
const spawnChance = 0.985 - level / 1000;
if (sinceLast <= 30 + level ||
muffinHolder.getNumChildren() >= maxInFlight ||
Math.random() <= spawnChance) {
return;
}
const muffin = muffinTemplate.clone();
muffin.x = w + 15;
muffin.y = Math.random() * (h - BOTTOM_PADDING - TOP_PADDING) + TOP_PADDING;
// Don't spawn on top of a muffin that is still near the right edge.
const newBox = new Hitbox(muffin.x, muffin.y,
muffin.x + muffinBounds.width, muffin.y + muffinBounds.height);
for (let i = 0; i < muffinHolder.getNumChildren(); i++) {
const other = muffinHolder.getChildAt(i);
const pos = other.localToGlobal(0, 0);
const otherBox = new Hitbox(pos.x, pos.y,
pos.x + muffinBounds.width, pos.y + muffinBounds.height);
if (boxesCollide(newBox, otherBox)) return; // try again next tick
}
muffin.lvl = level; // points are scored with the level it spawned at
lastSpawnTime = Date.now();
// Higher level → faster flight. 30000/(level/4+4) ms to cross the canvas.
const flightTime = 30000 / (level / 4 + 4);
const waveTime = flightTime / 10;
const amplitude = 7 + Math.round(level / 5);
const ease = WAVE_EASES[Math.min(Math.floor(level / 10), 9)];
createjs.Tween.get(muffin).to({ x: -25 }, flightTime);
createjs.Tween.get(muffin, { loop: true })
.to({ y: muffin.y + amplitude }, waveTime, createjs.Ease[ease[0]])
.to({ y: muffin.y }, waveTime, createjs.Ease[ease[1]])
.to({ y: muffin.y - amplitude }, waveTime, createjs.Ease[ease[0]])
.to({ y: muffin.y }, waveTime, createjs.Ease[ease[1]]);
muffinHolder.addChild(muffin);
}
/* ------------------------------------------------------------------ *
* COLLISION & SCORING — the runner's hitbox is a fixed column on the
* left; a muffin passing through it while overlapping the runner's
* height is caught. One muffin past the left edge counts as missed.
* ------------------------------------------------------------------ */
function catchMuffins() {
const runnerBottom = runner.y + runnerBounds.height;
for (let i = 0; i < muffinHolder.getNumChildren(); i++) {
const muffin = muffinHolder.getChildAt(i);
const pos = muffin.localToGlobal(0, 0);
if (pos.x < PLAYER_RIGHT && pos.x > PLAYER_LEFT) {
const muffinBox = new Hitbox(pos.x, pos.y,
pos.x + muffinBounds.width, pos.y + muffinBounds.height);
const runnerBox = new Hitbox(PLAYER_LEFT, runner.y, PLAYER_RIGHT, runnerBottom);
if (boxesCollide(muffinBox, runnerBox)) {
popPoints(muffin, pos);
streak++;
ratio++;
muffinHolder.removeChildAt(i);
return; // at most one catch per tick
}
} else if (pos.x < -20) {
streak = 0;
ratio--;
muffinHolder.removeChildAt(i);
return; // at most one miss per tick
}
}
}
/* A little "+points" label floats up from the caught muffin for a second.
* Catching without missing builds a streak that multiplies the reward. */
function popPoints(muffin, pos) {
const points = Math.round(muffin.lvl * muffin.lvl + (streak / 5) * muffin.lvl);
score += points;
const label = pointsTemplate.clone();
label.x = pos.x;
label.y = pos.y;
label.deadline = Date.now() + 1000;
label.text = points.toString();
createjs.Tween.get(label).to({ y: label.y - 50 }, 1000);
pointsHolder.addChild(label);
}
function removeExpiredPoints() {
for (let i = 0; i < pointsHolder.getNumChildren(); i++) {
if (Date.now() > pointsHolder.getChildAt(i).deadline) {
pointsHolder.removeChildAt(i);
return;
}
}
}
/* ------------------------------------------------------------------ *
* DIFFICULTY — a feedback loop. Catch 5 in a row: level +2. Catch 5 more
* than you miss: level +1. Miss 5 more than you catch: level −1. The game
* constantly seeks the speed where you just barely keep up.
* ------------------------------------------------------------------ */
function updateLevel(timeLeft) {
if (streak >= 5) {
level += 2;
streak = 0;
ratio = 0;
} else if (ratio >= 5) {
level += 1;
streak = 0;
ratio = 0;
} else if (ratio <= -5) {
if (level !== 1) level -= 1;
streak = 0;
ratio = 0;
}
hudText.text = "Time: " + Math.round(timeLeft / 1000) +
" lvl:" + level +
" ratio: " + ratio +
" streak: " + streak +
" score: " + score +
" FPS: " + Math.round(createjs.Ticker.getMeasuredFPS());
}
/* ------------------------------------------------------------------ *
* THE GAME LOOP — runs every frame until the timer ends.
* ------------------------------------------------------------------ */
function tick(event) {
if (gameOver) return;
const timeLeft = GAME_DURATION - (createjs.Ticker.getTime() - gameStartTime);
if (timeLeft <= 0) {
gameOver = true;
const resultText = new createjs.Text("Score: " + score, "bold 40px Helvetica", "#000");
resultText.x = w / 2;
resultText.y = h / 2;
resultText.textAlign = "center";
stage.addChild(resultText);
runner.gotoAndStop(1);
createjs.Ticker.setPaused(true);
stage.update(event);
return;
}
if (jumpCount > 0) animateJump();
spawnMuffins();
removeExpiredPoints();
catchMuffins();
updateLevel(timeLeft);
stage.update(event);
}
hitboxes.js
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
/*
* Axis-aligned bounding boxes (AABB) — the simplest useful collision test.
*
* A hitbox is a rectangle described by two corners: (x1, y1) top-left and
* (x2, y2) bottom-right. Two rectangles overlap exactly when their
* intervals overlap on BOTH axes — that is the whole trick.
*/
function Hitbox(x1, y1, x2, y2) {
this.x1 = x1;
this.y1 = y1;
this.x2 = x2;
this.y2 = y2;
}
/* Two intervals [s1, e1] and [s2, e2] overlap unless one ends before the
* other starts. */
function intervalsOverlap(s1, e1, s2, e2) {
return !(e1 <= s2 || e2 <= s1);
}
/* Rectangles collide when they overlap horizontally AND vertically. */
function boxesCollide(a, b) {
return intervalsOverlap(a.x1, a.x2, b.x1, b.x2) &&
intervalsOverlap(a.y1, a.y2, b.y1, b.y2);
}