Tiny Platformer
Mon, May 27, 2013I’ve been thinking about making a platform game recently, but I’ve never done one before, so I wanted to start small, very, very small!
How about starting with just a tiny rectangle player character jumping around on some larger rectangle platforms.
No, I’m not going to remake Thomas was Alone (a great game by the way).
Instead I just want to make a quick-and-dirty simple platform mechanic just to get started. No complexity, no baddies, no treasure to collect, just a single level.
You can view the source code or “play” the game below, just use the LEFT and RIGHT arrow keys to move the little yellow fellow around and SPACE to jump.
I told you it would be small
Finally, a weekend project that actually took less than one weekend!
I hope to build on top of this with a more robust platform game over the summer, but for now, let’s look at what it takes to get this minimal mechanic implemented:
- level data - provided by the open source Tiled map editor
- variables - that define our game
- game loop - a minimal fixed timestep game loop
- updating - the game world
- rendering - onto a HTML5
<canvas>
element
A note on code structure: Remembering that this is just an example of a simple mechanic, and not production ready code, we don’t need to over-engineer any kind of complicated OO class design. In fact, to keep this simple I use global variables and methods (oh the horror!)
Constants and Variables
Start with a number of (tunable) constants to define our world:
var MAP = { tw: 64, th: 48 }, // the size of the map (in tiles)
TILE = 32, // the size of each tile (in game pixels)
METER = TILE, // abitrary choice for 1m
GRAVITY = METER * 9.8 * 6, // very exagerated gravity (6x)
MAXDX = METER * 20, // max horizontal speed (20 tiles per second)
MAXDY = METER * 60, // max vertical speed (60 tiles per second)
ACCEL = MAXDX * 2, // horizontal acceleration - take 1/2 second to reach maxdx
FRICTION = MAXDX * 6, // horizontal friction - take 1/6 second to stop from maxdx
JUMP = METER * 1500; // (a large) instantaneous jump impulse
In addition, we have our game canvas, its context, and our player object:
var canvas = document.getElementById('canvas'),
ctx = canvas.getContext('2d'),
width = canvas.width = MAP.tw * TILE,
height = canvas.height = MAP.th * TILE,
player = { x: 320, y: 320, dx: 0, dy: 0 };
And a couple of utility methods for converting between tile and pixel coordinates:
var t2p = function(t) { return t*TILE; },
p2t = function(p) { return Math.floor(p/TILE); },
Level Data
We start with a simple COLOR
palette, and an array of COLORS
to
represent each tile type:
var COLOR = { BLACK: '#000000', YELLOW: '#ECD078', BRICK: '#D95B43', PINK: '#C02942', PURPLE: '#542437', GREY: '#333', SLATE: '#53777A' },
COLORS = [ COLOR.BLACK, COLOR.YELLOW, COLOR.BRICK, COLOR.PINK, COLOR.PURPLE, COLOR.GREY ];
We can then use the open source Tiled map editor to create the level layout:
The editor creates an xml .TMX
output file that defines the level, but I’m not going to be taking advantage of any advanced features, so instead I can simply
export it as JSON, manually pull out the data
for the layer, and paste it directly into my source code as a simple array of
cells
, along with a couple of simple accessor methods:
var cells = [5, 5, 5, 5, 5, 5, ... ],
cell = function(x,y) { return tcell(p2t(x),p2t(y)); },
tcell = function(tx,ty) { return cells[tx + (ty*MAP.tw)]; };
If I add multiple levels at a later date then I could easily load the exported .json files
using an ajax call at the start of each level
and a quick call to JSON.parse()
in order to extract the cells
, but I don’t need that
for this demo.
Game Loop
As usual, we will be using requestAnimationFrame to ensure our rendering loop is as smooth as possible with a controlled, fixed timestep update loop that is independent of our rendering loop. I explored this previously when implementing BoulderDash.
We will need a way to measure the current time. In modern browsers we can use the high resolution javascript timer, with a fall-back for older browsers:
function timestamp() {
if (window.performance && window.performance.now)
return window.performance.now();
else
return new Date().getTime();
}
The render loop runs as often as the browser allows, while the update loop is fixed
by accumulating time until we reach the step
required to trigger an update()
:
var fps = 60,
step = 1/fps,
dt = 0,
now, last = timestamp();
function frame() {
now = timestamp();
dt = dt + Math.min(1, (now - last) / 1000);
while(dt > step) {
dt = dt - step;
update(step);
}
render(ctx, dt);
last = now;
requestAnimationFrame(frame, canvas);
}
frame(); // start the first frame
NOTE: Since requestAnimationFrame will go idle when the browser is not visible, it is possible for dt to be very large, therefore we limit the actual dt to automatically ‘pause’ the loop when this happens
Interesting to note is that when we do have a large dt
we make sure to run update()
in a while loop to
ensure the game ‘catches up’ without missing any updates, but we don’t bother doing this for render()
where simply rendering the most recent state once is enough to catch up.
Keyboard Input
In addition to the running game loop, we need to gather input from the user.
We simply record if the player is trying to move left, right, or jump. The
update()
loop takes care of actually moving the player.
document.addEventListener('keydown', function(ev) { return onkey(ev, ev.keyCode, true); }, false);
document.addEventListener('keyup', function(ev) { return onkey(ev, ev.keyCode, false); }, false);
function onkey(ev, key, down) {
switch(key) {
case KEY.LEFT: player.left = down; return false;
case KEY.RIGHT: player.right = down; return false;
case KEY.SPACE: player.jump = down; return false;
}
}
Updating
The update()
loop is where the magic happens!
function update(dt) {
// magic happens
}
Told you!
The only entity that needs updating is the player:
- calculate the forces that apply to the player
- apply the forces to move and accelerate the player
- collision detection (and resolution)
Starting with some local variables:
var wasleft = player.dx < 0,
wasright = player.dx > 0,
falling = player.falling;
We can accumulate the horizontal and vertical forces that currently apply:
player.ddx = 0;
player.ddy = GRAVITY;
if (player.left)
player.ddx = player.ddx - ACCEL; // player wants to go left
else if (wasleft)
player.ddx = player.ddx + FRICTION; // player was going left, but not any more
if (player.right)
player.ddx = player.ddx + ACCEL; // player wants to go right
else if (wasright)
player.ddx = player.ddx - FRICTION; // player was going right, but not any more
if (player.jump && !player.jumping && !falling) {
player.ddy = player.ddy - JUMP; // apply an instantaneous (large) vertical impulse
player.jumping = true;
}
And integrate the forces to calculate the new position (x,y)
and velocity (dx,dy)
:
player.y = Math.floor(player.y + (dt * player.dy));
player.x = Math.floor(player.x + (dt * player.dx));
player.dx = bound(player.dx + (dt * player.ddx), -MAXDX, MAXDX);
player.dy = bound(player.dy + (dt * player.ddy), -MAXDY, MAXDY);
One tricky aspect of using a frictional force to slow the player down (as opposed to just allowing a dead-stop) is that the force is highly unlikely to be exactly the force needed to come to a halt. In fact, its likely to overshoot in the opposite direction and lead to a tiny jiggling effect instead of actually stopping the player.
In order to avoid this, we must clamp the horizontal velocity to zero if we detect that the players direction has just changed:
if ((wasleft && (player.dx > 0)) ||
(wasright && (player.dx < 0))) {
player.dx = 0; // clamp at zero to prevent friction from making us jiggle side to side
}
Collision Detection
Our collision detection logic is greatly simplified by the fact that the player is a rectangle and is exactly the same size as a single tile. So we know that the player can only ever occupy 1, 2 or 4 cells:
This means we can short-circuit and avoid building a general purpose collision detection engine (e.g. a quad tree) by simply looking at the 1 to 4 cells that the player occupies:
var tx = p2t(player.x),
ty = p2t(player.y),
nx = player.x%TILE, // true if player overlaps right
ny = player.y%TILE, // true if player overlaps below
cell = tcell(tx, ty),
cellright = tcell(tx + 1, ty),
celldown = tcell(tx, ty + 1),
celldiag = tcell(tx + 1, ty + 1);
If the player has vertical velocity, then check to see if they have hit a
platform below or above, in which case, stop their vertical velocity, and
clamp their y
position:
if (player.dy > 0) {
if ((celldown && !cell) ||
(celldiag && !cellright && nx)) {
player.y = t2p(ty); // clamp the y position to avoid falling into platform below
player.dy = 0; // stop downward velocity
player.falling = false; // no longer falling
player.jumping = false; // (or jumping)
ny = 0; // - no longer overlaps the cells below
}
}
else if (player.dy < 0) {
if ((cell && !celldown) ||
(cellright && !celldiag && nx)) {
player.y = t2p(ty + 1); // clamp the y position to avoid jumping into platform above
player.dy = 0; // stop upward velocity
cell = celldown; // player is no longer really in that cell, we clamped them to the cell below
cellright = celldiag; // (ditto)
ny = 0; // player no longer overlaps the cells below
}
}
Once the vertical velocity is taken care of, we can apply similar logic to the horizontal velocity:
if (player.dx > 0) {
if ((cellright && !cell) ||
(celldiag && !celldown && ny)) {
player.x = t2p(tx); // clamp the x position to avoid moving into the platform we just hit
player.dx = 0; // stop horizontal velocity
}
}
else if (player.dx < 0) {
if ((cell && !cellright) ||
(celldown && !celldiag && ny)) {
player.x = t2p(tx + 1); // clamp the x position to avoid moving into the platform we just hit
player.dx = 0; // stop horizontal velocity
}
}
The last calculation for our update()
method is to detect if the player is now
falling
or not. We can do that by looking to see if there is a platform
below them:
player.falling = ! (celldown || (nx && celldiag));
And thats the update()
loop complete, running this at 60fps gives us our
simple platform game mechanic.
Rendering
Finally, rendering our map is trivial. We use the standard <canvas> fillRect()
method to draw
rectangles for each tile and one more for the player:
function render(ctx) {
// render map
var x, y;
for(y = 0 ; y < MAP.th ; y++) {
for(x = 0 ; x < MAP.tw ; x++) {
ctx.fillStyle = COLORS[tcell(x,y)];
ctx.fillRect(x * TILE, y * TILE, TILE, TILE);
}
}
// render player
ctx.fillStyle = COLOR.YELLOW;
ctx.fillRect(player.x, player.y, TILE, TILE);
}
Since the map is static, we could have easily cached a rendered version in an off-screen canvas, but for something this simple performance is not an issue, so we can just re-render it every frame and keep the code clear and simple.
Conclusion
That is a very, very, minimal platform game, but it gets me started! and leaves so much more to add later:
- exits - to proceed to further levels
- treasure - collect stars or coins for points
- monsters - kill the player (or lose a life) - maybe squashable, mario-style ?
- ladders - to climb
- stairs & slopes - to walk up
- moving platforms - for carefully timed jumps
- higher jumps - the longer you hold down the SPACE key
- double jump - for extra height
- wall jump - meatboy style
- swinging ropes - pitfall-tastic
- tile sprites - more interesting platform tile graphics
- partial tile collision - more flexible collision detection
- character sprites - play as more than just a rectangle
- monster sprites - jump on interesting creatures
- traps - disintegrating platforms, collapsing columns, etc
- switches - and secret doors
- etc
- etc
- etc
Maybe, one day I’ll add some of those things, but until then…
Enjoy!