Pong
Voltar para: Games com Phaser + Reactjs
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
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
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.
# 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 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.
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} /> }
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.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.
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') } }
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.
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') }) } }
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.
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
A raquete não se move ao ser atingida pela bola. Sem isso, ela seria empurrada para fora.
Impede que a raquete saia pelos limites da tela (topo e base).
Define a velocidade vertical em pixels por segundo. Negativo = para cima.
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.
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) } }
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().
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')) } }
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.
import PhaserGame from './PhaserGame' export default function App() { return ( <div style={{ display: 'flex', justifyContent: 'center', padding: '2rem' }}> <PhaserGame /> </div> ) }
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
Adiciona um body de física a um objeto já criado na cena.
Objeto não é afetado por colisões — só empurra, não é empurrado.
Fator de restituição = 1 significa rebate sem perder velocidade.
Impede que o objeto saia pelos limites da tela.
Registra uma colisão entre dois objetos. Aceita callback opcional.
Executado ~60x por segundo. Toda lógica de movimento fica aqui.
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.