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.
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.
- 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
- 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.
// 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.
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.
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.
// 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.
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.
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.
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:
@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:
// 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:
// 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:
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.