Skip to content

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/camera
typescript
// 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.

Proprietary software. All rights reserved.