Tappy Color
Un juego iOS tap-to-survive construido con mi amigo Chris Greer en el verano de 2016. Remasterizado en 2024 despues de ocho anos en el cajon.
El juego
Tappy Color es un juego tap-to-survive. El nivel del agua baja constantemente, y si baja demasiado, Tappy muere. Tu objetivo es mantenerlo vivo el mayor tiempo posible.
Pantalla azul: Toca tan rapido como puedas. Cada toque sube el nivel del agua.
Pantalla roja: Deja de tocar! Tocar mientras esta rojo hace que el agua caiga repentinamente. Esta es la trampa.
Estrella de mar: Tocala para invencibilidad. Mientras eres invencible, el rojo no aparece - toca libremente.
Los cambios de color se vuelven mas rapidos a medida que aumenta la puntuacion. Lo que empieza como un juego de reflejos se convierte en una prueba de control de impulsos.
Lo registramos bajo "Bhris" - un acronimon de nuestros nombres. Chris construyo las mecanicas del juego y los sistemas centrales.
El proyecto permanecio intacto durante ocho anos. Luego en julio de 2024, decidi desempolvarlo.
Quien construyo que
Tappy Color fue una verdadera colaboracion. Chris y yo trabajamos codo a codo ese verano, intercambiando commits.
- Concepto central del juego (mecanica tap-to-survive)
- Impulso un personaje del que los jugadores se preocuparian
- Sistema de movimiento de natacion de Tappy
- Curva de dificultad usando tanh() para rampa suave
- Maquina de estados del juego y seguimiento de puntuacion
- Transiciones de color y mecanicas de timing
- Decodificador GIF hecho a mano para animaciones
- Pixel art y animaciones de Tappy (2016)
- Composicion de soundtrack (2016)
- Efectos de sonido 8-bit (2016)
- Pantallas de tutorial y ayuda (2016)
- Migracion de Swift 2 a Swift 5 (2024)
- Singleton AudioManager moderno (2024)
- Integracion de Game Center (2024)
- Modernizacion iOS 16.4+ (2024)
Una nota rapida sobre "diseno de juegos": en desarrollo de juegos, significa idear la idea central de lo que hace divertido al juego.
La primera version era solo una pantalla que se volvia verde cuando tocabas y roja cuando fallabas. Chris decia: "No, tenemos que hacer algo que le importe a la gente, como un personaje."
Pero el tenia razon. Me sumergi en el pixel art, dando vida a Tappy frame por frame.
Nuestro amigo mutuo Dustin lo jugaba una y otra vez, y a su hijo tambien le encantaba.
300 errores de compilador
Cuando abri el proyecto en Xcode 15, me recibieron mas de 300 errores de compilador. El codigo original estaba escrito en Swift 2.x.
Cada NSBundle se convirtio en Bundle. Cada NSURL se convirtio en URL. La migracion no fue solo tediosa - fue arqueologica.
// 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)
Animacion de nado de Tappy
Una de las cosas de las que todavia estamos orgullosos: Tappy realmente nada por la pantalla.
Chris escribio la logica de movimiento original usando Core Animation. El pez en esta pagina usa el mismo enfoque, portado a JavaScript.
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 GIF hecho a mano
En 2016, no habia una buena biblioteca Swift para GIFs animados, asi que Chris escribio la suya usando ImageIO y Core Graphics.
Funcionaba en Swift 2, y con actualizaciones de sintaxis menores durante el remaster, todavia funciona hoy.
El Remaster 2024
Despues de migrar la sintaxis de Swift, aborde mejoras reales.
Sistema de audio moderno
El codigo original tenia instancias de reproductor de audio dispersas por todas partes. Consolide todo en un singleton AudioManager con manejo adecuado 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()
}
}
}
Integracion Game Center
El juego original no tenia tablas de clasificacion. El remaster tiene soporte completo de Game Center con rankings globales.
Objetivo iOS 16.4+
No mas soporte para dispositivos antiguos. El remaster corre en iOS moderno con todas las ultimas APIs.
Bajo el capo
Algunos patrones de codigo que todavia funcionan ocho anos despues.
Curva de dificultad suave
El agua se drena mas rapido a medida que aumenta la puntuacion, pero la dificultad no sube linealmente. Chris uso una funcion tangente hiperbolica para crear una curva S suave.
// 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))))
}
Efectos de sonido thread-safe
Cuando tocas freneticamente, docenas de efectos de sonido pueden dispararse por segundo. El AudioManager remasterizado mantiene un pool de reproductores y protege el acceso concurrente con un lock.
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()
}
Animacion del Presentation Layer
Core Animation es complicado. Chris resolvio esto leyendo desde el presentation layer para obtener la posicion real de Tappy en pantalla durante las animaciones.
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 con logica de reintento
Las solicitudes de red fallan. La integracion de Game Center remasterizada usa backoff exponencial con numero maximo de reintentos.
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))
}
}
}
Lo abri de nuevo
Enero 2026. Sacudi el polvo y arregle todo.
Bucle de juego unico
Cuatro timers independientes se convirtieron en uno. Inspirado por la filosofia de John Carmack - un bucle maestro despacha a todos los 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 dificultad precalculada
La curva de dificultad usaba tanh() cada tick - operaciones de punto flotante costosas 20 veces por segundo. Ahora es una simple busqueda en 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]
Evitacion de asignacion CGRect
El movimiento de la estrella creaba un nuevo CGRect cada frame. Ahora modifica el frame existente en su 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
Actualizaciones de etiquetas con dirty flag
Las etiquetas se actualizaban cada tick aunque los valores no hubieran cambiado - 40 asignaciones de strings por segundo desperdiciadas.
private var lastDisplayedScore: Int = -1
func updateLabels() {
if game.score != lastDisplayedScore {
scoreLabel.text = "\(game.score)"
lastDisplayedScore = game.score
}
}
Extraccion de GameModel
Todo el estado mutable del juego ahora vive en una clase GameModel dedicada. El ViewController maneja UI; el modelo maneja logica.
Nueva version llegando pronto a la App Store.
Linea de tiempo
Agosto 2016 - Chris y yo construimos Tappy Color en Swift 2. Dos estudiantes universitarios, un pez payaso pixelado, publicado en la App Store.
2017-2023 - La app permanecio intocada. Swift evoluciono. El codigo se volvio arqueologico.
Agosto 2024 - Xcode 15 se abrio con 300+ errores de compilador. Todo migrado a Swift 5. UI remasterizada. Tappy Color Remastered publicado en la App Store.
Enero 2026 - Reabierto. GameModel extraido. 4 timers consolidados en un bucle de juego estilo Carmack. Curva de dificultad precalculada. Dirty flags anadidos. Codigo muerto eliminado.
Diez anos, tres versiones, el mismo pez.