Leave without saving?

Your editor changes will be lost.

Mario-2 puts a hero in the level. Click the preview, walk with A and D, and jump with Space. The player is animated, falls under gravity, collides with the tiles, and the camera now follows them instead of the keyboard.

Game Objects

GameObject is the base for anything that lives in the level. It holds three Vectors:

  • position: the top-left corner, in world pixels.
  • dimensions: width and height.
  • velocity: pixels per second on each axis.

Player extends it with the hero’s own data. It tracks which way they face, whether they’re on the ground, the tile map they move through, and their animations.

Sprites and Animation

The character sheet is one big image holding every pose. src/config/SpriteConfig.js records where each pose sits:

export const smallSpriteConfig = {
idle: [{ x: 24, y: 48, width: 16, height: 24 }],
walk: [
{ x: 180, y: 48, width: 16, height: 24 },
{ x: 232, y: 48, width: 16, height: 24 },
{ x: 284, y: 48, width: 16, height: 24 },
],
// …
};

The poses aren’t laid out on an even grid, so Sprite.loadByConfig() cuts out exactly the rectangles listed, rather than slicing the sheet into equal squares the way the tile sheet is. Each list of frames becomes an Animation, which cycles through its frames at a fixed interval:

this.animations = {
idle: new Animation(this.sprites.idle, 0.2),
walk: new Animation(this.sprites.walk, 0.1),
jump: new Animation(this.sprites.jump, 0.2),
fall: new Animation(this.sprites.fall, 0.2),
};

Facing Left and Right

The sheet only has the character facing left. To face right, PlayerState.render() mirrors the canvas with context.scale(-1, 1). Mirroring also flips the x axis, so the translation has to be negated and shifted by the sprite’s width to land in the same place:

const x = Math.round(this.player.position.x);
const y = Math.round(this.player.position.y);
if (this.player.facingRight) {
context.scale(-1, 1);
context.translate(-x - this.player.dimensions.x, y);
} else {
context.translate(x, y);
}
frame.render(context, 0, 0);

The position is rounded only here, when drawing. updatePosition() keeps the exact, fractional position, because slow movement can be less than a pixel per frame, and rounding the stored value every frame would erase it.

Player States

The player behaves differently depending on what they’re doing. You can jump while standing or walking, but not while already in the air. Like the game states you’ve used since Pong, each behaviour is a State, and the player owns a StateMachine to switch between them:

stateDiagram-v2
	[*] --> Idling
	Idling --> Walking: A or D held
	Idling --> Jumping: Space
	Walking --> Idling: stopped
	Walking --> Jumping: Space
	Walking --> Falling: walked off a ledge
	Jumping --> Falling: started moving down
	Falling --> Idling: landed, stopped
	Falling --> Walking: landed, moving

Each state sets its animation in enter(), and decides when to hand over to another state:

checkTransitions() {
if (this.player.isOnGround) {
this.changeToGroundState();
} else if (this.player.velocity.y >= 0) {
this.player.stateMachine.change(PlayerStateName.Falling);
}
}

Player.update() and Player.render() simply forward to the state machine, so the current state does all the work.

Shared Behaviour in PlayerState

All the player states extend PlayerState, which holds the physics, tile collision, and drawing they share. Its update() runs the physics, then advances the animation:

update(dt) {
this.applyGravity(dt);
this.updatePosition(dt);
this.animation?.update(dt);
}

The states that read input do it before calling super.update(dt), so a key press affects the same frame it’s read in. If the input changed state, isActive is now false and the old state stops updating:

update(dt) {
this.handleInput();
if (!this.isActive) {
return;
}
this.handleHorizontalMovement(dt);
super.update(dt);
this.checkTransitions();
}

Movement

Acceleration

Snapping straight to full speed feels robotic. handleHorizontalMovement() instead moves the velocity toward a target by a limited amount each frame, using a small helper:

function approach(value, target, maxDelta) {
if (value < target) {
return Math.min(value + maxDelta, target);
}
return Math.max(value - maxDelta, target);
}

Holding a direction approaches walk speed at the acceleration rate. Letting go approaches 0 at the deceleration rate.

Settings in Tiles

src/config/PlayerSettings.js describes movement in tiles and seconds rather than pixels:

export const PlayerSettings = {
walkSpeed: 4.7,
acceleration: 21,
deceleration: 14,
jumpHeight: 4,
timeToApex: 0.44,
maxFallSpeed: 15,
};

“Walk 4.7 tiles per second” and “jump 4 tiles high” are easier to reason about than raw pixel numbers, and they stay correct if the tile size changes. toPixels() converts them when the physics needs pixels.

