Snake
Voltar para: Games com Phaser + Reactjs
O Snake é o primeiro jogo da trilogia de puzzles lógicos e o mais imediato de entender: o corpo da cobra é literalmente um array que cresce para frente e encolhe por trás. É o jogo da série onde a estrutura de dados mais aparece na tela — cada segmento é um elemento do array, visível e rastreável.
📖 Curiosidade: o Snake surgiu em arcades nos anos 70 como Blockade, mas ficou mundialmente famoso quando a Nokia o incluiu nos celulares a partir de 1998. Em alguns modelos era possível jogar durante uma ligação — o que explica por que toda uma geração aprendeu a jogar escondido na escola. É um dos jogos mais portados da história.
Por que Snake depois do Jogo da Memória?
O Jogo da Memória introduziu estado por objeto e eventos de clique. O Snake retorna ao movimento contínuo — mas desta vez o estado que importa é o array inteiro do corpo, não objetos individuais. É o primeiro jogo da série onde o jogador literalmente cresce ao longo do tempo.
| Jogos anteriores | Snake |
|---|---|
| Objetos criados e destruídos | Array que cresce (push) e encolhe (shift) |
| Estado externo ao objeto | O array é o estado do jogo |
| Input instantâneo | Direção buffered — muda no próximo tick |
| Colisão com outros objetos | Colisão com si mesmo |
| update() com física | update() por timer (não por frame) |
A série completa
Objetivos do projeto
- Grid de jogo com células de tamanho fixo
- Cobra que se move automaticamente a cada tick
- Crescimento do corpo ao comer a comida
- Game Over ao bater na parede ou em si mesma
- Comida gerada em posição aleatória livre
- Placar e aceleração progressiva da velocidade
A ideia central: o corpo como array
A cobra inteira é um array de posições {x, y}. A cada tick, um novo segmento é adicionado na frente (a nova cabeça) e o último é removido. Quando come a comida, simplesmente não remove o último — e a cobra cresce:
Fluxo do jogo
Estrutura de arquivos
src/ ├── main.jsx ├── PhaserGame.jsx ├── scenes/ │ ├── BootScene.js │ ├── MenuScene.js │ └── SnakeScene.js ├── objects/ │ └── SnakeBody.js # encapsula o array e o desenho do corpo └── systems/ └── FoodSpawner.js # gera comida em posição livre
No Snake, o Phaser não controla posição nem velocidade dos segmentos — nós controlamos tudo via array. O Phaser serve apenas para desenhar retângulos nas posições do array e capturar o teclado. É o exemplo mais puro da série de “lógica separada da renderização”.
O SnakeBody encapsula o array de segmentos e os retângulos visuais correspondentes. Os dois precisam estar sempre sincronizados — para cada posição no array, existe um retângulo na tela.
export const CELL = 32 // tamanho de cada célula em pixels export default class SnakeBody { constructor(scene, startX, startY) { this.scene = scene // Array de posições lógicas: [{x, y}, {x, y}, ...] this.segments = [ { x: startX, y: startY }, { x: startX - 1, y: startY }, { x: startX - 2, y: startY }, ] // Array paralelo de retângulos visuais this.rects = this.segments.map((seg, i) => makeRect(scene, seg, i === 0) ) } move(nextHead, grow = false) { // 1. Adiciona nova cabeça no início do array this.segments.unshift(nextHead) this.rects.unshift(makeRect(this.scene, nextHead, true)) // Recolore a antiga cabeça para cor de corpo if (this.rects[1]) this.rects[1].setFillStyle(0x639922) if (!grow) { // 2. Remove a cauda (move sem crescer) this.segments.pop() const tail = this.rects.pop() tail.destroy() } } getHead() { return this.segments[0] } // Verifica se a cabeça colidiu com algum segmento do corpo collidesWithSelf() { const head = this.getHead() return this.segments .slice(1) // ignora a própria cabeça .some(seg => seg.x === head.x && seg.y === head.y) } contains(pos) { return this.segments.some(s => s.x === pos.x && s.y === pos.y) } destroy() { this.rects.forEach(r => r.destroy()) } } // Cria um retângulo visual para um segmento function makeRect(scene, seg, isHead) { const color = isHead ? 0x3B6D11 : 0x639922 return scene.add .rectangle( seg.x * CELL + CELL / 2, seg.y * CELL + CELL / 2, CELL - 2, CELL - 2, color ) .setStrokeStyle(1, 0x0d1a0a) }
unshift() na frente e pop() no fim é o padrão perfeito para uma fila — exatamente como o corpo de uma cobra funciona. Cada tick, a cobra “avança” sem precisar recalcular nenhuma posição intermediária.A comida precisa aparecer em uma célula que não esteja ocupada pelo corpo da cobra. O FoodSpawner coleta todas as células livres do grid e escolhe uma aleatoriamente — o mesmo padrão do spawnTile do 2048.
import { CELL } from '../objects/SnakeBody' export default class FoodSpawner { constructor(scene, cols, rows) { this.scene = scene this.cols = cols this.rows = rows this.rect = null this.pos = null } spawn(snakeBody) { // Remove comida anterior se existir if (this.rect) this.rect.destroy() // Coleta todas as células livres const free = [] for (let x = 0; x < this.cols; x++) { for (let y = 0; y < this.rows; y++) { if (!snakeBody.contains({ x, y })) { free.push({ x, y }) } } } if (free.length === 0) return null // tabuleiro cheio = vitória! // Posição aleatória entre as livres this.pos = Phaser.Utils.Array.GetRandom(free) this.rect = this.scene.add .rectangle( this.pos.x * CELL + CELL / 2, this.pos.y * CELL + CELL / 2, CELL - 4, CELL - 4, 0xE24B4A ) .setStrokeStyle(1, 0x791F1F) return this.pos } getPos() { return this.pos } }
A cena gerencia o tick de movimento via time.addEvent — a cobra avança a cada N milissegundos, independente do framerate. O input do teclado apenas muda a direção pendente, que é consumida no próximo tick.
import SnakeBody, { CELL } from '../objects/SnakeBody' import FoodSpawner from '../systems/FoodSpawner' export default class SnakeScene extends Phaser.Scene { constructor() { super('Game') } create() { const COLS = 20, ROWS = 18 const W = COLS * CELL, H = ROWS * CELL // ─── Fundo e grid decorativo ───────────────────────── this.add.rectangle(W/2, H/2 + 20, W, H, 0x0d1a0a) for (let x = 0; x < COLS; x++) { for (let y = 0; y < ROWS; y++) { const color = (x + y) % 2 === 0 ? 0x111f0d : 0x0d1a0a this.add.rectangle( x * CELL + CELL/2, y * CELL + CELL/2 + 40, CELL, CELL, color ) } } // ─── Objetos do jogo ───────────────────────────────── this.snake = new SnakeBody(this, 8, 9) this.spawner = new FoodSpawner(this, COLS, ROWS) this.spawner.spawn(this.snake) // ─── Estado do jogo ─────────────────────────────────── this.dir = { x: 1, y: 0 } // direção atual (começa indo para direita) this.nextDir = { x: 1, y: 0 } // próxima direção (buffered) this.score = 0 this.speed = 160 // ms por tick (menor = mais rápido) this.gameOver = false this.COLS = COLS this.ROWS = ROWS // ─── HUD ───────────────────────────────────────────── this.scoreText = this.add.text(8, 8, 'Pontos: 0', { fontSize: '16px', fontFamily: 'monospace', color: '#C0DD97' }) this.add.text(W - 8, 8, 'Snake', { fontSize: '16px', fontFamily: 'monospace', fontStyle: 'bold', color: '#97C459' }).setOrigin(1, 0) // ─── Controles ─────────────────────────────────────── this.cursors = this.input.keyboard.createCursorKeys() this.wasd = this.input.keyboard.addKeys({ up: 'W', down: 'S', left: 'A', right: 'D' }) // ─── Tick de movimento ──────────────────────────────── // A cobra avança a cada this.speed milissegundos this.tickEvent = this.time.addEvent({ delay: this.speed, loop: true, callback: this.tick, callbackScope: this }) } }
O ponto mais importante do Snake é a separação entre input e movimento. O teclado registra a intenção de mudar de direção, mas o movimento real só acontece no próximo tick. Isso evita dois bugs clássicos: a cobra fazer uma curva de 180° e o input ser descartado por chegar antes do tick.
tick() { if (this.gameOver) return // Aplica a direção buffered this.dir = this.nextDir // Calcula a nova cabeça const head = this.snake.getHead() const nextPos = { x: head.x + this.dir.x, y: head.y + this.dir.y } // ── Verificar colisão com as paredes ────────────────── if ( nextPos.x < 0 || nextPos.x >= this.COLS || nextPos.y < 0 || nextPos.y >= this.ROWS ) { this.endGame() return } // ── Verificar colisão com o próprio corpo ───────────── if (this.snake.contains(nextPos)) { this.endGame() return } // ── Verificar se comeu a comida ─────────────────────── const food = this.spawner.getPos() const ate = food && nextPos.x === food.x && nextPos.y === food.y const grow = ate // Move (grow=true = não remove a cauda) this.snake.move(nextPos, grow) if (ate) { this.score += 10 this.scoreText.setText('Pontos: ' + this.score) this.spawner.spawn(this.snake) // Aumenta a velocidade a cada 50 pontos if (this.score % 50 === 0 && this.speed > 60) { this.speed -= 15 this.tickEvent.reset({ delay: this.speed, loop: true, callback: this.tick, callbackScope: this }) } } } update() { if (this.gameOver) return const up = this.cursors.up.isDown || this.wasd.up.isDown const down = this.cursors.down.isDown || this.wasd.down.isDown const left = this.cursors.left.isDown || this.wasd.left.isDown const right = this.cursors.right.isDown || this.wasd.right.isDown // Guarda a próxima direção, mas impede inverter 180° if (up && this.dir.y !== 1) this.nextDir = { x: 0, y: -1 } else if (down && this.dir.y !== -1) this.nextDir = { x: 0, y: 1 } else if (left && this.dir.x !== 1) this.nextDir = { x: -1, y: 0 } else if (right && this.dir.x !== -1) this.nextDir = { x: 1, y: 0 } } endGame() { this.gameOver = true this.tickEvent.remove() const W = this.COLS * CELL const H = this.ROWS * CELL this.add.rectangle(W/2, H/2 + 20, W, H, 0x000000, 0.6) this.add.text(W/2, H/2 - 20, 'Game Over', { fontSize: '40px', fontFamily: 'monospace', fontStyle: 'bold', color: '#E24B4A' }).setOrigin(0.5) this.add.text(W/2, H/2 + 30, 'Pontos: ' + this.score, { fontSize: '20px', fontFamily: 'monospace', color: '#ffffff' }).setOrigin(0.5) this.add.text(W/2, H/2 + 75, 'ESPAÇO = jogar de novo | ESC = menu', { fontSize: '12px', 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')) }
this.dir.y !== 1 antes de aceitar “cima” é essencial. Sem ela, o jogador pode pressionar a tecla contrária e a cobra colidir consigo mesma instantaneamente — o bug mais clássico do Snake mal implementado.O buffer de direção (nextDir separado de dir) é um detalhe simples que muda completamente a sensação do jogo. Veja a diferença:
| Sem buffer | Com buffer (nosso código) |
|---|---|
| Direção muda imediatamente no update() | update() só guarda a intenção em nextDir |
| Input antes do tick pode ser ignorado | Input sempre é preservado até o próximo tick |
| Curva de 180° possível se o tick atrasar | Curva de 180° bloqueada na checagem |
| Input rápido pode “pular” uma direção | Apenas a última direção válida é aplicada |
// update() roda 60x por segundo: guarda a intenção if (up && this.dir.y !== 1) this.nextDir = { x: 0, y: -1 } // tick() roda a cada Nms: consome a intenção this.dir = this.nextDir // só aqui a direção real muda
O Snake também não usa física arcade — todo o movimento é manual via array. O config é simples:
import { useEffect, useRef } from 'react' import Phaser from 'phaser' import BootScene from './scenes/BootScene' import MenuScene from './scenes/MenuScene' import SnakeScene from './scenes/SnakeScene' export default function PhaserGame() { const divRef = useRef(null) useEffect(() => { const config = { type: Phaser.AUTO, width: 640, // 20 colunas × 32px height: 616, // 18 linhas × 32px + 40px de HUD parent: divRef.current, backgroundColor: '#0d1a0a', // Sem physics — todo o movimento é feito via array scene: [BootScene, MenuScene, SnakeScene] } const game = new Phaser.Game(config) return () => game.destroy(true) }, []) return <div ref={divRef} /> }
Conceitos fundamentais aprendidos
O corpo inteiro é um array. Adicionar na frente e remover no fim é suficiente para toda a lógica de movimento.
O movimento acontece em intervalos fixos, não a cada frame. Permite controlar a velocidade independente do FPS.
Input guarda intenção; tick consome intenção. Evita curva de 180° e garante que nenhum input seja perdido.
Verifica se a nova cabeça coincide com qualquer segmento do corpo — o tipo de colisão mais único da série.
A única diferença entre mover e crescer é se o pop() é chamado ou não. Elegância máxima.
Ao atingir certos pontos, o timer é reiniciado com delay menor — o jogo fica mais rápido naturalmente.
Desafios para evoluir o jogo
- Paredes que teletransportam
- Modo sem fim com mapa infinito
- Dois jogadores na mesma tela
- Comida especial que vale mais
- Velocidade reduzida temporária
- Escudo contra a própria cauda
- Sprites animados no lugar de retângulos
- Partícula ao comer comida
- Rastro de brilho na cabeça
- Salvar melhor pontuação com localStorage
- Ranking local com nome do jogador
- Histórico das últimas 5 partidas
