barts.work
5balls — app screenshot
Work in progress ios

5balls

A calm, offline ball-line puzzle chasing Apple Design Award-level polish, down to the shaders and synthesized chimes.

5balls is my take on the old Lines / Color Lines puzzle: a 9×9 board (or 8×12 tall), seven ball colors, tap to select, tap an empty cell to move — but only along a path the board actually has room for, via honest BFS pathfinding. Line up five or more of one color and they clear in a shader-driven burst of light. Miss, and more balls spawn until the board fills. Three difficulty levels, one-step undo, persistent high scores per board and difficulty, and full state restoration so you can walk away mid-game and come back to the exact same board.

And most importantly — it was built for my wife.

Status

The app is done — built, tested, fully configured in App Store Connect, screenshots shot, copy written, build uploaded and selected for release. Then submission stalled on everything except code: Apple requires VAT-UE registration for a Polish developer selling a paid app intra-EU, and I haven’t finished that registration yet. And my only Mac runs beta Xcode on beta macOS, while the App Store wants builds from a release-candidate toolchain. So parked, not live — the game is stocked and priced; what’s missing is a tax form and an Xcode build that Apple will accept. I’ll get there.

How it was built

The brief I gave myself was blunt: Apple Design Award-level polish, 100% offline. No ads, no analytics, no accounts, no network calls of any kind. Every ball is a real Metal shader — subsurface gradient, specular highlight, soft contact shadow — not a gradient PNG. Every sound is synthesized at runtime with AVAudioEngine, not sampled. CoreHaptics answers every tap. The game logic itself lives in a separate Swift package, FiveBallsKit, with zero UI imports and 50 passing Swift Testing tests covering pathfinding, line detection, scoring, and the engine’s spawn/undo/game-over rules.

Lessons learned

  • Loop length is a math problem, not a taste choice. The ambient pad had an audible step at its loop seam. The cause: an 8-second loop over a detuned 110.7 Hz carrier is 885.6 cycles — the waveform never returns to zero at the boundary. A 10-second loop puts both oscillators on whole cycles (1100 and 1107) and the seam vanished. When you synthesize your own loops, the duration comes from the frequencies, not from a round number.
  • In SwiftUI, a timestamp you animate against must be @State. The clear-burst effect kept restarting mid-animation because its start time was a plain let — and the view struct gets rebuilt on every render pass, quietly resetting the clock. One property wrapper, a whole class of bugs gone.
  • Boundaries are where invariants go to die. The engine’s board subscripts deliberately don’t bounds-check; “UI guards first” was a written rule. Then switching board sizes left stale tap targets alive for one frame, a row-nine coordinate walked into an eight-row board, and the rule I wrote became the rule I broke. The real fix wasn’t adding the guard — it was accepting that the check belongs at the exact line where UI coordinates enter the model, not wherever I promised to remember it.