Tutorial: Platformer
In this tutorial you'll build a 2D side-scrolling platformer with:
- A character that runs and jumps with real physics
- A tilemap-based level loaded from a Tiled JSON file
- Collectible coins that increment a counter
- Checkpoint flags and a goal zone
- A camera that follows the player
Estimated time: 60 minutes
Difficulty: Beginner–Intermediate
1. Set Up Physics
bash
pnpm add @web-engine-dev/physics2d @web-engine-dev/physics2d-rapier
pnpm add @web-engine-dev/tilemap @web-engine-dev/sprites @web-engine-dev/cameratypescript
// src/main.ts
import {
createEngine,
CoreSchedule,
createInputPlugin,
createPhysics2DPlugin,
createSpritePlugin,
} from '@web-engine-dev/engine';
import { RapierPhysicsBackend } from '@web-engine-dev/physics2d-rapier';
import { TilemapPlugin } from '@web-engine-dev/tilemap';
import { InputManagerResource } from '@web-engine-dev/input';
import { RendererPlugin } from '@web-engine-dev/renderer';
const engine = createEngine({ autoStart: false });
engine
.addPlugin(createInputPlugin())
.addPlugin(
createPhysics2DPlugin({ backend: new RapierPhysicsBackend(), gravity: { x: 0, y: 800 } })
)
.addPlugin(createSpritePlugin())
.addPlugin(TilemapPlugin)
.addPlugin(RendererPlugin);
const world = engine.world;
// Register input action map on startup
world.addSystemToSchedule(CoreSchedule.Startup, (w) => {
w.getResource(InputManagerResource).registerActionMap({
name: 'platformer',
actions: [
{
name: 'left',
valueType: 'button',
bindings: [
{ type: 'keyboard', key: 'ArrowLeft' },
{ type: 'keyboard', key: 'KeyA' },
],
},
{
name: 'right',
valueType: 'button',
bindings: [
{ type: 'keyboard', key: 'ArrowRight' },
{ type: 'keyboard', key: 'KeyD' },
],
},
{
name: 'jump',
valueType: 'button',
bindings: [
{ type: 'keyboard', key: 'Space' },
{ type: 'keyboard', key: 'ArrowUp' },
],
},
],
});
});2. Load the Tilemap
Create public/levels/level-01.json using Tiled (https://www.mapeditor.org/). The tilemap should have:
- A Tiles layer (solid ground platforms)
- An Objects layer with object types:
spawn-point,coin,checkpoint,goal
typescript
// src/systems/load-level.ts
import { type World, type Commands } from '@web-engine-dev/ecs';
import { TilemapLoader } from '@web-engine-dev/tilemap';
export async function loadLevel(world: World, commands: Commands) {
const loader = world.getResource(TilemapLoader);
await loader.load('/levels/level-01.json', {
// Map Tiled layer names to physics collision shapes
collisionLayers: ['Tiles'],
// Map Tiled object types to spawn handlers
objectHandlers: {
'spawn-point': (obj, cmds) => spawnPlayer(obj.x, obj.y, cmds),
coin: (obj, cmds) => spawnCoin(obj.x, obj.y, cmds),
checkpoint: (obj, cmds) => spawnCheckpoint(obj.x, obj.y, cmds),
goal: (obj, cmds) => spawnGoal(obj.x, obj.y, obj.width, obj.height, cmds),
},
});
}3. Define the Player Entity
typescript
// src/entities/player.ts
import { type Commands } from '@web-engine-dev/ecs';
import {
RigidBody2D,
Collider2D,
ColliderShape2D,
ContactFilter2D,
GroundSensor,
} from '@web-engine-dev/physics2d';
import { Sprite, SpriteAnimator } from '@web-engine-dev/sprites';
import { Player, PlayerState } from '../components.js';
export function spawnPlayer(x: number, y: number, commands: Commands) {
commands.spawnBundle([
[Player, {}],
[PlayerState, { grounded: false, facing: 1, coyoteTime: 0 }],
[
Collider2D,
{
// Capsule collider for smooth edge traversal
shape: ColliderShape2D.capsule(12, 24),
friction: 0.0, // no wall sticking
restitution: 0.0,
},
],
[RigidBody2D, { x, y, fixedRotation: true, linearDamping: 0 }],
[GroundSensor, { offsetY: 25, width: 18, height: 6 }], // sensor below feet
[Sprite, { src: 'player.png', width: 32, height: 48 }],
[
SpriteAnimator,
{
animations: {
idle: { frames: [0], fps: 1 },
run: { frames: [1, 2, 3, 4], fps: 10 },
jump: { frames: [5], fps: 1 },
fall: { frames: [6], fps: 1 },
},
},
],
]);
}4. Player Controller System
typescript
// src/systems/player-controller.ts
import { type World, CurrentTime } from '@web-engine-dev/ecs';
import { RigidBody2D, GroundSensor } from '@web-engine-dev/physics2d';
import { SpriteAnimator } from '@web-engine-dev/sprites';
import { InputManagerResource, InputStateResource } from '@web-engine-dev/input';
import { Player, PlayerState } from '../components.js';
const RUN_SPEED = 220;
const JUMP_FORCE = -480;
const COYOTE_TIME = 0.1; // seconds of "grace" after walking off ledge
export function playerControllerSystem(world: World) {
const { delta: deltaTime } = world.getResource(CurrentTime);
const inputMgr = world.getResource(InputManagerResource);
const rawInput = world.getResource(InputStateResource);
const playerQ = world
.query()
.with(Player, PlayerState, RigidBody2D, GroundSensor, SpriteAnimator)
.build();
for (const result of world.run(playerQ)) {
const [, state, body, sensor, anim] = result.components;
const entity = result.entity;
const grounded = sensor.touching;
// Coyote time
let coyote = state.coyoteTime;
if (grounded) coyote = COYOTE_TIME;
else coyote = Math.max(0, coyote - deltaTime);
// Horizontal movement from raw keys
const dx =
(rawInput.pressedKeys.has('ArrowRight') || rawInput.pressedKeys.has('KeyD') ? 1 : 0) -
(rawInput.pressedKeys.has('ArrowLeft') || rawInput.pressedKeys.has('KeyA') ? 1 : 0);
const vx = dx * RUN_SPEED;
const isJumping = inputMgr.getActionDigital('jump');
const vy = isJumping && coyote > 0 ? JUMP_FORCE : body.velocityY;
world.insert(entity, RigidBody2D, { ...body, velocityX: vx, velocityY: vy });
world.insert(entity, PlayerState, {
grounded,
coyoteTime: coyote,
facing: dx !== 0 ? dx : state.facing,
});
// Animation
let clip = 'idle';
if (!grounded) clip = body.velocityY < 0 ? 'jump' : 'fall';
else if (dx !== 0) clip = 'run';
world.insert(entity, SpriteAnimator, { ...anim, current: clip, flipX: state.facing < 0 });
}
}5. Coins
typescript
// src/entities/coin.ts
import { type Commands } from '@web-engine-dev/ecs';
import { Collider2D, ColliderShape2D, Sensor2D } from '@web-engine-dev/physics2d';
import { Sprite } from '@web-engine-dev/sprites';
import { Coin } from '../components.js';
export function spawnCoin(x: number, y: number, commands: Commands) {
commands.spawnBundle([
[Coin, {}],
[Collider2D, { shape: ColliderShape2D.circle(10), isSensor: true }],
[Sensor2D, { x, y }],
[Sprite, { src: 'coin.png', width: 20, height: 20 }],
]);
}typescript
// src/systems/collect-coins.ts
import { type World, type Commands } from '@web-engine-dev/ecs';
import { Sensor2D } from '@web-engine-dev/physics2d';
import { Player, Coin } from '../components.js';
import { PlayerScore } from '../resources.js';
export function collectCoinsSystem(world: World) {
const score = world.getResource(PlayerScore);
const cmd = world.commands();
const query = world.query().with(Coin, Sensor2D).build();
for (const result of world.run(query)) {
const [, contacts] = result.components;
const coinEntity = result.entity;
for (const contactEntity of contacts.touching) {
if (world.has(contactEntity, Player)) {
score.coins += 1;
cmd.despawn(coinEntity);
break;
}
}
}
}6. Camera Follow
typescript
// src/systems/camera-follow.ts
import { type World } from '@web-engine-dev/ecs';
import { RigidBody2D } from '@web-engine-dev/physics2d';
import { ActiveCameraResource, type FollowCamera } from '@web-engine-dev/camera';
import { Player } from '../components.js';
export function cameraFollowSystem(world: World) {
const camera = world.getResource(ActiveCameraResource) as FollowCamera;
// Find the player's physics body position
const query = world.query().with(Player, RigidBody2D).build();
for (const result of world.run(query)) {
const [, body] = result.components;
// Point the FollowCamera at the player; it will smooth-interpolate each frame
camera.setTarget({ position: { x: body.x, y: body.y - 60 } });
break;
}
}7. Goal Zone & Win Condition
typescript
// src/systems/goal.ts
import { type World } from '@web-engine-dev/ecs';
import { Sensor2D } from '@web-engine-dev/physics2d';
import { Player, Goal } from '../components.js';
import { GameState } from '../resources.js';
export function goalSystem(world: World) {
const gs = world.getResource(GameState);
if (gs.phase !== 'playing') return;
const query = world.query().with(Goal, Sensor2D).build();
for (const result of world.run(query)) {
const [, contacts] = result.components;
for (const e of contacts.touching) {
if (world.has(e, Player)) {
gs.phase = 'win';
}
}
}
}8. What to Try Next
- Add enemy patrol AI, an enemy that walks back and forth, reversing direction at ledge edges
- Add moving platforms, use
RigidBody2D.setKinematicTarget()to animate them - Add a parallax background, see the 2D Development guide
- Add screen shake on landing or taking damage, see the Camera guide
- Save checkpoints using
@web-engine-dev/save, see the Save & Load guide
Continue with the RPG Tutorial to learn inventory, quests, and dialogue.