Tappy the clownfish
Menu Theme Made with GarageBand samples

Tappy Color

Um jogo iOS de tocar para sobreviver criado com meu amigo Chris Greer no verão de 2016. Remasterizado em 2024 após oito anos na gaveta.

O Jogo

Tappy Color é um jogo de toque para sobreviver. O nível da água está constantemente caindo, e se cair demais, Tappy morre. Seu objetivo é mantê-lo vivo o maior tempo possível.

Blue raises water
Red warning
Red drops water
Starfish
Invincible mode

Tela azul: Toque o mais rápido que puder. Cada toque aumenta o nível da água.

Tela vermelha: Pare de tocar! Tocar enquanto está vermelho faz a água cair subitamente. Esta é a armadilha.

Estrela-do-mar: Toque nela para ficar invencível. Enquanto invencível, o vermelho não aparece - então toque livremente.

As mudanças de cor ficam mais rápidas conforme sua pontuação aumenta. O que começa como um jogo de reação se torna um teste de controle de impulsos.

Registramos os direitos autorais como "Bhris" - uma combinação dos nossos nomes. Chris construiu a mecânica do jogo e os sistemas principais. Eu cuidei do design, sons e pixel art. Lançamos, conseguimos alguns downloads de amigos e familiares, e então a vida aconteceu.

O projeto ficou intocado por oito anos. Então, em julho de 2024, decidi tirá-lo da gaveta.

Quem Construiu o Quê

Tappy Color foi uma verdadeira colaboração. Chris e eu trabalhamos lado a lado, enviando commits um para o outro durante aquele verão.

Chris Greer (2016)
Game Design e Engenharia
  • Conceito principal do jogo (mecânica de toque para sobreviver)
  • Insistiu em um personagem com o qual os jogadores se importariam
  • Sistema de movimento de natação do Tappy
  • Curva de dificuldade usando tanh() para progressão suave
  • Máquina de estados do jogo e rastreamento de pontuação
  • Transições de cores e mecânicas de tempo
  • Decodificador de GIF feito à mão para animações
Blake Crosley
Arte, Som & Remasterização 2024
  • Pixel art e animações do Tappy (2016)
  • Composição da trilha sonora (2016)
  • Efeitos sonoros 8-bit (2016)
  • Telas de tutorial e ajuda (2016)
  • Migração de Swift 2 para Swift 5 (2024)
  • AudioManager singleton moderno (2024)
  • Integração com Game Center (2024)
  • Modernização para iOS 16.4+ (2024)

Uma observação sobre "game design": no desenvolvimento de jogos, significa criar a ideia central do que torna o jogo divertido. "Desenvolvedor de jogos" significa iterar sobre essa semente e fazer tudo funcionar. Não tem nada a ver com design visual.

A primeira versão era apenas uma tela que ficava verde quando você tocava e vermelha quando errava. Chris insistia: "Não, precisamos fazer algo com que as pessoas se importem, tipo um personagem. Ninguém vai se importar com essa tela." Na época, eu achava que não estávamos lançando rápido o suficiente.

Mas ele estava certo. Me dediquei à pixel art, dando vida ao Tappy quadro a quadro. Compus a música e os efeitos sonoros. Projetei a UX e o fluxo do tutorial. Chris construiu o sistema de movimento que faz o Tappy nadar de forma tão natural. Juntos, foi isso que tornou divertido.

Nosso amigo em comum Dustin jogava repetidamente, e o filho dele também adorava. Ele queria que investíssemos mais e realmente completássemos. Feliz por finalmente termos feito isso.

300 Erros de Compilação

Quando abri o projeto no Xcode 15, fui recebido com mais de 300 erros de compilação. O código original foi escrito em Swift 2.x - uma versão do Swift tão antiga que a maior parte da sua sintaxe não existe mais.

Todo NSBundle virou Bundle. Toda NSURL virou URL. Toda função CGAffineTransformMake* foi substituída por inicializadores. A migração não foi apenas tediosa - foi arqueológica.

Migration Example
// Swift 2.x (2016) - What we wrote
let imageData = NSData(contentsOfURL: NSBundle.mainBundle()
    .URLForResource("tappy", withExtension: "gif")!)
self.transform = CGAffineTransformMakeScale(-1.0, 1.0)

// Swift 5.0 (2024) - What it needed to become
let imageData = NSData(contentsOf: Bundle.main
    .url(forResource: "tappy", withExtension: "gif")!)
self.transform = CGAffineTransform(scaleX: -1.0, y: 1.0)

Animação de Natação do Tappy

Uma das coisas de que ainda temos orgulho: o Tappy realmente nada pela tela. Ele escolhe um destino aleatório, anima suavemente até esse ponto em 3-6 segundos, depois escolhe um novo destino. Ele até vira horizontalmente ao mudar de direção.

Chris escreveu a lógica de movimento original usando Core Animation. O peixe nesta página usa a mesma abordagem, portada para JavaScript. Clique nele para visitar a App Store.