Gravity from Jump Height

Picking a gravity number by trial and error is fiddly. Change it and the jump height changes too. Instead, getGravity() works it out from two values a designer actually cares about, how high the jump goes and how long it takes to reach the top:

export function getGravity() {
const height = PlayerSettings.jumpHeight * TILE_SIZE;
return (2 * height) / PlayerSettings.timeToApex ** 2;
}
export function getLaunchVelocity(heightInTiles) {
return -Math.sqrt(2 * getGravity() * heightInTiles * TILE_SIZE);
}

Both come from the equations of motion under constant acceleration. PlayerJumpingState.enter() sets the vertical velocity to getLaunchVelocity(PlayerSettings.jumpHeight), and gravity slows it to exactly 0 at the requested height. The velocity is negative because the y axis points down on a canvas.

Try it below. Change jumpHeight and the peak moves, but the jump still takes timeToApex to get there. Change timeToApex and the jump stays the same height, only faster or floatier. The dots are spaced at equal steps of time, so they bunch up near the top where the player is slowest. The dashed arc is the default jump, for comparison. A red circle marks where the fall reaches maxFallSpeed and stops speeding up.

 

Tile Collision

In Pong and Breakout, we checked a few objects against each other with AABB collision. Checking the player against every tile in the map would mean 800 checks each frame for a tiny level. A tile map makes it much cheaper. From the player’s position, we can work out exactly which few tiles they overlap and check only those.

Read the tile collision notes for the full walkthrough. Here is how this game applies it.

One Axis at a Time

updatePosition() moves the player horizontally, fixes any horizontal collision, then moves vertically and fixes any vertical collision:

this.player.position.x += dx;
this.checkHorizontalCollisions();
this.player.position.y += dy;
this.checkVerticalCollisions();

Handling the axes separately means every collision has an obvious fix. A horizontal overlap must be a wall, so push the player out sideways. A vertical overlap must be a floor or ceiling, so push them out vertically.

Compare the two approaches below. The box walks right with gravity pulling it down each frame. One axis at a time walks smoothly until the wall stops it. Both at once pushes out of each solid tile along whichever axis overlaps least, and snags on the first seam between two floor tiles. Use Step to see each phase. The dashed outline is where the box was before the phase, yellow tiles are the ones checked, and red tiles are solid tiles it overlapped.

Which Tiles to Check

Dividing a pixel coordinate by the tile size (and rounding down) gives the tile it falls in. For a player 16 × 24 pixels at (48, 32) on 16-pixel tiles:

tileLeft = floor(48 / 16) = 3
tileRight = floor((48 + 16 - 1) / 16) = 3
tileTop = floor(32 / 16) = 2
tileBottom = floor((32 + 24 - 1) / 16) = 3

The - 1 matters. The player’s last pixel is at 48 + 16 - 1 = 63, still in column 3. Without it, a player standing perfectly inside one column would also “touch” the next one.

Walls

When moving right, checkHorizontalCollisions() checks every row the player covers in the column just past their right edge. If one of those tiles is solid, the player snaps back so their right edge lines up with that tile’s left edge:

if (this.player.map.isSolidTileAt(tileRight, y)) {
this.player.position.x = tileRight * tileSize - this.player.dimensions.x;
this.player.velocity.x = 0;
break;
}

Collision on the side

Floors and Ceilings

checkVerticalCollisions() looks at the row below the player’s feet when falling, and the row above their head when rising:

Collision below

if (this.checkSolidCollision(tileLeft, tileRight, tileBottom)) {
this.player.position.y = tileBottom * tileSize - this.player.dimensions.y;
this.player.velocity.y = 0;
this.player.isOnGround = true;
}

Landing sets isOnGround, which gravity and the states read. Hitting a ceiling snaps the player below the tile and stops the upward velocity, so they start falling straight away.

Collision above

Turn on Player Collision in the ⚙️ Tuning panel to watch this happen. Yellow tiles are the ones near the player, red ones are solid, and the blue box is the player’s rectangle.

A Camera That Follows

The camera no longer reads the keyboard. Each frame it centres itself on the player, then clamps to the world as before:

const centerX = this.player.position.x + this.player.dimensions.x / 2;
const centerY = this.player.position.y + this.player.dimensions.y / 2;
this.x = this.clamp(
centerX - this.viewportWidth / 2,
this.worldWidth - this.viewportWidth
);

It works, but it feels stiff. Every tiny step drags the whole screen, and each jump bounces it up and down. The next lesson fixes that, along with the rest of the game feel.

📚 References

Powered by WebContainers
Files
Preparing Environment