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