Tappy the clownfish
Menu Theme Made with GarageBand samples

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.

Blue raises water
Red warning
Red drops water
Starfish
Invincible mode

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.

Chris Greer (2016)
Diseno de juego e ingenieria
  • 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
Blake Crosley
Arte, sonido y remaster 2024
  • 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.

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)

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.

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

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()
        }
    }
}

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.

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))))
}

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.

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()
}

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.

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 con logica de reintento

Las solicitudes de red fallan. La integracion de Game Center remasterizada usa backoff exponencial con numero maximo de reintentos.

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))
        }
    }
}

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.

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

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]

Evitacion de asignacion CGRect

El movimiento de la estrella creaba un nuevo CGRect cada frame. Ahora modifica el frame existente en su 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

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.

ViewController.swift — Dirty Flags
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.

Sobre la musica y el sonido
La musica de fondo fue compuesta usando samples en GarageBand. Todos los efectos de sonido del juego fueron creados usando generadores de sonido 8-bit en linea.