← Explorations
A Second Game
Same engine. Not one new function.

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.

A fresh project, the same engine

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.

An arena, and a square that moves

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.

Two functions in, one already on the shelf

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.

Walls that patrol

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.

A hunter

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.

Coins

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.

Lives and rounds

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.

Press M

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.

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

What you used, and what stayed on the shelf

Open engine.js and go down it. This game called:

  • body for the player, the walls, the hunter and the coins
  • clamp to keep the player in the arena and the lives between 0 and 3
  • patrol for the walls, unchanged from the enemy that paced the ground
  • moveToward for the hunter, and directionTo inside it
  • touching for the coins and the hunter, and distance inside it
  • overlaps for the walls
  • every instrument: debugBody, debugRange, debugRadius, debugHeading, debugTrail, debugTriangle, debugNumbers

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.

One thing your engine cannot do yet

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.

Make it yours

Some places to go from here, in rough order of effort. None of them needs anything beyond what you have.

  • A second hunter with the far and near rule from the week 7 assignment, so one lurks while the other pounces.
  • A timer using frameCount: survive sixty seconds to win the round, instead of collecting every coin.
  • Walls that speed up each round along with the hunter.
  • The vertical patrol above, and a wall that uses it.
  • A goal in one corner, the way the flag worked, that only opens once every coin is gone.
  • Your engine.js in the new project is byte for byte the one from the platformer.
  • The player moves in four directions, stops at all four edges, and never falls.
  • Three walls patrol at three speeds and a hunter comes straight at you at a fair speed.
  • Coins collect by their round edge, and clearing them starts a faster round.
  • Getting caught costs a life, and losing the last one starts over.
  • M shows every instrument working on a game none of them was written for.
  • You can name the ten engine functions this game never called.
move collect the coins, dodge the walls, outrun the hunter S slow motion R start over M see what the engine sees