Tappy Color
Ein Tap-to-Survive iOS-Spiel, das ich im Sommer 2016 mit meinem Freund Chris Greer gebaut habe. Nach acht Jahren in der Schublade 2024 ueberarbeitet.
Das Spiel
Tappy Color ist ein Tap-to-Survive-Spiel. Der Wasserstand sinkt staendig, und wenn er zu niedrig wird, stirbt Tappy. Dein Ziel ist es, ihn so lange wie moeglich am Leben zu halten.
Blauer Bildschirm: Tippe so schnell wie moeglich. Jedes Tippen erhoeht den Wasserstand.
Roter Bildschirm: Hoer auf zu tippen! Tippen waehrend Rot laesst das Wasser ploetzlich fallen. Das ist die Falle.
Seestern: Tippe ihn fuer Unverwundbarkeit. Waehrend unverwundbar erscheint kein Rot - also tippe frei.
Die Farbwechsel werden schneller, wenn der Score steigt. Was als Reaktionsspiel beginnt, wird zu einem Test der Impulskontrolle.
Wir registrierten das Copyright unter "Bhris" - ein Portmanteau unserer Namen. Chris baute die Spielmechanik und Kernsysteme.
Das Projekt lag acht Jahre lang unberuehrt. Dann im Juli 2024 beschloss ich, es hervorzuholen.
Wer was gebaut hat
Tappy Color war eine echte Zusammenarbeit. Chris und ich arbeiteten den ganzen Sommer Seite an Seite und tauschten Commits aus.
- Kern-Spielkonzept (Tap-to-Survive-Mechanik)
- Trieb einen Charakter voran, der den Spielern wichtig sein wuerde
- Tappys Schwimmbewegungssystem
- Schwierigkeitskurve mit tanh() fuer sanfte Steigerung
- Spielzustandsmaschine und Punkteverfolgung
- Farbuebergaenge und Timing-Mechaniken
- Handgeschriebener GIF-Decoder fuer Animationen
- Tappy Pixelkunst und Animationen (2016)
- Soundtrack-Komposition (2016)
- 8-Bit Soundeffekte (2016)
- Tutorial und Hilfebildschirme (2016)
- Swift 2 zu Swift 5 Migration (2024)
- Moderner AudioManager-Singleton (2024)
- Game Center Integration (2024)
- iOS 16.4+ Modernisierung (2024)
Eine kurze Anmerkung zum "Spieldesign": In der Spielentwicklung bedeutet es, die Kernidee zu entwickeln, was das Spiel unterhaltsam macht.
Die erste Version war nur ein Bildschirm, der beim Tippen gruen und bei Versagen rot wurde. Chris sagte immer: "Nein, wir muessen etwas machen, das den Leuten wichtig ist, wie einen Charakter."
Aber er hatte recht. Ich vertiefte mich in die Pixelkunst und erweckte Tappy Frame fuer Frame zum Leben.
Unser gemeinsamer Freund Dustin spielte es immer wieder, und sein Kind liebte es auch.
300 Compiler-Fehler
Als ich das Projekt in Xcode 15 oeffnete, wurde ich mit ueber 300 Compiler-Fehlern begruesst. Die urspruengliche Codebasis wurde in Swift 2.x geschrieben.
Jedes NSBundle wurde zu Bundle. Jede NSURL wurde zu URL. Die Migration war nicht nur muehsam - sie war archaeologisch.
// 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)
Tappys Schwimmanimation
Eine Sache, auf die wir immer noch stolz sind: Tappy schwimmt tatsaechlich ueber den Bildschirm.
Chris schrieb die urspruengliche Bewegungslogik mit Core Animation. Der Fisch auf dieser Seite verwendet denselben Ansatz, portiert auf 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
}
Handgefertigter GIF-Decoder
2016 gab es keine gute Swift-Bibliothek fuer animierte GIFs, also schrieb Chris seine eigene mit ImageIO und Core Graphics.
Es funktionierte in Swift 2, und mit geringfuegigen Syntaxaktualisierungen waehrend des Remasters funktioniert es noch heute.
Das 2024 Remaster
Nach der Migration der Swift-Syntax habe ich echte Verbesserungen in Angriff genommen.
Modernes Audiosystem
Der urspruengliche Code hatte Audioplayer-Instanzen ueberall verstreut. Ich habe alles in einen Singleton AudioManager mit korrekter AVAudioSession-Handhabung konsolidiert.
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()
}
}
}
Game Center Integration
Das Originalspiel hatte keine Bestenlisten. Das Remaster hat volle Game Center-Unterstuetzung mit globalen Rankings.
iOS 16.4+ Ziel
Keine Unterstuetzung mehr fuer alte Geraete. Das Remaster laeuft auf modernem iOS mit allen neuesten APIs.
Unter der Haube
Einige Code-Muster, die auch nach acht Jahren noch gelten.
Sanfte Schwierigkeitskurve
Das Wasser laeuft schneller ab, wenn der Score steigt, aber die Schwierigkeit steigt nicht linear an. Chris verwendete eine Hyperbeltangensfunktion um eine sanfte S-Kurve zu erstellen.
// 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))))
}
Thread-sichere Soundeffekte
Wenn man wuetend tippt, koennen Dutzende von Soundeffekten pro Sekunde ausgeloest werden. Der ueberarbeitete AudioManager haelt einen Player-Pool und schuetzt gleichzeitigen Zugriff mit einem 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()
}
Presentation Layer Animation
Core Animation ist knifflig. Chris loeste dies, indem er vom Presentation Layer las, um Tappys wahre Bildschirmposition waehrend Animationen zu erhalten.
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 mit Retry-Logik
Netzwerkanfragen schlagen fehl. Die ueberarbeitete Game Center-Integration verwendet exponentielles Backoff mit maximaler Wiederholungsanzahl.
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))
}
}
}
Ich habe es wieder geoeffnet
Januar 2026. Staub abgewischt und alles repariert.
Einzelner Spiel-Loop
Vier unabhaengige Timer wurden zu einem. Inspiriert von John Carmacks Spiel-Engine-Philosophie - eine Master-Schleife verteilt an alle Subsysteme.
@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()
}
Vorberechnete Schwierigkeitskurve
Die Schwierigkeitskurve verwendete jeden Tick tanh() - teure Fliesskommaoperationen 20 mal pro Sekunde. Jetzt ist es ein einfacher Array-Lookup.
// 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]
CGRect-Allokationsvermeidung
Die Sternbewegung erstellte jeden Frame ein neues CGRect. Jetzt wird der bestehende Frame direkt modifiziert.
// 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
Dirty-Flag Label-Updates
Labels wurden jeden Tick aktualisiert, auch wenn sich die Werte nicht geaendert hatten - 40 String-Allokationen pro Sekunde verschwendet.
private var lastDisplayedScore: Int = -1
func updateLabels() {
if game.score != lastDisplayedScore {
scoreLabel.text = "\(game.score)"
lastDisplayedScore = game.score
}
}
GameModel-Extraktion
Alle veraenderlichen Spielzustaende befinden sich jetzt in einer dedizierten GameModel-Klasse. Der ViewController behandelt UI; das Model behandelt Logik.
Neue Version kommt bald in den App Store.
Zeitstrahl
August 2016 - Chris und ich bauten Tappy Color in Swift 2. Zwei College-Studenten, ein pixeliger Clownfisch, im App Store veroeffentlicht.
2017-2023 - Die App lag unberuehrt. Swift entwickelte sich weiter. Die Codebasis wurde archaeologisch.
August 2024 - Xcode 15 oeffnete sich mit 300+ Compiler-Fehlern. Alles auf Swift 5 migriert. UI ueberarbeitet. Tappy Color Remastered im App Store veroeffentlicht.
Januar 2026 - Wieder geoeffnet. GameModel extrahiert. 4 Timer zu einem Carmack-Stil Spiel-Loop konsolidiert. Schwierigkeitskurve vorberechnet. Dirty-Flags hinzugefuegt. Toten Code entfernt.
Zehn Jahre, drei Versionen, derselbe Fisch.