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