Pong

0

Neste tutorial você vai criar um jogo completo de Pong usando React + Phaser 3, do zero. Cada etapa é explicada com código comentado e boas práticas de organização de projeto.

Por que Pong?

O Pong foi criado pela Atari e é considerado um dos primeiros videogames de sucesso comercial. Apesar de simples, ele é perfeito para aprender os fundamentos de jogos:

  • Movimento e velocidade de objetos
  • Detecção de colisão
  • Input do jogador via teclado
  • Sistema de pontuação
  • Física básica com bounce
  • Game loop com update()

Objetivos do projeto

Ao final deste tutorial você terá:

  • Projeto React configurado com Vite
  • Phaser 3 integrado como componente React
  • Tela de boot e menu inicial
  • Gameplay com colisão, pontuação e IA
  • Código organizado em cenas e objetos

Fluxo do jogo

Boot Menu GameScene

Estrutura de arquivos

src/
 ├── main.jsx          # ponto de entrada React
 ├── App.jsx
 ├── PhaserGame.jsx    # componente que monta o Phaser
 ├── scenes/
 │   ├── BootScene.js
 │   ├── MenuScene.js
 │   └── GameScene.js
 └── objects/
     ├── Paddle.js
     └── Ball.js

1
Criando o projeto

Com Node.js 18+ instalado, use o Vite para criar o projeto. O Vite é a ferramenta de build moderna recomendada para React — muito mais rápida que o Create React App.

terminal
# Criar projeto com template React
npm create vite@latest meu-pong -- --template react
cd meu-pong

# Instalar dependências padrão
npm install

# Instalar o Phaser 3
npm install phaser

# Iniciar servidor de desenvolvimento
npm run dev
💡 O Vite já configura hot reload automaticamente. Cada vez que você salvar um arquivo, o navegador atualiza na hora.
2
Integrando o Phaser no React

O Phaser precisa de um elemento <div> do DOM para montar o canvas. No React, usamos useRef para referenciar esse elemento e useEffect para criar o jogo após a montagem do componente.

src/PhaserGame.jsx
import { useEffect, useRef } from 'react'
    import Phaser from 'phaser'
    import BootScene from './scenes/BootScene'
    import MenuScene from './scenes/MenuScene'
    import GameScene from './scenes/GameScene'

    export default function PhaserGame() {
      const divRef = useRef(null)

      useEffect(() => {
        const config = {
      type: Phaser.AUTO,       // detecta WebGL ou Canvas automaticamente
      width: 800,
      height: 500,
      parent: divRef.current,  // monta o canvas dentro do nosso div
      physics: {
        default: 'arcade',
        arcade: { debug: false }
      },
      scene: [BootScene, MenuScene, GameScene]
        }

        const game = new Phaser.Game(config)

        // Cleanup: destrói o jogo ao desmontar o componente
        return () => game.destroy(true)
      }, []) // [] = executa só uma vez

      return <div ref={divRef} />
    }
⚠️ O return () => game.destroy(true) dentro do useEffect é essencial. Sem ele, múltiplas instâncias do Phaser são criadas quando o componente re-monta, causando bugs difíceis de debugar.
3
BootScene — carregamento de assets

A BootScene é a primeira cena executada. Ela é o lugar correto para carregar imagens, sons e outros assets com this.load. Como nosso jogo usa formas geométricas, ela apenas redireciona para o Menu.

src/scenes/BootScene.js
import Phaser from 'phaser'

    export default class BootScene extends Phaser.Scene {
      constructor() {
        super('Boot') // chave única da cena
      }

      preload() {
        // Aqui você carregaria assets externos, por exemplo:
        // this.load.image('fundo', 'assets/fundo.png')
        // this.load.audio('beep', 'assets/beep.mp3')
      }

      create() {
        // Após o preload, avança para o Menu
        this.scene.start('Menu')
      }
    }
4
MenuScene — tela inicial

A tela de menu exibe o título e aguarda o jogador pressionar ESPAÇO. Também desenhamos uma linha central pontilhada para dar o visual clássico do Pong.

src/scenes/MenuScene.js
import Phaser from 'phaser'

    export default class MenuScene extends Phaser.Scene {
      constructor() { super('Menu') }

      create() {
        const { width: W, height: H } = this.cameras.main

        // Fundo escuro
        this.add.rectangle(W/2, H/2, W, H, 0x1a1a2e)

        // Linha central pontilhada
        for (let y = 0; y < H; y += 30) {
      this.add.rectangle(W/2, y + 10, 4, 18, 0x444466)
        }

        // Título
        this.add.text(W/2, H/2 - 60, 'PONG', {
      fontSize: '64px',
      fontFamily: 'monospace',
      color: '#ffffff'
        }).setOrigin(0.5)

        // Instrução
        this.add.text(W/2, H/2 + 20, 'Pressione ESPAÇO para jogar', {
      fontSize: '18px',
      fontFamily: 'monospace',
      color: '#aaaaaa'
        }).setOrigin(0.5)

        // Detectar tecla ESPAÇO e iniciar o jogo
        const space = this.input.keyboard
      .addKey(Phaser.Input.Keyboard.KeyCodes.SPACE)

        space.on('down', () => {
      this.scene.start('Game')
        })
      }
    }
