Eight weeks ago you were told you were building an engine, not a game. Here is the test of that. Take your engine.js, exactly as it is, and build a different game on it: a top-down dodge, no gravity, no platforms, no jumping. A player that moves in four directions, walls that slide across the arena, a hunter that comes for you, coins to collect, three lives, rounds that get faster. If it can be built without adding a single function to the engine, then the engine was real.
Nobody walks you through this one. Each section tells you where a piece of code goes and what the screen should show when you run it, and if yours does not match, that is the moment to stop and look. The finished version is on the Play tab, so you can see where you are heading and compare when something is off.
The whole game is about 120 lines of game.js. Every one of those lines is a call into functions you wrote.
Make a new project in Mr Code Editor, the way you did in the first week: add p5.js from Libraries, rename script.js to game.js, and add a file called engine.js. Then open your platformer, copy the whole of its engine.js, and paste it into the new one. Do not change a line of it. In index.html, the engine loads first, the same as before:
<script src="engine.js"></script>
<script src="game.js"></script>
Your game.js is empty. That is the whole point. Everything the platformer knew about gravity, jumping, landing and cameras is sitting in the engine, unused, waiting to see whether it is needed.
At the top of game.js:
// Dodge. A second game on the same engine, and not one new function in it.
const SPEED = 3; // the player moves this far each frame, in any of four directions
let player;
let slow = false; // S watches the same game at 5 frames a second
The level is built by a function of its own from the start, because the game is going to rebuild it every round, and you learned in week 6 what happens when a level lives inside setup. The player is a square this time, 30 by 30, standing at the bottom middle:
function setup() {
createCanvas(700, 400);
buildArena();
}
function buildArena() {
player = body(width / 2 - 15, height - 50, 30, 30);
}
Now draw. Four directions instead of two, and no applyGravity anywhere, because nothing in this world falls. The player would happily leave the screen in any of the four directions, so clamp holds it in both:
function draw() {
background('#20242f');
// move: four directions, and nothing pulls the player down
if (keyIsDown(LEFT_ARROW)) player.x -= SPEED;
if (keyIsDown(RIGHT_ARROW)) player.x += SPEED;
if (keyIsDown(UP_ARROW)) player.y -= SPEED;
if (keyIsDown(DOWN_ARROW)) player.y += SPEED;
player.x = clamp(player.x, 0, width - player.w);
player.y = clamp(player.y, 0, height - player.h);
// draw the arena
fill('#285ac8');
rect(player.x, player.y, player.w, player.h);
}
function keyPressed() {
if (key === 'm' || key === 'M') DEBUG = !DEBUG;
if (key === 's' || key === 'S') { slow = !slow; frameRate(slow ? 5 : 60); }
if (key === 'r' || key === 'R') buildArena();
}
Run it. A dark arena and a blue square. The arrow keys push it around, it stops dead at every edge, and it never falls. Press M: a pink box around the square and a blue arrow that stays short, because vx and vy are never set here. The keys move the position directly, the way they did in week 1.
body and clamp are doing exactly what they did on day one. applyGravity is in your engine and this game will never call it. An engine is not a set of rules every game must follow. It is a set of rules a game can pick from.
Something has to be in the way. Three bars that slide across the arena, each at its own speed. A wall is a body plus its two edges, the same shape as a mover in the platformer, with one more number so each can have its own speed. Under let slow at the top:
let walls = [];
In buildArena, after the player. The edges are 40 in from each side, so a wall never quite touches the border where the player might be hiding:
function buildArena() {
player = body(width / 2 - 15, height - 50, 30, 30);
walls = [
{ b: body(120, 80, 70, 18), from: 40, to: 660, speed: 2 },
{ b: body(480, 160, 70, 18), from: 40, to: 660, speed: 3 },
{ b: body(300, 240, 70, 18), from: 40, to: 660, speed: 2.5 },
];
}
They move with patrol, which does not know or care that it used to move an enemy along the ground. In draw, after the two clamp lines:
// things that move on their own
for (const w of walls) patrol(w.b, w.from, w.to, w.speed);
Touching a wall sends the player home. Walls are boxes, so the box test is the right test. Under the patrol line:
// ouch
for (const w of walls) {
if (overlaps(player, w.b)) respawn();
}
respawn belongs to the game, because only the game knows where home is. Put it between draw and keyPressed:
function respawn() {
player.x = width / 2 - 15;
player.y = height - 50;
}
Draw the walls in the arena block, above the player so the player is always on top:
// draw the arena
fill('#a07850');
for (const w of walls) rect(w.b.x, w.b.y, w.b.w, w.b.h);
fill('#285ac8');
rect(player.x, player.y, player.w, player.h);
Run it. Three brown bars slide back and forth at three speeds, turning at 40 and 660. Walk into one and you are back at the bottom middle. Slip between them and nothing happens, because a box test only fires when the boxes cross.
Walls are predictable. Now something that is not. A hunter starts in the top right corner and comes straight for the player, wherever the player goes, at a fair speed in every direction, and you already own the function that does that. Under let walls:
let hunter;
let hunterSpeed = 1.2; // goes up a little every round
In buildArena, on the line after the player:
function buildArena() {
player = body(width / 2 - 15, height - 50, 30, 30);
hunter = body(640, 30, 26, 26);
walls = [
It moves with moveToward, on the line after the walls patrol:
// things that move on their own
for (const w of walls) patrol(w.b, w.from, w.to, w.speed);
moveToward(hunter, player, hunterSpeed);
The hunter is round, so it gets the round test, on the line after the walls' ouch loop:
for (const w of walls) {
if (overlaps(player, w.b)) respawn();
}
if (touching(player, hunter)) respawn();
Draw it as a purple circle, between the walls and the player:
fill('#a07850');
for (const w of walls) rect(w.b.x, w.b.y, w.b.w, w.b.h);
fill('#966edc');
circle(hunter.x + hunter.w / 2, hunter.y + hunter.h / 2, hunter.w);
fill('#285ac8');
rect(player.x, player.y, player.w, player.h);
And when the player goes home, so does the hunter, or it would be standing on the spawn point waiting. In respawn:
function respawn() {
player.x = width / 2 - 15;
player.y = height - 50;
hunter.x = 640;
hunter.y = 30;
}
Run it. The purple circle leaves its corner and comes at you along a straight line, and keeps coming as you move. It is slower than you, so you can keep away from it, until a wall gets in the way. Press M and watch its arrow: the same length whichever way it heads. The diagonal bug you fixed in week 7 stays fixed, because the fix lives in the engine and not in the platformer.
Something to go and get, so that dodging has a reason. Six coins, round, collected with the round test. Under let hunterSpeed:
let coins = [];
let score = 0;
In buildArena, after the walls list. They sit in the gaps between the wall rows:
{ b: body(300, 240, 70, 18), from: 40, to: 660, speed: 2.5 },
];
coins = [
body(80, 120, 16, 16),
body(600, 120, 16, 16),
body(340, 40, 16, 16),
body(180, 300, 16, 16),
body(520, 300, 16, 16),
body(340, 200, 16, 16),
];
}
Collecting is the block from week 6, unchanged. In draw, between the things that move and the ouch section:
// collect
for (const c of coins) {
if (touching(player, c)) score += 1;
}
coins = coins.filter(c => !touching(player, c));
Draw them first in the arena block, so everything else is drawn over them:
// draw the arena
fill('#f0c828');
for (const c of coins) circle(c.x + c.w / 2, c.y + c.h / 2, c.w);
fill('#a07850');
And the score, at the end of draw:
// the score
fill('#e6e6e6');
textSize(14);
text("coins: " + score, 20, 28);
Run it. Six coins, a count in the corner that climbs as you take them, and a hunter that makes every one of them a decision.
Two things are missing from a real game: a cost for getting caught, and a reason to keep going. Under let score:
let lives = 3;
let round = 1;
Getting hurt costs a life and sends you home. Losing the last life starts everything over. Clearing every coin starts the next round with a faster hunter. Three functions, above respawn:
function nextRound() {
round += 1;
hunterSpeed += 0.3;
buildArena();
}
function hurt() {
lives = clamp(lives - 1, 0, 3);
respawn();
if (lives === 0) startOver();
}
function startOver() {
lives = 3;
score = 0;
round = 1;
hunterSpeed = 1.2;
buildArena();
}
clamp again, keeping lives between 0 and 3 no matter what calls this. Now the two places that send the player home should cost a life instead. One word changes in each, in the ouch section:
// ouch
for (const w of walls) {
if (overlaps(player, w.b)) hurt();
}
if (touching(player, hunter)) hurt();
The next round starts the moment the last coin goes. One line, after the filter line in the collect section:
coins = coins.filter(c => !touching(player, c));
if (coins.length === 0) nextRound();
Show both numbers, under the coin count:
text("coins: " + score, 20, 28);
text("lives: " + lives, 20, 48);
text("round " + round, 20, 68);
And R should start over properly now, not just rebuild. In keyPressed:
if (key === 'r' || key === 'R') startOver();
Run it. Get caught: lives 2, everyone home. Collect all six: round 2, the coins are back, and the hunter is noticeably quicker. By round 4 it is as fast as you are and the walls are the only place it cannot follow you. Lose the last life and it is round 1 again.
The platformer's M view took eight weeks to build, one instrument at a time. This game gets all of it in one block, because the instruments live in the engine and only need to be pointed at things. In draw, between the arena drawing and the score:
// what the engine sees
for (const w of walls) { debugBody(w.b); debugRange(w.b, w.from, w.to); }
for (const c of coins) { debugBody(c); debugRadius(c); }
debugBody(hunter);
debugRadius(hunter);
debugHeading(hunter, player);
debugBody(player);
debugRadius(player);
debugTrail(player);
let nearest = null;
for (const c of coins) {
if (nearest === null || distance(player, c) < distance(player, nearest)) nearest = c;
}
if (nearest !== null) debugTriangle(player, nearest, true);
debugNumbers(player);
Press M. Every wall shows its two edges and its signed speed. Every coin has its touching circle. The hunter has its heading, with the two numbers of the direction at the tip, and its own circle. The player leaves a trail, and a triangle runs from it to the nearest coin with the three sides labelled. The panel in the corner holds the six numbers. Not one of these was written for this game, and every one of them is telling the truth about it.
Stand still and let the hunter come. With M on, read the direction at the tip of its arrow every second or so: the two numbers change as it closes in, and squaring and adding them always gives 1. Then press S and watch the frame where its circle crosses yours. That frame is touching saying yes.
Open engine.js and go down it. This game called:
And it never called applyGravity, jump, jumpFor, cutJump, accelerate, applyFriction, landOn, follow, debugFeet or debugCamera. Ten functions on the shelf, and the game does not miss them. That is the difference between an engine and a game. A game is the rules it uses. An engine is the rules that are there whether or not this game uses them, and the next game will pick differently.
patrol only moves things sideways, because that is all the platformer ever asked of it. A wall that slides up and down would need a patrol that works on y. That is the first change this game wants from the engine, and it is a change you now know how to make: same four lines, with y and h where x and w are, and a name like patrolY. When you add it, you are doing what engine programmers do all day: a game asked for something, and the engine grew by one honest function.
Some places to go from here, in rough order of effort. None of them needs anything beyond what you have.
frameCount: survive sixty seconds to win the round, instead of collecting every coin.engine.js in the new project is byte for byte the one from the platformer.