Building Nubrixy: Designing, Architecting, and Shipping a Block Puzzle Game

How I took a web prototype and turned it into a native iOS game — and the architecture decisions that made it survive feature creep.

Starting with a prototype

Nubrixy began life as a web prototype. The core loop was simple and old as time: you're given shapes, you drag them onto an 8×8 grid, and you clear full rows or columns to score. If you can't fit any of your current shapes, the game's over.

The web version proved the loop was fun. But "fun in a browser" and "a game people keep on their home screen" are different bars. The prototype had no juice, no progression, no reason to come back tomorrow. Porting to iOS wasn't just a technology change — it was the moment to ask what the game actually wanted to be.

The architecture decision that shaped everything: SwiftUI + SpriteKit

The first real call was how to render the game. iOS gives you a few options, and the obvious pure-SwiftUI route felt wrong almost immediately.

The grid, the dragging, the particle effects when a line clears, the little "+500" score floaters, the shake when you place a hard piece — all of that is game feel, and SwiftUI fights you on game feel. It's a layout engine, not a game engine. You can technically animate a grid of Rectangles, but the moment you want a satisfying tile-shatter on a board clear, you're swimming upstream.

So I went hybrid:

The two talk through a thin shared object. A GameState (ObservableObject) holds the values both worlds care about — current score, high score, whether the game's over — and SpriteKit writes to it while SwiftUI reads from it via @Published properties. The SwiftUI side hosts the scene with a SpriteView and layers its overlays on top with a ZStack.

This split paid for itself over and over. When I redesigned the home screen, I never touched game code. When I tuned the line-clear animation, I never touched a single view. The boundary between "game" and "app" stayed clean because the two halves were built in the tools that suit them.

Scoring: one source of truth, many frames around it

The single most important architectural principle in the whole game turned out to be this: there is exactly one scoring system, and everything else is a frame around it.

That sounds obvious, but it's the thing that let me add entire game modes without rewriting the core. The scoring lives in GameLogic, which is deliberately mode-agnostic. It knows about:

The crucial discipline: GameLogic doesn't know what mode you're playing. It just knows the rules. Endless mode, the timed Blitz modes, the hand-authored puzzle packs — they all run the exact same scoring path. A 25,000-point run scores identically no matter which mode produced it.

This is why I could later add a timed survival mode in a single new file without touching the scoring at all. Which brings me to the part of the project I'm proudest of.

Blitz mode: adding a whole game mode without breaking anything

The web prototype only had endless play. For the iOS version I wanted a second pillar — something with urgency, something you could play in 60 seconds while waiting for a coffee.

Blitz is a timed survival mode. A clock drains continuously while you play; clearing lines pushes time back onto the clock. Run out of time — or run out of moves — and it's over. There are three sub-modes: a 60-second sprint, a brutal 30-second variant, and a daily challenge that competes on a global leaderboard.

The design contract I wrote before writing any code was a single sentence: Blitz must have score parity with Endless. The same shape placed in the same situation must produce the same points in either mode. Blitz adds a clock around the loop; it changes nothing inside it.

Holding that line made the implementation almost suspiciously clean. The whole mode lives in one file. It wraps the same GameScene the endless mode uses, flips a single isBlitzMode flag on the game logic, and layers its own timer HUD and game-over ceremony on top in SwiftUI. The flag gates exactly three things — it suppresses the endless high-score save (Blitz scores go to their own per-mode leaderboards), it hides a couple of UI elements that don't apply, and it disables one bonus-life mechanic that would break the timed format. Everything else — shape generation, scoring, line clears, streaks, sparks — runs unchanged.

The lesson: the cost of a new feature is set by how cleanly your core was separated from your modes, long before you write the feature. I got Blitz nearly for free because I'd refused to let mode-specific logic leak into the scoring system months earlier.

The shape system: data, not code

Every shape in the game is just data — a 16-element array describing a 4×4 grid, plus a color and a point value:

static let tLong = ShapeDefinition(
    name: "tLong",
    def: [1, 1, 1, 0, 0, 1, 0, 0, 0, 1, 0, 0, 0, 0, 0, 0],
    color: .coral,
    points: 150
)

            

Shapes are bucketed into difficulty pools — easy, medium, hard — and the random selector weights them by how far into a run you are. New players see almost entirely easy shapes for their first several placements; once they've found their footing, the harder pieces start showing up.

Because shapes are pure data, adding one is a five-minute job. I recently added a rare "long T" that only appears about 2% of the time at full difficulty, with its own pool so I can tune its rarity independently. No new rendering code, no new rotation logic — the existing systems operate on the data array, so a new array just works.

Puzzle Packs: the same engine, authored by hand