FishImageView.swift — Chris Greer
func startFishMove() {
    let fromPoint = self.layer.position
    self.destination = acceptablePoint()

    let movement = CABasicAnimation(keyPath: "position")
    movement.fromValue = NSValue(cgPoint: fromPoint)
    movement.toValue = NSValue(cgPoint: self.destination)
    movement.duration = 3.0 + Double(arc4random_uniform(3))
    movement.delegate = self

    self.layer.add(movement, forKey: "fishmove")
    self.state = .ChilaxedMove
    self.layer.position = self.destination
}

Decodificador de GIF Artesanal

Em 2016, não havia uma boa biblioteca Swift para GIFs animados, então Chris escreveu a sua própria usando ImageIO e Core Graphics. Ela analisa o GIF quadro a quadro, extrai dados de tempo dos metadados, encontra o maior divisor comum para reprodução otimizada e retorna uma animação UIImage adequada.

Funcionava no Swift 2, e com pequenas atualizações de sintaxe durante a remasterização, ainda funciona hoje.

A Remasterização de 2024

Após migrar a sintaxe do Swift, parti para melhorias reais.

Sistema de Áudio Moderno

O código original tinha instâncias de players de áudio espalhadas por todo lugar. Consolidei tudo em um singleton AudioManager com tratamento adequado de AVAudioSession.

AudioManager.swift — Blake Crosley (2024)
class AudioManager {
    static let shared = AudioManager()
    private var backgroundMusicPlayer: AVAudioPlayer?
    private var soundEffectPlayers: [AVAudioPlayer] = []

    func playSound(named filename: String) {
        guard !isMuted else { return }

        guard let url = Bundle.main.url(forResource: filename,
                                         withExtension: "wav") else { return }

        let player = try? AVAudioPlayer(contentsOf: url)
        player?.play()

        // Auto-cleanup after playback
        soundEffectPlayers.append(player!)
        DispatchQueue.main.asyncAfter(deadline: .now() + player!.duration) {
            self.cleanUpFinishedPlayers()
        }
    }
}

Integração com Game Center

O jogo original não tinha placares. A remasterização tem suporte completo ao Game Center com rankings globais.

Alvo iOS 16.4+

Chega de suportar dispositivos antigos. A remasterização roda no iOS moderno com todas as APIs mais recentes.

Por Baixo do Capô

Alguns padrões de código que ainda se mantêm oito anos depois.

Curva de Dificuldade Suave

A água drena mais rápido conforme sua pontuação aumenta, mas a dificuldade não aumenta linearmente - isso pareceria punitivo. Em vez disso, Chris usou uma função tangente hiperbólica para criar uma curva S suave: gentil no início, íngreme no meio, depois estabiliza em um teto. Parece justo mesmo quando está difícil.

ViewController.swift — Chris Greer
// Difficulty ramps using tanh() for a smooth S-curve
// Starts gentle, gets steep, then plateaus at ceiling
if counter <= 140 {
    // Early game: 150 → 370 drain rate
    self.currentRate = Int(150.0 + (220.0 * tanh(0.03 * (Float(counter) / 20.0))))
} else {
    // Late game: approaches ceiling asymptotically
    self.currentRate = Int(195.0 + (ceilingRate * tanh(rateAttack * (Double(counter - 140) / 20.0))))
}

Efeitos Sonoros Thread-Safe

Quando você está tocando freneticamente, dezenas de efeitos sonoros podem disparar por segundo. O AudioManager remasterizado mantém um pool de players e protege o acesso concorrente com um lock - sem falhas de áudio, sem crashes.

AudioManager.swift — Blake Crosley (2024)
private let soundEffectPlayersLock = NSLock()
private var soundEffectPlayers: [AVAudioPlayer] = []

func playSound(named filename: String) {
    guard !isMuted, let url = Bundle.main.url(forResource: filename,
                                                  withExtension: "wav"),
          let player = try? AVAudioPlayer(contentsOf: url) else { return }

    // Thread-safe: lock before modifying shared array
    soundEffectPlayersLock.lock()
    soundEffectPlayers.append(player)
    soundEffectPlayersLock.unlock()

    player.play()
}

private func cleanUpFinishedPlayers() {
    soundEffectPlayersLock.lock()
    soundEffectPlayers.removeAll { !$0.isPlaying }
    soundEffectPlayersLock.unlock()
}

Animação da Camada de Apresentação

Core Animation é complicado: quando você anima a posição de uma camada, a propriedade position pula para o valor final imediatamente - apenas a representação visual anima. Chris resolveu isso lendo da camada de apresentação para obter a posição real do Tappy na tela durante as animações.

FishImageView.swift — Chris Greer
func getActualPosition() -> CGPoint {
    // During animation, layer.position is already the END value
    // The presentation layer gives us where it ACTUALLY is on screen
    if self.layer.animation(forKey: "fishmove") != nil {
        if let presentationLayer = self.layer.presentation() {
            return presentationLayer.position
        }
    }
    return self.layer.position
}

