Tappy the clownfish
Menu Theme Made with GarageBand samples

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.

Blue raises water
Red warning
Red drops water
Starfish
Invincible mode

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.

Chris Greer (2016)
Spieldesign & Engineering
  • 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
Blake Crosley
Kunst, Sound & 2024 Remaster
  • 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.

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)

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.

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
}

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.

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

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.

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

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.

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

Presentation Layer Animation

Core Animation ist knifflig. Chris loeste dies, indem er vom Presentation Layer las, um Tappys wahre Bildschirmposition waehrend Animationen zu erhalten.

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 mit Retry-Logik

Netzwerkanfragen schlagen fehl. Die ueberarbeitete Game Center-Integration verwendet exponentielles Backoff mit maximaler Wiederholungsanzahl.

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

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.

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

Vorberechnete Schwierigkeitskurve

Die Schwierigkeitskurve verwendete jeden Tick tanh() - teure Fliesskommaoperationen 20 mal pro Sekunde. Jetzt ist es ein einfacher Array-Lookup.

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]

CGRect-Allokationsvermeidung

Die Sternbewegung erstellte jeden Frame ein neues CGRect. Jetzt wird der bestehende Frame direkt modifiziert.

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

Dirty-Flag Label-Updates

Labels wurden jeden Tick aktualisiert, auch wenn sich die Werte nicht geaendert hatten - 40 String-Allokationen pro Sekunde verschwendet.

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

Ueber die Musik und den Sound
Die Hintergrundmusik wurde mit Samples in GarageBand komponiert. Alle Spielsoundeffekte wurden mit Online-8-Bit-Soundgeneratoren erstellt.