Tappy the clownfish
Menu Theme Made with GarageBand samples

Tappy Color

Un jeu iOS tap-to-survive construit avec mon ami Chris Greer pendant l'ete 2016. Remasterise en 2024 apres huit ans dans le tiroir.

Le jeu

Tappy Color est un jeu tap-to-survive. Le niveau d'eau baisse constamment, et s'il tombe trop bas, Tappy meurt. Votre objectif est de le garder en vie le plus longtemps possible.

Blue raises water
Red warning
Red drops water
Starfish
Invincible mode

Ecran bleu: Tapez aussi vite que possible. Chaque tap augmente le niveau d'eau.

Ecran rouge: Arretez de taper! Taper pendant le rouge fait chuter l'eau soudainement. C'est le piege.

Etoile de mer: Tapez pour l'invincibilite. Pendant l'invincibilite, le rouge n'apparait pas - tapez librement.

Les changements de couleur accelerent a mesure que le score augmente. Ce qui commence comme un jeu de reflexes devient un test de controle des impulsions.

Nous l'avons depose sous "Bhris" - un mot-valise de nos noms. Chris a construit les mecaniques de jeu et les systemes centraux.

Le projet est reste intact pendant huit ans. Puis en juillet 2024, j'ai decide de le ressortir.

Qui a construit quoi

Tappy Color etait une vraie collaboration. Chris et moi avons travaille cote a cote cet ete-la, en echangeant des commits.

Chris Greer (2016)
Game design et ingenierie
  • Concept de jeu principal (mecanique tap-to-survive)
  • A pousse pour un personnage dont les joueurs se soucieraient
  • Systeme de mouvement de nage de Tappy
  • Courbe de difficulte utilisant tanh() pour une montee en douceur
  • Machine d'etat du jeu et suivi des scores
  • Transitions de couleurs et mecaniques de timing
  • Decodeur GIF fait main pour les animations
Blake Crosley
Art, son et remaster 2024
  • Pixel art et animations Tappy (2016)
  • Composition de la bande son (2016)
  • Effets sonores 8-bit (2016)
  • Ecrans de tutoriel et d'aide (2016)
  • Migration Swift 2 vers Swift 5 (2024)
  • Singleton AudioManager moderne (2024)
  • Integration Game Center (2024)
  • Modernisation iOS 16.4+ (2024)

Une note rapide sur le "game design": dans le dev de jeux, ca signifie trouver l'idee centrale de ce qui rend le jeu amusant.

La premiere version etait juste un ecran qui devenait vert quand on tapait et rouge quand on echouait. Chris disait toujours: "Non, on doit faire quelque chose dont les gens se soucieront, comme un personnage."

Mais il avait raison. Je me suis plonge dans le pixel art, donnant vie a Tappy frame par frame.

Notre ami commun Dustin y jouait sans arret, et son enfant l'adorait aussi.

300 erreurs de compilation

Quand j'ai ouvert le projet dans Xcode 15, j'ai ete accueilli par plus de 300 erreurs de compilation. Le code original etait ecrit en Swift 2.x.

Chaque NSBundle est devenu Bundle. Chaque NSURL est devenu URL. La migration n'etait pas juste fastidieuse - elle etait archeologique.

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)

Animation de nage de Tappy

Une des choses dont nous sommes encore fiers: Tappy nage vraiment autour de l'ecran.

Chris a ecrit la logique de mouvement originale avec Core Animation. Le poisson sur cette page utilise la meme approche, portee en 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
}

Decodeur GIF fait main

En 2016, il n'y avait pas de bonne bibliotheque Swift pour les GIF animes, donc Chris a ecrit la sienne avec ImageIO et Core Graphics.

Ca marchait en Swift 2, et avec des mises a jour de syntaxe mineures pendant le remaster, ca marche encore aujourd'hui.

Le Remaster 2024

Apres la migration de la syntaxe Swift, j'ai aborde de vraies ameliorations.

Systeme audio moderne

Le code original avait des instances de lecteur audio dispersees partout. J'ai tout consolide dans un singleton AudioManager avec une gestion appropriee d'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()
        }
    }
}

Integration Game Center

Le jeu original n'avait pas de classements. Le remaster a un support complet de Game Center avec des classements mondiaux.

Cible iOS 16.4+

Fini le support des anciens appareils. Le remaster fonctionne sur iOS moderne avec toutes les dernieres APIs.

Sous le capot

Quelques patterns de code qui tiennent encore apres huit ans.

Courbe de difficulte douce

L'eau se vide plus vite a mesure que le score augmente, mais la difficulte n'augmente pas lineairement. Chris a utilise une fonction tangente hyperbolique pour creer une courbe en S douce.

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

Effets sonores thread-safe

Quand on tape frenetiquement, des dizaines d'effets sonores peuvent se declencher par seconde. L'AudioManager remasterise maintient un pool de players et protege l'acces concurrent avec 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()
}

Animation du Presentation Layer

Core Animation est delicat. Chris a resolu cela en lisant depuis le presentation layer pour obtenir la vraie position de Tappy a l'ecran pendant les animations.

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 avec logique de retry

Les requetes reseau echouent. L'integration Game Center remasterisee utilise un backoff exponentiel avec un nombre maximum de retries.

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

Je l'ai rouvert

Janvier 2026. Depoussierage et tout repare.

Boucle de jeu unique

Quatre timers independants sont devenus un seul. Inspire de la philosophie de John Carmack - une boucle principale distribue a tous les sous-systemes.

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

Courbe de difficulte precalculee

La courbe de difficulte utilisait tanh() a chaque tick - des operations floating-point couteuses 20 fois par seconde. Maintenant c'est une simple consultation de tableau.

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]

Evitement d'allocation CGRect

Le mouvement de l'etoile creait un nouveau CGRect a chaque frame. Maintenant il modifie le frame existant sur place.

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

Mises a jour de labels avec dirty flag

Les labels etaient mis a jour a chaque tick meme quand les valeurs n'avaient pas change - 40 allocations de chaines par seconde gaspillees.

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

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

Extraction GameModel

Tous les etats de jeu mutables vivent maintenant dans une classe GameModel dediee. Le ViewController gere l'UI; le modele gere la logique.

Nouvelle version bientot sur l'App Store.

Chronologie

Aout 2016 - Chris et moi avons construit Tappy Color en Swift 2. Deux etudiants, un poisson-clown pixelise, publie sur l'App Store.

2017-2023 - L'app est restee intouchee. Swift a evolue. Le codebase est devenu archeologique.

Aout 2024 - Xcode 15 s'est ouvert avec 300+ erreurs de compilation. Tout migre vers Swift 5. UI remasterisee. Tappy Color Remastered publie sur l'App Store.

Janvier 2026 - Rouvert. GameModel extrait. 4 timers consolides en une boucle de jeu style Carmack. Courbe de difficulte precalculee. Dirty flags ajoutes. Code mort supprime.

Dix ans, trois versions, le meme poisson.

A propos de la musique et du son
La musique de fond a ete composee avec des samples dans GarageBand. Tous les effets sonores du jeu ont ete crees avec des generateurs de sons 8-bit en ligne.