Build a 2D Parkour Game with Phaser 3 and Playroom Kit
Learn how to build a multiplayer 2D parkour game where players navigate through platformer levels. You’ll use Phaser 3 for 2D game rendering and Playroom Kit for real-time multiplayer functionality.
Getting Started
This tutorial shows you how to create a 2D platformer parkour game that works as a multiplayer experience. Players can move, jump, and perform wall jumps to navigate through tilemap-based levels. The game runs directly in the browser and synchronizes player positions across all connected devices using Playroom Kit’s state management via setState and getState.
The game features arcade physics with gravity, wall sliding mechanics, and joystick controls for mobile compatibility. Playroom Kit handles the multiplayer networking, player state synchronization, and provides the host-authoritative model using isHost to prevent cheating.
Vibe Coding System Prompt
Vibe-code friendly
If you are using an AI coding tool, copy this prompt:
You are a senior software engineer at Little Umbrella specializing in building real-time multiplayer applications using Playroom Kit and Phaser 3.Your task is to help me build a "2D Parkour Game".The application should be created using Phaser 3 as the game framework and Playroom Kit as a multiplayer framework.Do not assume the existence of any Playroom Kit APIs or hooks. Only use APIs documented in the official Playroom Kit documentation.I already have a project set up.I will provide step-by-step instructions. After each step, clearly explain what was implemented and ask if I want customizations before proceeding.
You can adjust the prompt according to whether or not you have an existing application.
Guide
If you haven’t cloned the starter repository yet, clone it before moving to the first step. The starter code contains the game assets (hero sprite, tile images) and a pre-built level tilemap in JSON format.
If you’re vibe-coding, start by copying the Vibe Coding System prompt. Then copy each step one by one into your coding assistant by clicking “Copy Prompt”.
Step 1: Set Up the HTML Entry Point
Set up the HTML file that will host the Phaser game canvas. This is the entry point that loads the game JavaScript module.
<!DOCTYPE html>
<html>
<head>
<meta charset="UTF-8" />
<meta name="viewport" content="width=device-width, initial-scale=1.0" />
<style type="text/css">
body {
background: #000000;
padding: 0px;
margin: 0px;
display: flex;
align-items: center;
/* height: 100vh; */
overflow: hidden;
justify-content: center;
}
</style>
<script type="module" src="./src/game.js"></script>
</head>
<body>
<div id="container"></div>
</body>
</html>Step 2: Initialize Playroom Kit
Initialize Playroom Kit to handle multiplayer state and player connections using insertCoin. This enables the game to support multiple players with synchronized state.
import Phaser from "phaser";
import Player from "./player";
import { onPlayerJoin, insertCoin, isHost, myPlayer, Joystick } from "playroomkit";
import ColorReplacePipelinePlugin from 'phaser3-rex-plugins/plugins/colorreplacepipeline-plugin.js';
class PlayGame extends Phaser.Scene {
constructor() {
super("PlayGame");
}
preload() {
this.load.image("tile", "/tile.png");
this.load.image("hero", "/hero.png");
this.load.tilemapTiledJSON("level", "/level.json");
}
create() {
// setting background color
this.cameras.main.setBackgroundColor(gameOptions.bgColor);
// creatin of "level" tilemap
this.map = this.make.tilemap({ key: "level", tileWidth: 64, tileHeight: 64 });
// adding tiles (actually one tile) to tilemap
this.tileset = this.map.addTilesetImage("tileset01", "tile");
// which layer should we render? That's right, "layer01"
this.layer = this.map.createLayer("layer01", this.tileset, 0, 0);
this.layer.setCollisionBetween(0, 1, true);
// loading level tilemap
this.physics.world.setBounds(0, 0, this.map.widthInPixels, this.map.heightInPixels);
// players and their controllers
this.players = [];
onPlayerJoin(async (player) => {
const joystick = new Joystick(player, {
type: "dpad",
buttons: [
{ id: "jump", label: "JUMP" }
]
});
const hero = new Player(
this,
this.layer,
this.cameras.main.width / 2 + (this.players.length * 20),
440,
player.getProfile().color.hex,
joystick);
this.players.push({ player, hero, joystick });
player.onQuit(() => {
this.players = this.players.filter(({ player: _player }) => _player !== player);
hero.destroy();
});
});
}
update() {
this.players.forEach(({ player, hero }) => {
if (isHost()) {
hero.update();
player.setState('pos', hero.pos());
}
else {
const pos = player.getState('pos');
if (pos) {
hero.setPos(pos.x, pos.y);
}
}
});
}
}
var gameOptions = {
// width of the game, in pixels
gameWidth: 14 * 32,
// height of the game, in pixels
gameHeight: 23 * 32,
// background color
bgColor: 0xF7DEB5
}
// Phaser 3 game configuration
const config = {
type: Phaser.AUTO,
width: gameOptions.gameWidth,
height: gameOptions.gameHeight,
parent: "container",
scene: [PlayGame],
physics: {
default: "arcade",
arcade: {
gravity: { y: 900 },
debug: false
}
},
scale: {
mode: Phaser.Scale.FIT,
autoCenter: Phaser.Scale.CENTER_BOTH
},
plugins: {
global: [{
key: 'rexColorReplacePipeline',
plugin: ColorReplacePipelinePlugin,
start: true
},
]
}
};
insertCoin().then(() => {
// creating a new Phaser 3 game instance
const game = new Phaser.Game(config);
});Step 3: Configure Phaser with Arcade Physics
Configure the Phaser 3 game with arcade physics for platformer mechanics, including gravity and collision detection.
// Phaser 3 game configuration
const config = {
type: Phaser.AUTO,
width: gameOptions.gameWidth,
height: gameOptions.gameHeight,
parent: "container",
scene: [PlayGame],
physics: {
default: "arcade",
arcade: {
gravity: { y: 900 },
debug: false
}
},
scale: {
mode: Phaser.Scale.FIT,
autoCenter: Phaser.Scale.CENTER_BOTH
},
plugins: {
global: [{
key: 'rexColorReplacePipeline',
plugin: ColorReplacePipelinePlugin,
start: true
},
]
}
};Step 4: Create the PlayGame Scene
Create the main game scene that handles level loading, physics setup, and the game loop for updating player positions.
class PlayGame extends Phaser.Scene {
constructor() {
super("PlayGame");
}
preload() {
this.load.image("tile", "/tile.png");
this.load.image("hero", "/hero.png");
this.load.tilemapTiledJSON("level", "/level.json");
}
create() {
// setting background color
this.cameras.main.setBackgroundColor(gameOptions.bgColor);
// creatin of "level" tilemap
this.map = this.make.tilemap({ key: "level", tileWidth: 64, tileHeight: 64 });
// adding tiles (actually one tile) to tilemap
this.tileset = this.map.addTilesetImage("tileset01", "tile");
// which layer should we render? That's right, "layer01"
this.layer = this.map.createLayer("layer01", this.tileset, 0, 0);
this.layer.setCollisionBetween(0, 1, true);
// loading level tilemap
this.physics.world.setBounds(0, 0, this.map.widthInPixels, this.map.heightInPixels);
// players and their controllers
this.players = [];
onPlayerJoin(async (player) => {
const joystick = new Joystick(player, {
type: "dpad",
buttons: [
{ id: "jump", label: "JUMP" }
]
});
const hero = new Player(
this,
this.layer,
this.cameras.main.width / 2 + (this.players.length * 20),
440,
player.getProfile().color.hex,
joystick);
this.players.push({ player, hero, joystick });
player.onQuit(() => {
this.players = this.players.filter(({ player: _player }) => _player !== player);
hero.destroy();
});
});
}
update() {
this.players.forEach(({ player, hero }) => {
if (isHost()) {
hero.update();
player.setState('pos', hero.pos());
}
else {
const pos = player.getState('pos');
if (pos) {
hero.setPos(pos.x, pos.y);
}
}
});
}
}
var gameOptions = {
// width of the game, in pixels
gameWidth: 14 * 32,
// height of the game, in pixels
gameHeight: 23 * 32,
// background color
bgColor: 0xF7DEB5
}Step 5: Implement Multiplayer Player Joining
Handle multiplayer player connections using onPlayerJoin by creating Joystick controls and Player instances for each joined player.
onPlayerJoin(async (player) => {
const joystick = new Joystick(player, {
type: "dpad",
buttons: [
{ id: "jump", label: "JUMP" }
]
});
const hero = new Player(
this,
this.layer,
this.cameras.main.width / 2 + (this.players.length * 20),
440,
player.getProfile().color.hex,
joystick);
this.players.push({ player, hero, joystick });
player.onQuit(() => {
this.players = this.players.filter(({ player: _player }) => _player !== player);
hero.destroy();
});
});Step 6: Implement Host-Authoritative Movement
Implement the host-authoritative multiplayer model using isHost where the host calculates physics and synchronizes player positions to all other clients using setState and getState.
update() {
this.players.forEach(({ player, hero }) => {
if (isHost()) {
hero.update();
player.setState('pos', hero.pos());
}
else {
const pos = player.getState('pos');
if (pos) {
hero.setPos(pos.x, pos.y);
}
}
});
}Step 7: Create the Player Class
Create the Player class that handles all player physics, movement, jumping, and wall jump mechanics.
var playerOptions = {
// player horizontal speed
playerSpeed: 300,
// player force
playerJump: 300,
playerWallDragMaxVelocity: 50,
// allow how many jumps (>1 for mid air jumps)
playerMaxJumps: 1,
// should be below acceleration.
// You can disable "slippery floor" setting by giving ridiculously high value
playerDrag: 700,// 9999,
playerAcceleration: 1500
}
export default class Player {
constructor(scene, layerTiles, x, y, playerColor, joystick) {
this.scene = scene;
this.layer = layerTiles;
this.joystick = joystick;
this.jumpKeyIsDown = false;
this.jumpKeyDownAt = 0;
// adding the hero sprite and replace it's color with playerColor
this.hero = scene.physics.add.sprite(x, y, "hero");
scene.plugins.get('rexColorReplacePipeline').add(this.hero, {
originalColor: 0x2B2FDA,
newColor: playerColor,
epsilon: 0.4
});
// this.hero = scene.add.rectangle(x, y, 20, 20, playerColor);
scene.physics.add.existing(this.hero);
// scene.physics.world.addCollider(this.hero, scene.layer);
scene.physics.add.collider(this.hero, scene.layer);
// scene.physics.add.collider(this.hero, scene.layer, null, null, this);
// setting hero anchor point
this.hero.setOrigin(0.5);
this.hero.body.setCollideWorldBounds(true);
// Set player minimum and maximum movement speed
this.hero.body.setMaxVelocity(playerOptions.playerSpeed, playerOptions.playerSpeed * 10);
// Add drag to the player that slows them down when they are not accelerating
this.hero.body.setDrag(playerOptions.playerDrag, 0);
// the hero can jump
this.canJump = true;
// hero is in a jump
this.jumping = false;
// the hero is not on the wall
this.onWall = false;
}
handleJump() {
// the hero can jump when:
// canJump is true AND the hero is on the ground (blocked.down)
// OR
// the hero is on the wall
if (this.canJump || this.onWall) {
// applying jump force
this.hero.body.setVelocityY(-playerOptions.playerJump);
// is the hero on a wall and this isn't the first jump (jump from ground to wall)
// if yes then push to opposite direction
if (this.onWall && !this.isFirstJump) {
// flip horizontally the hero
this.hero.flipX = !this.hero.flipX;
// change the horizontal velocity too. This way the hero will jump off the wall
this.hero.body.setVelocityX(playerOptions.playerSpeed * (this.hero.flipX ? -1 : 1));
}
// hero is not on the wall anymore
this.onWall = false;
}
}
pos(){
return { x: this.hero.body.x, y: this.hero.body.y };
}
setPos(x, y) {
this.hero.body.x = x;
this.hero.body.y = y;
}
body(){
return this.hero.body;
}
destroy(){
this.hero.destroy();
}
update() {
// hero on the ground
if (this.hero.body.blocked.down) {
// hero can jump
this.canJump = true;
// hero not on the wall
this.onWall = false;
}
// hero NOT on the ground and touching a wall on the right
if (this.hero.body.blocked.right && !this.hero.body.blocked.down) {
// hero on a wall
this.onWall = true;
// drag on wall only if key pressed and going downwards.
if (this.rightInputIsActive() && this.hero.body.velocity.y > playerOptions.playerWallDragMaxVelocity) {
this.hero.body.setVelocityY(playerOptions.playerWallDragMaxVelocity);
}
}
if (this.hero.body.blocked.left && !this.hero.body.blocked.down) {
this.onWall = true;
// drag on wall only if key pressed and going downwards.
if (this.leftInputIsActive() && this.hero.body.velocity.y > playerOptions.playerWallDragMaxVelocity) {
this.hero.body.setVelocityY(playerOptions.playerWallDragMaxVelocity);
}
}
if (this.hero.body.blocked.down || this.onWall) {
// set total jumps allowed
this.jumps = playerOptions.playerMaxJumps;
this.jumping = false;
} else if (!this.jumping) {
this.jumps = 0;
}
if (this.leftInputIsActive()) {
// If the LEFT key is down, set the player velocity to move left
this.hero.body.setAccelerationX(-playerOptions.playerAcceleration);
this.hero.flipX = true;
} else if (this.rightInputIsActive()) {
// If the RIGHT key is down, set the player velocity to move right
this.hero.body.setAccelerationX(playerOptions.playerAcceleration);
this.hero.flipX = false;
} else {
this.hero.body.setAccelerationX(0);
}
if ((this.onWall || this.jumps > 0) && this.spaceInputIsActive(150)) {
if (this.hero.body.blocked.down)
this.isFirstJump = true;
this.handleJump();
this.jumping = true;
}
if (this.spaceInputReleased()) {
this.isFirstJump = false;
}
if (this.jumping && this.spaceInputReleased()) {
this.jumps--;
this.jumping = false;
}
}
spaceInputIsActive(duration) {
if (!this.jumpKeyIsDown && this.joystick.isPressed("jump")) {
this.jumpKeyIsDown = true;
this.jumpKeyDownAt = Date.now();
}
return this.jumpKeyIsDown && ((Date.now() - this.jumpKeyDownAt) < duration);
}
spaceInputReleased() {
if (!this.joystick.isPressed("jump")) {
this.jumpKeyIsDown = false;
return true;
}
return false;
}
rightInputIsActive() {
return this.joystick.dpad().x === "right";
}
leftInputIsActive() {
return this.joystick.dpad().x === "left";
}
}Improvements
- Add more levels with increasing difficulty
- Implement collectible coins or power-ups
- Add enemy AI characters that players must avoid
- Create a timer-based scoring system for completing levels
- Add death/respawn mechanics when falling off the map