A block-placement puzzle game for iPhone and iPad. Drag pieces onto an 8×8 board, fill rows and columns to clear them, and chain clears into combos. It's built with SwiftUI, the Observation framework, SwiftData and Swift Charts, and has no third-party dependencies.
The name, visuals and sounds are all original. Blocks are drawn in code and sound effects are synthesized at runtime, so the app has no image or audio assets.
- Features
- How to play
- Requirements
- Getting started
- Project structure
- Architecture
- Game rules in detail
- Visual effects
- Accessibility
- Devices and layout
- Testing
- Debug demo scenarios
- Adding a new piece shape
- Performance notes
- Classic 8×8 block puzzle. Three pieces per round, no rotation, and rows and columns clear at the same time.
- Fair dealing. Each new set of three contains at least one piece that fits whenever any piece can fit. Difficulty rises gently as your score grows.
- Combos and bonuses. Consecutive clears multiply your points, and emptying the board earns a +300 perfect-clear bonus.
- Responsive drag and drop. The piece lifts above your finger, snaps to the grid with a ghost preview, and highlights the lines it will clear.
- Polished effects. Placement pop, clears that sweep outward into particles, bouncing combo text, rolling score digits, and confetti for a new best.
- Haptics and sound. Distinct feedback for pickup, placement, clears, combos and invalid drops. Sound follows the silent switch.
- Persistence. Unfinished games auto-save when the app goes to the background. Best score, settings and the history of finished games are stored in SwiftData.
- Stats. Games played, best and average score, total lines, longest combo, and a chart of your last 20 scores.
- Themes. Dark by default, with System and Light options and three block palettes, including High Contrast.
- Accessible. Dynamic Type, VoiceOver, Reduce Motion and a high-contrast palette are all supported.
- Drag any of the three pieces from the tray onto the board. Pieces can't be rotated.
- Fill a whole row or column to clear it. Filling a row and a column with one piece clears both.
- After you place all three pieces, a new set is dealt.
- The game ends when none of your remaining pieces fits anywhere on the board.
Tap the pause button to resume, restart (with confirmation), return home, or toggle sound and haptics.
| Deployment target | iOS 17.0 (iPhone and iPad) |
| Xcode | 16 or later (the project uses synchronized folders) |
| Swift | 5.10+ (the project builds in Swift 5 language mode) |
| Dependencies | None |
The animated mesh-gradient background needs iOS 18. On iOS 17 the app falls back to a static navy-to-indigo gradient.
- Open
Blockfall.xcodeprojin Xcode. - Select the Blockfall scheme and any iPhone or iPad simulator.
- Press Run (⌘R).
To build and run the tests from the command line:
xcodebuild test -project Blockfall.xcodeproj -scheme Blockfall -destination 'platform=iOS Simulator,name=iPhone 17 Pro'Use any simulator name installed on your Mac.
The project builds with zero compiler warnings. To run on a physical device, set your development team under Signing & Capabilities and change the bundle identifier (com.example.blockfall) to one you own.
Blockfall/
├── App/
│ ├── BlockfallApp.swift App entry point, SwiftData container, root view, scene-phase auto-save
│ ├── AppModel.swift Navigation, profile/settings, recording games, saved-game handling
│ └── DemoScenario.swift Debug-only screenshot and test scenarios
├── Engine/ Pure game logic (Foundation only, no UI imports)
│ ├── Piece.swift Shape catalog defined as ASCII patterns
│ ├── Board.swift 8×8 grid, placement checks, line detection and clearing
│ ├── ScoreCalculator.swift Scoring and combo rules
│ ├── PieceGenerator.swift Seedable RNG and fair, difficulty-weighted dealing
│ └── GameState.swift Codable game state and move handling
├── Features/
│ ├── Home/ Animated title, best score, Play/Continue
│ ├── Game/ Game screen, board canvas, tray, drag and drop, floating text, pause sheet
│ ├── GameOver/ Result card with confetti
│ ├── Settings/ Sound, haptics, theme, palette, reset stats
│ └── Stats/ Stat cards and Swift Charts history
├── DesignSystem/
│ ├── Palette.swift Block palettes, theme colors, rounded fonts
│ ├── BlockRenderer.swift Glossy block drawing shared by board, tray and effects
│ ├── Components.swift Buttons, animated background, confetti, stat card
│ └── SoundEngine.swift AVAudioEngine synthesized sound effects
└── Persistence/
├── Models.swift SwiftData models: PlayerProfile, GameRecord
└── SavedGameStore.swift JSON save file for the in-progress game
BlockfallTests/
└── EngineTests.swift Swift Testing suites for the engine and saved games
Screenshots/ Images used in this README
Blockfall uses MVVM with a pure engine.
┌──────────────┐ gestures / intents ┌─────────────────┐ place(slot:row:col:) ┌─────────────┐
│ SwiftUI views│ ─────────────────────▶ │ GameViewModel │ ───────────────────────▶ │ GameState │
│ (Features) │ ◀───────────────────── │ (@Observable) │ ◀─────────────────────── │ (engine) │
└──────────────┘ observed properties └─────────────────┘ MoveOutcome └─────────────┘
│
▼
AppModel → SwiftData / SavedGameStore
- Engine (
Engine/). Value types with no UI imports.GameState.place(slot:row:col:)validates a move, updates the board, scores it, deals a new tray when needed, and returns aMoveOutcomedescribing everything that happened: cleared cells, points breakdown, combo, perfect clear and game over. The whole state isCodable, including the random number generator, so a restored game deals exactly the pieces it would have dealt. - View model (
GameViewModel). An@Observableclass that owns theGameState, drag state (dragged slot, translation, ghost position, highlighted lines), effect timestamps, floating text, and counters that drive.sensoryFeedback. Layout measurements such as the board frame are@ObservationIgnoredso geometry updates don't trigger re-renders. - App model (
AppModel). Handles navigation between Home and Game, thePlayerProfile(settings and best score), recording finished games, and saving and restoring the in-progress game. - Persistence. SwiftData stores the profile and one
GameRecordper finished game. The in-progress game is saved as JSON in Application Support, so it's easy to inspect and test.
The catalog has 41 fixed shapes. Every orientation is a separate entry, because pieces never rotate:
| Group | Shapes |
|---|---|
| Single and lines | 1×1; 1×2 through 1×5, horizontal and vertical |
| Squares and rectangles | 2×2, 3×3, 2×3, 3×2 |
| Small L/J (3 cells) | 4 orientations |
| L and J (4 cells) | 4 orientations each |
| T | 4 orientations |
| S and Z | Horizontal and vertical each |
| 3×3 corners (5 cells) | 4 orientations |
| Diagonals | 2-cell and 3-cell, both directions |
| Event | Points |
|---|---|
| Each cell placed | +1 |
| Line clear | 10 × lines² (1 line = 10, 2 = 40, 3 = 90, 4 = 160) |
| Combo multiplier | Clear points × (1 + combo × 0.5) |
| Perfect clear (board left empty) | +300 |
Combo rules:
- Every move that clears at least one line adds 1 to the combo counter.
- The multiplier uses the combo value from before the current move, so a first clear scores the base amount. "Combo xN" appears from the second clear in a streak onward.
- Three placements in a row without a clear reset the combo to 0. A clear within those three moves keeps the streak alive.
Example: two lines on the third clear of a streak score 10 × 2² × (1 + 2 × 0.5) = 80.
- Pieces that currently fit somewhere are weighted 3×, and pieces that don't fit are weighted 0.25×.
- Guarantee: if none of the three dealt pieces fits but any piece in the catalog does, one slot is replaced with a fitting piece.
- Difficulty: as the score rises toward 6,000, small pieces become slightly less likely and large or awkward pieces (5-cell shapes, 3×3 squares, 3-cell diagonals) become more likely.
- The generator uses a seedable SplitMix64 RNG, so tests are reproducible.
When none of the remaining tray pieces fits, the game shows a short "No more space" banner, records the result, and then shows the Game Over card. If you beat your previous best, the card adds a New Best! badge and confetti.
These frames come from a screen recording of a single move. Dropping a 1×2 piece completes the bottom row and the right column together, which leaves the board empty:
- Placement pop: a damped spring scale on the newly placed cells.
- Line clear: each cell's delay depends on its distance from the center of its line. Cells shrink and fade, then break into particles in their own color.
- Floating text: "+score", "Combo xN", "Great!" (2 lines), "Excellent!" (3+ lines), "Perfect!" and "New Best!". Combo text scales, bounces and wobbles using
KeyframeAnimator. Text size scales with the board, adjusted by the user's Dynamic Type setting within a capped range. - Score: the score rolls up digit by digit with
.contentTransition(.numericText()). - Tray: each new set of pieces springs in one after another.
- Background: a slowly drifting
MeshGradienton iOS 18+.
| Event | Haptic | Sound |
|---|---|---|
| Pick up a piece | Light impact | Short rising blip |
| Place a piece | Medium impact | Soft thud |
| Clear lines | Rigid impact | Three-note arpeggio |
| Combo | Success + heavy impact | Faster four-note arpeggio |
| Invalid drop / game over | Error | Low double tone / descending phrase |
Sound effects are synthesized with AVAudioEngine when the app launches. The audio session uses the .ambient category, so the silent switch mutes the game and it doesn't interrupt music. Sound and haptics can be turned off in Settings or from the pause sheet.
- Dynamic Type: all UI text uses SF Pro Rounded text styles and scales with the user's setting. At the largest accessibility sizes, layouts adapt: buttons and stat cards stack vertically, long screens scroll, and the palette swatches move below their names. The in-game top bar and the logo stop growing at a fixed size so they don't wrap or clip.
- VoiceOver: each board cell has a label like "Row 3, column 5, empty" or "Row 8, column 2, filled, blue". Tray pieces describe their color, size and whether they fit. A double-tap places a piece in the first open spot, so the game is playable without dragging.
- Reduce Motion: turns off the placement pop, particles, floating-text bounce and rise, title animation, mesh drift and confetti. Clears use a simple fade instead.
- High Contrast palette: strongly separated hues and brightness levels, selectable in Settings.
The board is sized from the available space: up to 520pt on iPhone and 760pt on iPad, centered, with the tray scaled to match. iPhone is portrait only, while iPad supports all orientations.
| iPhone SE (iOS 18) | iPad Pro 13" | iPad mini |
|---|---|---|
![]() |
![]() |
![]() |
The engine is covered by Swift Testing (@Test) suites in BlockfallTests/EngineTests.swift:
| Suite | What it checks |
|---|---|
| Catalog | Unique ids and shapes, normalized cell offsets, dimensions |
| Placement | Valid and invalid positions at every edge and corner, overlaps, diagonal gaps, fitsAnywhere |
| Clearing | Single rows, simultaneous row + column crosses, multiple rows and columns at once, and a 500-board randomized check that the drag highlight matches the actual clear |
| Scoring | The 10 × lines² formula, combo multipliers, the combo reset after three placements without a clear, the perfect-clear bonus |
| Game state | New tray after all three pieces are placed, game-over detection, game over reported by a move |
| Generator | Same seed gives the same deals; at least one piece fits in each of 10,000 seeded deals on random boards; difficulty weighting; colors and unique ids |
| Saved games | Encoding and decoding round-trips, including the RNG continuing identically; corrupt boards are rejected; the store saves, loads and clears, including in a directory whose name contains spaces |
Drag and drop, line clears and the effects above were also tested by hand in the iOS Simulator.
Debug builds accept a -demo launch argument that sets up a specific scene. Set it under Edit Scheme › Run › Arguments, or pass it with xcrun simctl launch:
| Argument | Scene |
|---|---|
-demo combo |
A board mid-combo that automatically plays a two-row clear ("Combo x3", "Great!") |
-demo gameover |
A full board with no moves left, ending on a New Best with confetti |
-demo clear |
A board one move from a row-and-column perfect clear, for testing by hand |
Demo games never touch your real data: they don't record Stats, change the best score, or overwrite a saved game. They're compiled out of Release builds.
Add one line to Piece.catalog in Blockfall/Engine/Piece.swift:
Piece(id: "plus", pattern: [".X.", "XXX", ".X."]),Xmarks a filled cell and.an empty one. The shape is normalized automatically, so leading empty rows or columns don't matter.- Pieces never rotate. Add each orientation you want as its own entry with a unique
id. - The difficulty tier comes from the cell count: 3 or fewer cells → tier 0, 4 → tier 1, 5 or more → tier 2. Pass
tier:to override it for awkward shapes, as the diagonals do. - Run the tests. The catalog suite checks that ids and shapes are unique, and the generator suite re-checks the fairness guarantee with the new shape included.
- Saved games store full piece data, so existing saves keep working.
- The board is drawn in a single
Canvas. ATimelineViewdrives redraws only while a placement or clear effect is running, so the board is static the rest of the time. - Drag updates don't allocate memory. Placement checks use index math on a flat 64-cell array, and line highlights use row and column bitmasks (
LineMask). - Game-over detection is O(pieces × 64 × cells).











