Tappy the clownfish
Menu Theme Made with GarageBand samples

Tappy Color

A tap-to-survive iOS game built with my friend Chris Greer in the summer of 2016. Remastered in 2024 after eight years in the drawer.

The Game

Tappy Color is a tap-to-survive game. The water level is constantly dropping, and if it falls too low, Tappy dies. Your goal is to keep him alive as long as possible.

Blue raises water
Red warning
Red drops water
Starfish
Invincible mode

Blue screen: Tap as fast as you can. Each tap raises the water level.

Red screen: Stop tapping! Tapping while red drops the water suddenly. This is the trap.

Starfish: Tap it for invincibility. While invincible, red won't appear—so tap freely.

The color changes get faster as your score increases. What starts as a reaction game becomes a test of impulse control.

We copyrighted it under "Bhris"—a portmanteau of our names. Chris built the game mechanics and core systems. I handled the design, sounds, and pixel art. We shipped it, got a few downloads from friends and family, and then life happened.

The project sat untouched for eight years. Then in July 2024, I decided to dust it off.

Who Built What

Tappy Color was a true collaboration. Chris and I worked side by side, pushing commits back and forth over that summer.

Chris Greer (2016)
Game Design & Engineering
  • Core game concept (tap-to-survive mechanic)
  • Pushed for a character players would care about
  • Tappy's swimming movement system
  • Difficulty curve using tanh() for smooth ramping
  • Game state machine and score tracking
  • Color transitions and timing mechanics
  • Hand-rolled GIF decoder for animations
Blake Crosley
Art, Sound & 2024 Remaster
  • Tappy pixel art and animations (2016)
  • Soundtrack composition (2016)
  • 8-bit sound effects (2016)
  • Tutorial and help screens (2016)
  • Swift 2 → Swift 5 migration (2024)
  • Modern AudioManager singleton (2024)
  • Game Center integration (2024)
  • iOS 16.4+ modernization (2024)

A quick note on "game design": in game dev, it means coming up with the core idea of what makes the game fun. "Game developer" means iterating on that seed and getting everything to work. It has nothing to do with visual design.

The first version was just a screen that turned green when you tapped and red when you failed. Chris kept pushing: "No, we need to make something people will care about, like a character. No one will care about this screen." At the time, I thought we weren't shipping fast enough.

But he was right. I poured into the pixel art, bringing Tappy to life frame by frame. I composed the music and sound effects. I designed the UX and tutorial flow. Chris built the movement system that makes Tappy swim around so naturally. Together, that's what made it fun.

Our mutual friend Dustin would play it over and over, and his kid loved it too. He wanted us to put more into it and actually complete it. Happy we finally did.

300 Compiler Errors

When I opened the project in Xcode 15, I was greeted with over 300 compiler errors. The original codebase was written in Swift 2.x—a version of Swift so old that most of its syntax no longer exists.

Every NSBundle became Bundle. Every NSURL became URL. Every CGAffineTransformMake* function was replaced with initializers. The migration wasn't just tedious—it was archaeological.

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)

Tappy's Swimming Animation

One of the things we're still proud of: Tappy actually swims around the screen. He picks a random destination, animates smoothly to that point over 3-6 seconds, then picks a new destination. He even flips horizontally when changing direction.

Chris wrote the original movement logic using Core Animation. The fish on this page uses the same approach, ported to JavaScript. Click him to visit the App Store.

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
}

Hand-Crafted GIF Decoder

In 2016, there wasn't a great Swift library for animated GIFs, so Chris wrote his own using ImageIO and Core Graphics. It parses the GIF frame-by-frame, extracts timing data from metadata, finds the greatest common divisor for optimal playback, and returns a proper UIImage animation.

It worked in Swift 2, and with minor syntax updates during the remaster, it still works today.

The 2024 Remaster

After migrating the Swift syntax, I tackled real improvements.

Modern Audio System

The original code had audio player instances scattered everywhere. I consolidated everything into a singleton AudioManager with proper AVAudioSession handling.

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

The original game had no leaderboards. The remaster has full Game Center support with global rankings.

iOS 16.4+ Target

No more supporting ancient devices. The remaster runs on modern iOS with all the latest APIs.

Under the Hood

Some code patterns that still hold up eight years later.

Smooth Difficulty Curve

The water drains faster as your score increases, but the difficulty doesn't just ramp linearly—that would feel punishing. Instead, Chris used a hyperbolic tangent function to create a smooth S-curve: gentle at first, steep in the middle, then plateaus at a ceiling. It feels fair even when it's hard.

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-Safe Sound Effects

When you're tapping furiously, dozens of sound effects can trigger per second. The remastered AudioManager keeps a pool of players and protects concurrent access with a lock—no audio glitches, no crashes.

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 is tricky: when you animate a layer's position, the position property jumps to the final value immediately—only the visual representation animates. Chris solved this by reading from the presentation layer to get Tappy's true on-screen position during 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 with Retry Logic

Network requests fail. The remastered Game Center integration uses exponential backoff with a maximum retry count—scores eventually sync even on flaky connections.

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

I Opened It Again

January 2026. I dusted it off and fixed everything.

Single Game Loop

Four independent timers became one. Inspired by John Carmack's game engine philosophy—one master loop dispatches to all subsystems:

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

Pre-Computed Difficulty Curve

The difficulty curve used tanh() every tick—expensive floating-point math 20 times per second. Now it's a single 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 Allocation Avoidance

The star movement was creating a new CGRect every frame. Now it modifies the existing frame in 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

Dirty-Flag Label Updates

Labels were updating every tick even when values hadn't changed—40 string allocations per second for nothing:

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

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

GameModel Extraction

All mutable game state now lives in a dedicated GameModel class. The ViewController handles UI; the model handles logic. Score, round life, card state, invincibility counters—all extracted and testable. Magic numbers documented in GameConstants.swift. Force unwraps replaced with safe unwrapping. Dead code removed.

New version hitting the App Store soon.

The Timeline

August 2016 — Chris and I build Tappy Color in Swift 2. Two college kids, one pixelated clownfish, shipped to the App Store.

2017–2023 — The app sits untouched. Swift evolves. The codebase becomes archaeological.

August 2024 — I open Xcode 15 to 300+ compiler errors. Migrate everything to Swift 5. Remaster the UI. Ship Tappy Color Remastered to the App Store.

January 2026 — I open it again. Extract GameModel. Consolidate four timers into one Carmack-style game loop. Pre-compute the difficulty curve. Add dirty flags. Remove dead code. Fix every issue I said I would.

Ten years, three versions, same fish.

About the Music & Sound
The background music was composed using samples in GarageBand. All game sound effects were created using 8-bit sound generators online. Use the player in the corner to switch between tracks from the game.