Alongside the random modes, Nubrixy has hand-crafted puzzle packs — themed sets of levels with specific objectives ("clear 8 lines", "reach this score in 12 moves"). These reuse the same grid, the same scoring, the same everything — but with a scripted sequence of shapes and a pre-filled board, defined in JSON.

This is the data-driven principle taken to its conclusion. A puzzle level is a JSON object. Designing levels doesn't require writing Swift; it requires editing data. That meant I could iterate on difficulty, fix unsolvable levels, and tune star thresholds without recompiling — and it opened the door to building a separate authoring tool down the line.

It also caught me out a few times. Scripted puzzles are mathematically constrainable in ways random play isn't — I shipped a couple of levels that were literally impossible to solve because the shapes I'd scripted couldn't physically produce the line clears the objective demanded. Lesson learned: when you hand-author content, you need to verify solvability, not just vibe-check it.

Persistence and the bug that taught me about race conditions

Nubrixy saves your in-progress game after every move so you can close the app mid-run and pick up where you left off. The snapshot is a Codable struct written to UserDefaults on a background queue, debounced so rapid placements don't thrash the disk.

This system shipped with a subtle, nasty bug that I think is worth sharing because it's the kind of thing that only shows up in the wild.

Here's the sequence: you place your final shape. The board is now dead — no move will fit. Two things need to happen: the snapshot needs to save (which fires on placement), and the game-over flow needs to run (which clears the snapshot). If you background the app in the narrow window between those two events, the OS can suspend your process before the clear runs. Now there's a saved snapshot on disk representing a dead board.

Re-open the app, tap resume, and you're dropped onto a board where nothing fits — with no game-over screen, because the restore code faithfully rebuilt the dead state without ever asking "wait, is this position actually game-over?" The player is stuck. Their only escape is deleting the app.

The fix was three lines: after restoring a snapshot, re-run the game-over check. If the restored board is dead, fire the same game-over flow that live gameplay uses. It self-heals — even if the original race wins, the next resume cleans it up.

The deeper lesson: restore paths need to re-validate state, not just rebuild it. Saved data represents the world at a moment in time, and that moment might have been a bad one to freeze. Don't trust your snapshot to be in a playable state just because it was once a real state.

Game Center: the entitlement maze

Adding global leaderboards and achievements via Game Center was, on paper, simple: wire up a service object, submit scores on game-over, report achievements as they unlock. The actual code was maybe a day's work, and I built it to degrade gracefully — every call is a no-op if the player isn't signed in, so the game stays fully playable for people who never touch Game Center.

The painful part wasn't the code. It was the three-layer configuration chain that has to agree before any of it works: the entitlement in your app, the capability in App Store Connect, and the capability on your App ID in the Apple Developer Portal — a separate website that's easy to forget. Get any one of those out of sync and your dev build works perfectly while your release build crashes on launch, because the Distribution-signed binary enforces entitlements strictly where the Development one doesn't.

A few things I'd tell my past self about Game Center:

Achievements: a drift problem hiding in plain sight

One quietly important design choice: local state is the source of truth, and Game Center is a mirror we keep trying to sync up.

The naive approach is to report an achievement to Game Center the moment it unlocks. But what if the player unlocked it while signed out? Or before you'd finished configuring it on Apple's side? That achievement is now unlocked locally and invisible on Game Center, forever, because your code only reports things at the moment they newly unlock.

The fix is to stop thinking in terms of moments and start thinking in terms of reconciliation. On every game-over, and again whenever the player signs in, I re-report the entire set of locally-unlocked achievements. Game Center deduplicates on its end, so it's harmless to re-send things it already knows. Drift becomes impossible — any gap between local and remote heals on the next run.

What I'd tell someone starting a similar project

A few things crystallized over this build:

  1. Pick the right tool for each layer. SwiftUI for chrome, a real game engine for game feel. Forcing one tool to do both jobs is how you end up fighting your framework.
  2. Define your invariants in one sentence before you write code. "Blitz has score parity with Endless" did more architectural work than any class diagram. When a one-line contract is clear, the code that honors it tends to fall out naturally.
  3. Make content data, not code. Shapes, puzzle levels, difficulty curves — anything you'll want to tune should be editable without recompiling. Your future self iterating on game balance will thank you.
  4. Keep your core mode-agnostic. The cost of your fifth feature is determined by how disciplined you were about separation during your first. Every mode-specific if you let leak into core logic is a tax you'll pay forever.
  5. Restore paths must re-validate, not just rebuild. Saved state is a photograph of a moment, and the moment might have been a bad one.
  6. Degrade gracefully everywhere. No Game Center account? Game still works. Offline? Scores save locally and sync later. Corrupt save file? Fall back to a fresh game, never crash. The player should never hit a dead end your code created.

Nubrixy is a small game. But building it taught me that the difference between a prototype and a shippable product is almost entirely about the boundaries you draw — between game and app, between core and mode, between data and code, between the state you saved and the state you trust.