Snake

0

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 anterioresSnake
Objetos criados e destruídosArray que cresce (push) e encolhe (shift)
Estado externo ao objetoO array é o estado do jogo
Input instantâneoDireção buffered — muda no próximo tick
Colisão com outros objetosColisão com si mesmo
update() com físicaupdate() por timer (não por frame)

A série completa

Pong
física
Breakout
grupos
Shooter
spawn
Plataforma
câmera
2048
matriz
Memória
estado
Snake
array corpo

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:

head[0]
body[1]
body[2]
tail[3]
food
antes do tick:[ {x:5,y:3}, {x:4,y:3}, {x:3,y:3}, {x:2,y:3} ]
unshift nova cabeça:[ {x:6,y:3}, {x:5,y:3}, {x:4,y:3}, {x:3,y:3}, {x:2,y:3} ]
pop cauda (move):[ {x:6,y:3}, {x:5,y:3}, {x:4,y:3}, {x:3,y:3} ]
sem pop (cresce):[ {x:6,y:3}, {x:5,y:3}, {x:4,y:3}, {x:3,y:3}, {x:2,y:3} ]
A única diferença entre mover e crescer é se o pop() acontece ou não.

Fluxo do jogo

Boot Menu SnakeScene Comer comida / Game Over

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
Diferença arquitetural importante

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”.


1
SnakeBody.js — o array como corpo

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.

src/objects/SnakeBody.js
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)
}
💡 Usar 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.
2
FoodSpawner.js — comida em posição livre novo

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.

src/systems/FoodSpawner.js
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
  }
}
3
SnakeScene — o coração do jogo novo

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.

src/scenes/SnakeScene.js — create()
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
    })
  }
}
4
tick() e update() — movimento e input

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.

src/scenes/SnakeScene.js — tick() e update()
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'))
}
⚠️ A verificação 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.
5
Direção buffered — o detalhe que separa um bom Snake de um ruim

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 bufferCom buffer (nosso código)
Direção muda imediatamente no update()update() só guarda a intenção em nextDir
Input antes do tick pode ser ignoradoInput sempre é preservado até o próximo tick
Curva de 180° possível se o tick atrasarCurva de 180° bloqueada na checagem
Input rápido pode “pular” uma direçãoApenas 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
6
Registrando no PhaserGame.jsx

O Snake também não usa física arcade — todo o movimento é manual via array. O config é simples:

src/PhaserGame.jsx
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

Array como estado
unshift + pop

O corpo inteiro é um array. Adicionar na frente e remover no fim é suficiente para toda a lógica de movimento.

Tick por timer
time.addEvent(loop)

O movimento acontece em intervalos fixos, não a cada frame. Permite controlar a velocidade independente do FPS.

Direção buffered
nextDir vs dir

Input guarda intenção; tick consome intenção. Evita curva de 180° e garante que nenhum input seja perdido.

Colisão com si mesmo
array.some()

Verifica se a nova cabeça coincide com qualquer segmento do corpo — o tipo de colisão mais único da série.

Crescimento condicional
grow = ate

A única diferença entre mover e crescer é se o pop() é chamado ou não. Elegância máxima.

Aceleração progressiva
tickEvent.reset()

Ao atingir certos pontos, o timer é reiniciado com delay menor — o jogo fica mais rápido naturalmente.


Desafios para evoluir o jogo

🅽 Modos de jogo
  • Paredes que teletransportam
  • Modo sem fim com mapa infinito
  • Dois jogadores na mesma tela
✅ Power-ups
  • Comida especial que vale mais
  • Velocidade reduzida temporária
  • Escudo contra a própria cauda
🎨 Visual
  • Sprites animados no lugar de retângulos
  • Partícula ao comer comida
  • Rastro de brilho na cabeça
🏆 Recordes
  • Salvar melhor pontuação com localStorage
  • Ranking local com nome do jogador
  • Histórico das últimas 5 partidas
🚀 Próximo da série: o Tetris — que pega o conceito de array e adiciona a dimensão do tempo (as peças caem sozinhas) e transformação de forma (rotação de matriz). O Snake prepara perfeitamente o raciocínio necessário para enfrentar esse desafio.