5
Paddle.js — a raquete

Separar a raquete em uma classe própria facilita a reutilização — tanto para o jogador quanto para a IA. O método physics.add.existing() adiciona física a um objeto que já existe na cena.

src/objects/Paddle.js
export default class Paddle {
  constructor(scene, x, y, color = 0xffffff) {
    this.scene = scene

    // Cria um retângulo de 16x100 pixels
    this.sprite = scene.add.rectangle(x, y, 16, 100, color)

    // Adiciona física arcade ao retângulo existente
    scene.physics.add.existing(this.sprite)

    // Imóvel: a raquete não é empurrada quando a bola bate
    this.sprite.body.setImmovable(true)

    // Não sai da tela
    this.sprite.body.setCollideWorldBounds(true)
  }

  moveUp()   { this.sprite.body.setVelocityY(-350) }
  moveDown() { this.sprite.body.setVelocityY(350) }
  stop()     { this.sprite.body.setVelocityY(0) }
}

Conceitos importantes

setImmovable
true

A raquete não se move ao ser atingida pela bola. Sem isso, ela seria empurrada para fora.

setCollideWorldBounds
true

Impede que a raquete saia pelos limites da tela (topo e base).

setVelocityY
±350

Define a velocidade vertical em pixels por segundo. Negativo = para cima.

6
Ball.js — a bola

A bola usa setBounce(1, 1) para rebater perfeitamente sem perder velocidade. A velocidade aumenta a cada rebatida na raquete para aumentar a dificuldade gradualmente.

src/objects/Ball.js
export default class Ball {
  constructor(scene, x, y) {
    this.scene = scene

    // Círculo branco com raio 10
    this.sprite = scene.add.circle(x, y, 10, 0xffffff)
    scene.physics.add.existing(this.sprite)

    // Rebate nas bordas superior e inferior da tela
    this.sprite.body.setCollideWorldBounds(true)

    // bounce(1,1) = sem perda de energia ao rebater
    this.sprite.body.setBounce(1, 1)
  }

  launch() {
    // Direção aleatória a cada lançamento
    const vx = Math.random() > 0.5 ? 240 : -240
    const vy = Phaser.Math.Between(-160, 160)
    this.sprite.body.setVelocity(vx, vy)
  }

  reset(cx, cy) {
    // Volta ao centro e para
    this.sprite.setPosition(cx, cy)
    this.sprite.body.setVelocity(0, 0)
  }
}
7
GameScene.js — a cena principal

A GameScene une tudo: cria o jogador, a IA e a bola, registra as colisões, lida com o input do teclado e verifica pontuação a cada frame no update().

