Step 2: Create the Phaser Scene
This is where the magic happens! The implementation differs significantly between Phaser Helpers and Core Primitives.
Create src/scene.ts:
Using Phaser Helpers
import Phaser from 'phaser';
import type { GameRuntime } from '@martini-kit/core';
import { PhaserAdapter, createPlayerHUD } from '@martini-kit/phaser';
export function createPaddleBattleScene(runtime: GameRuntime, isHost: boolean) {
return class PaddleBattleScene extends Phaser.Scene {
private adapter!: PhaserAdapter;
private spriteManager: any;
private inputManager: any;
private ball!: Phaser.GameObjects.Arc;
private hud: any;
constructor() {
super({ key: 'PaddleBattle' });
}
create() {
// Initialize adapter
this.adapter = new PhaserAdapter(runtime, this);
// Background
this.add.rectangle(400, 300, 800, 600, 0x1a1a2e);
// Center line (dashed)
for (let i = 0; i < 600; i += 20) {
this.add.rectangle(400, i + 10, 4, 10, 0x444444);
}
// ====== HELPER 1: SpriteManager ======
// Automatically syncs all paddles (players) between host and clients
this.spriteManager = this.adapter.createSpriteManager({
// staticProperties = metadata synced once, not every frame
staticProperties: ['side'],
// Built-in labels (no manual positioning needed!)
label: {
getText: (data: any) => data.side === 'left' ? 'P1' : 'P2',
offset: { y: -35 },
style: {
fontSize: '14px',
color: '#fff',
backgroundColor: '#000',
padding: { x: 4, y: 2 },
},
},
// Create the visual sprite
onCreate: (key: string, data: any) => {
const x = data.side === 'left' ? 50 : 750;
return this.add.rectangle(x, data.y, 15, 100, 0xffffff);
},
// Set up physics for each sprite
onCreatePhysics: (sprite: any) => {
this.physics.add.existing(sprite);
const body = sprite.body as Phaser.Physics.Arcade.Body;
body.setCollideWorldBounds(true);
body.setImmovable(true);
},
});
// ====== HELPER 2: HUD ======
this.hud = createPlayerHUD(this.adapter, this, {
title: 'Paddle Battle - Multiplayer Pong',
roleText: (myPlayer: any) => {
if (!myPlayer) return 'Spectator';
return myPlayer.side === 'left' ? 'Left Player' : 'Right Player';
},
controlHints: () => 'W/S or ↑/↓ to Move',
stats: (state: any) => {
const scores = Object.entries(state.players)
.map(([_, player]: any) => `${player.side}: ${player.score}`)
.join(' | ');
return scores;
},
});
// ====== HELPER 3: InputManager ======
this.inputManager = this.adapter.createInputManager();
this.inputManager.useProfile('platformer');
// Create ball manually (special object, not managed by sprite manager)
if (isHost) {
const state = runtime.getState();
this.ball = this.add.circle(state.ball.x, state.ball.y, 10, 0xff6b6b);
this.physics.add.existing(this.ball);
const ballBody = this.ball.body as Phaser.Physics.Arcade.Body;
ballBody.setBounce(1, 1);
ballBody.setCollideWorldBounds(false);
ballBody.setVelocity(state.ball.velocityX, state.ball.velocityY);
this.adapter.trackSprite(this.ball, 'ball');
// Add collisions between ball and all paddles
for (const paddle of Object.values(this.spriteManager.group.getChildren())) {
this.physics.add.collider(this.ball, paddle as any);
}
} else {
// Clients receive ball updates via sprite tracking
this.adapter.onChange((state: any) => {
if (!state._sprites?.ball) return;
if (this.ball) return;
const data = state._sprites.ball;
this.ball = this.add.circle(data.x || 400, data.y || 300, 10, 0xff6b6b);
this.adapter.registerRemoteSprite('ball', this.ball);
});
}
// HOST SETUP
if (isHost) {
const state = runtime.getState();
for (const [playerId, playerData] of Object.entries(state.players)) {
this.spriteManager.add(`paddle-${playerId}`, playerData);
}
}
}
update() {
// HOST: Ensure new players get sprites
if (this.adapter.isHost()) {
const state = runtime.getState();
for (const [playerId, playerData] of Object.entries(state.players)) {
const key = `paddle-${playerId}`;
if (!this.spriteManager.get(key)) {
this.spriteManager.add(key, playerData);
}
}
}
// CLIENT & HOST: Smooth interpolation
this.spriteManager.update();
if (!isHost) return; // Clients don't run logic
// ====== HOST ONLY ======
// Capture input and submit actions
this.inputManager.update();
const myInput = this.inputManager.getState();
runtime.submitAction('move', {
up: myInput.up || false,
down: myInput.down || false,
});
// Update physics
this.updatePhysics();
// Check scoring
this.checkScoring();
}
private updatePhysics() {
const state = runtime.getState();
if (!this.ball?.body) return;
const speed = 300;
const ballBody = this.ball.body as Phaser.Physics.Arcade.Body;
// Apply input to paddles
const inputs = state.inputs || {};
for (const [playerId, input] of Object.entries(inputs)) {
const paddle = this.spriteManager.get(`paddle-${playerId}`);
if (!paddle?.body) continue;
const paddleBody = paddle.body as Phaser.Physics.Arcade.Body;
if (input.up) {
paddleBody.setVelocityY(-speed);
} else if (input.down) {
paddleBody.setVelocityY(speed);
} else {
paddleBody.setVelocityY(0);
}
// Update state with new position
state.players[playerId].y = paddle.y;
}
// Update ball state
state.ball.x = this.ball.x;
state.ball.y = this.ball.y;
state.ball.velocityX = ballBody.velocity.x;
state.ball.velocityY = ballBody.velocity.y;
// Manual top/bottom bounce
if (this.ball.y <= 10) {
this.ball.y = 10;
ballBody.setVelocityY(Math.abs(ballBody.velocity.y));
} else if (this.ball.y >= 590) {
this.ball.y = 590;
ballBody.setVelocityY(-Math.abs(ballBody.velocity.y));
}
}
private checkScoring() {
const state = runtime.getState();
if (this.ball.x < -10) {
const rightPlayer = Object.entries(state.players).find(
([_, data]: any) => data.side === 'right'
);
if (rightPlayer) {
runtime.submitAction('score', undefined, rightPlayer[0]);
this.resetBall();
}
} else if (this.ball.x > 810) {
const leftPlayer = Object.entries(state.players).find(
([_, data]: any) => data.side === 'left'
);
if (leftPlayer) {
runtime.submitAction('score', undefined, leftPlayer[0]);
this.resetBall();
}
}
}
private resetBall() {
setTimeout(() => {
const state = runtime.getState();
this.ball.setPosition(state.ball.x, state.ball.y);
(this.ball.body as Phaser.Physics.Arcade.Body).setVelocity(
state.ball.velocityX,
state.ball.velocityY
);
}, 10);
}
};
} What Helpers Do:
- SpriteManager: Automatically syncs paddle positions between host and clients
- InputManager: Handles keyboard input with preset profiles (platformer = up/down keys)
- HUD: Displays scores and player information automatically
How It Works
The Multiplayer Flow
1. Player presses W
↓
2. Input captured (InputManager or manual)
↓
3. runtime.submitAction('move', { up: true })
↓
4. Action sent to host, applies to state
↓
5. state.inputs[playerId] = { up: true }
↓
6. Host's updatePhysics() reads state.inputs
↓
7. paddleBody.setVelocityY(-speed) applies physics
↓
8. SpriteManager/adapter tracks new position
↓
9. Host broadcasts state diff to all clients
↓
10. Clients receive diff and update their sprites
↓
11. Smooth interpolation displays movement Next: Initialize & Test
Now we’ll wire everything together and test the game!
👉 Continue to Part 3: Running Your Game