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
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
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 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)   // azul
    this.enemy  = new Paddle(this, W - 40, H/2, 0xee5555) // vermelho
    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
    )

    // Teclas de seta + WASD
    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())
  }

  // Chamado a cada colisão bola ↔ raquete
  onHit(ball, paddle) {
    const v   = ball.body.velocity
    const spd = Math.min(
      Math.sqrt(v.x*v.x + v.y*v.y) * 1.06, // +6% a cada rebatida
      520                                      // velocidade máxima
    )
    const ang = Math.atan2(v.y, v.x)
    ball.body.setVelocity(Math.cos(ang)*spd, Math.sin(ang)*spd)
  }

  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 7)
    if (this.pScore >= 7 || this.eScore >= 7) {
      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 >= 7

    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.