Game Center com Lógica de Retry

Requisições de rede falham. A integração remasterizada do Game Center usa backoff exponencial com contagem máxima de tentativas - as pontuações eventualmente sincronizam mesmo em conexões instáveis.

GameCenterService.swift — Blake Crosley (2024)
enum GameCenterError: Error {
    case notAuthenticated, leaderboardNotFound
    case networkError, retryLimitExceeded
}

private func loadLeaderboardWithRetry(
    retryCount: Int = 0,
    completion: @escaping (Result<GKLeaderboard, GameCenterError>) -> Void
) {
    GKLeaderboard.loadLeaderboards { leaderboards, error in
        if let leaderboard = leaderboards?.first {
            completion(.success(leaderboard))
        } else if error != nil, retryCount < self.maxRetries {
            // Exponential backoff: wait longer each retry
            DispatchQueue.main.asyncAfter(deadline: .now() + self.retryDelay) {
                self.loadLeaderboardWithRetry(
                    retryCount: retryCount + 1,
                    completion: completion
                )
            }
        } else {
            completion(.failure(.retryLimitExceeded))
        }
    }
}

Abri de Novo

Janeiro de 2026. Tirei a poeira e consertei tudo.

Loop de Jogo Único

Quatro timers independentes viraram um só. Inspirado na filosofia de engine de jogos de John Carmack - um loop mestre despacha para todos os subsistemas:

ViewController.swift — Single Game Loop
@objc func timerAction() {
    game.tickCounter += 1

    // Difficulty from pre-computed lookup table
    game.currentRate = difficultyLUT[min(game.tickCounter, 1199)]

    // Flag-driven subsystems
    if isInvincibleBlinkActive { updateInvincibleBlink() }
    if isStarMovementActive { updateStarMovement() }

    // Dirty-flag updates
    updateLabels()
    fishView.update()
}

Curva de Dificuldade Pré-Computada

A curva de dificuldade usava tanh() a cada tick - cálculos de ponto flutuante caros 20 vezes por segundo. Agora é uma única consulta em array:

ViewController.swift — Difficulty LUT
// Setup: compute once at game start
private var difficultyLUT: [Int] = []

func precomputeDifficultyTable() {
    difficultyLUT.reserveCapacity(1200)
    for tick in 0..<1200 {
        let normalized = tanh(Double(tick) / 1000.0)
        difficultyLUT.append(Int(normalized * 20) + baseRate)
    }
}

// Game loop: O(1) lookup instead of tanh()
game.currentRate = difficultyLUT[game.tickCounter]

Eliminação de Alocação de CGRect

O movimento das estrelas criava um novo CGRect a cada frame. Agora ele modifica o frame existente no lugar:

ViewController.swift — Star Movement
// Before: new CGRect every tick
starButton.frame = CGRect(x: newX, y: newY, width: w, height: h)

// After: modify origin directly
var starFrame = starButton.frame
starFrame.origin.y -= cachedStarMoveAmount
starFrame.origin.x = CGFloat(game.starActiveX) + xphase
starButton.frame = starFrame

Atualizações de Label com Dirty Flag

Os labels eram atualizados a cada tick mesmo quando os valores não tinham mudado - 40 alocações de string por segundo sem necessidade:

ViewController.swift — Dirty Flags
private var lastDisplayedScore: Int = -1

func updateLabels() {
    if game.score != lastDisplayedScore {
        scoreLabel.text = "\(game.score)"
        lastDisplayedScore = game.score
    }
}

Extração do GameModel

Todo o estado mutável do jogo agora vive em uma classe GameModel dedicada. O ViewController cuida da UI; o model cuida da lógica. Pontuação, vida da rodada, estado das cartas, contadores de invencibilidade - tudo extraído e testável. Números mágicos documentados em GameConstants.swift. Force unwraps substituídos por unwrapping seguro. Código morto removido.

Nova versão chegando à App Store em breve.

A Linha do Tempo

Agosto de 2016 - Chris e eu criamos o Tappy Color em Swift 2. Dois universitários, um peixe-palhaço pixelado, publicado na App Store.

2017-2023 - O app fica intocado. O Swift evolui. O código se torna arqueológico.

Agosto de 2024 - Abro o Xcode 15 com mais de 300 erros de compilação. Migro tudo para Swift 5. Remasterizo a UI. Publico o Tappy Color Remastered na App Store.

Janeiro de 2026 - Abro novamente. Extraio o GameModel. Consolido quatro timers em um único game loop estilo Carmack. Pré-computo a curva de dificuldade. Adiciono dirty flags. Removo código morto. Corrijo cada problema que disse que corrigiria.

Dez anos, três versões, o mesmo peixe.

Sobre a Música e Som
A música de fundo foi composta usando samples no GarageBand. Todos os efeitos sonoros do jogo foram criados usando geradores de som 8-bit online. Use o player no canto para alternar entre as faixas do jogo.