src/scenes/GameScene.js
import Phaser from 'phaser'
    import Paddle from '../objects/Paddle'
    import Ball from '../objects/Ball'

    export default class GameScene extends Phaser.Scene {
      constructor() { super('Game') }

      create() {
        const { width: W, height: H } = this.cameras.main

        // Fundo
        this.add.rectangle(W/2, H/2, W, H, 0x1a1a2e)

        // Criar raquetes e bola
        this.player = new Paddle(this, 40, H/2, 0x5599ee)
        this.enemy  = new Paddle(this, W - 40, H/2, 0xee5555)
        this.ball   = new Ball(this, W/2, H/2)

        // Registrar colisões: bola ↔ raquetes
        this.physics.add.collider(
      this.ball.sprite, this.player.sprite,
      this.onHit, null, this
        )
        this.physics.add.collider(
      this.ball.sprite, this.enemy.sprite,
      this.onHit, null, this
        )

        // Ajusta colisões com os limites do mundo
        this.physics.world.setBoundsCollision(false, false, true, true)

        // Teclas de controle
        this.cursors = this.input.keyboard.createCursorKeys()
        this.wasd    = this.input.keyboard.addKeys({ up: 'W', down: 'S' })

        // Placar
        this.pScore = 0
        this.eScore = 0
        this.scoreText = this.add.text(W/2, 28, '0  :  0', {
      fontSize: '28px', fontFamily: 'monospace', color: '#ffffff'
        }).setOrigin(0.5)

        // Lançar bola após 1.2s
        this.time.delayedCall(1200, () => this.ball.launch())
      }

      onHit(ball, paddle) {
        const v   = ball.body.velocity
        const spd = Math.min(
      Math.sqrt(v.x*v.x + v.y*v.y) * 1.06,
      520
        )
        const ang = Math.atan2(v.y, v.x)
        ball.body.setVelocity(Math.cos(ang)*spd, Math.sin(ang)*spd)

        // Efeito visual: pop de escala
        try {
      this.tweens.add({
        targets: ball,
        scale: 1.25,
        duration: 60,
        yoyo: true,
        ease: 'Power1'
      })

      // Cor baseada na velocidade
      let color = 0xffffff
      if (spd > 440) color = 0xff4444
      else if (spd > 380) color = 0xffcc66
      else if (spd > 320) color = 0xffff88
      if (typeof ball.setFillStyle === 'function') ball.setFillStyle(color)

      // Efeito visual de colisão
      const effect = this.add.circle(ball.x, ball.y, 14, 0xffffff, 0.22).setDepth(5)
      this.tweens.add({
        targets: effect,
        alpha: 0,
        scale: 1.8,
        duration: 140,
        ease: 'Quad.easeOut',
        onComplete: () => effect.destroy()
      })
        } catch (e) {
      // silent fail
        }
      }

      update() {
        const W = this.cameras.main.width

        // Controle do jogador
        if (this.cursors.up.isDown || this.wasd.up.isDown)
      this.player.moveUp()
        else if (this.cursors.down.isDown || this.wasd.down.isDown)
      this.player.moveDown()
        else
      this.player.stop()

        // IA: segue a bola com velocidade limitada
        const diff = this.ball.sprite.y - this.enemy.sprite.y
        if      (diff >  6) this.enemy.moveDown()
        else if (diff < -6) this.enemy.moveUp()
        else                this.enemy.stop()

        // Verificar ponto (bola saiu pela lateral)
        if (this.ball.sprite.x < -15)  this.addPoint('enemy')
        if (this.ball.sprite.x > W+15)  this.addPoint('player')
      }

      addPoint(who) {
        if (who === 'player') this.pScore++
        else                   this.eScore++

        this.scoreText.setText(this.pScore + '  :  ' + this.eScore)

        const { width: W, height: H } = this.cameras.main
        this.ball.reset(W/2, H/2)

        // Verifica vitória (primeiro a 5)
        if (this.pScore >= 5 || this.eScore >= 5) {
      this.showGameOver()
      return
        }

        // Próximo ponto após 1.1s
        this.time.delayedCall(1100, () => this.ball.launch())
      }

      showGameOver() {
        const { width: W, height: H } = this.cameras.main
        const won = this.pScore >= 5

        this.add.text(W/2, H/2,
      won ? 'Você ganhou!' : 'CPU ganhou!', {
      fontSize: '36px', fontFamily: 'monospace',
      color: won ? '#66ff88' : '#ff6666'
        }).setOrigin(0.5)

        this.add.text(W/2, H/2 + 50,
      'ESPAÇO = jogar de novo  |  ESC = menu', {
      fontSize: '13px', fontFamily: 'monospace',
      color: '#aaaaaa'
        }).setOrigin(0.5)

        this.input.keyboard
      .addKey('SPACE').on('down', () => this.scene.restart())
        this.input.keyboard
      .addKey('ESC').on('down', () => this.scene.start('Menu'))
      }
    }
8
Conectando tudo no App

Por último, importe o PhaserGame no App.jsx e renderize-o. O React cuida do ciclo de vida e o Phaser cuida do jogo dentro do div.

src/App.jsx
import PhaserGame from './PhaserGame'

export default function App() {
  return (
    <div style={{ display: 'flex', justifyContent: 'center', padding: '2rem' }}>
      <PhaserGame />
    </div>
  )
}
💡 Quer comunicar dados entre React e Phaser? Você pode passar callbacks via props ou usar uma store global como Zustand — o Phaser emite eventos e o React reage a eles.

Resumo dos conceitos de física arcade

physics.add.existing(obj)
adiciona física

Adiciona um body de física a um objeto já criado na cena.

setImmovable(true)
raquete

Objeto não é afetado por colisões — só empurra, não é empurrado.

setBounce(1, 1)
bola

Fator de restituição = 1 significa rebate sem perder velocidade.

setCollideWorldBounds
todos

Impede que o objeto saia pelos limites da tela.

physics.add.collider()
colisão

Registra uma colisão entre dois objetos. Aceita callback opcional.

update()
game loop

Executado ~60x por segundo. Toda lógica de movimento fica aqui.

🚀 Próximos passos sugeridos: adicione sons com this.sound.play(), crie uma tela de seleção de personagem, implemente dois jogadores no mesmo teclado, ou suba o jogo no Vercel com npm run build.