Create a Tetromino Puzzle Game Using Swift – Final Steps

Share this article

I hope you have enjoyed the tutorial so far. In this final part we add some final elements to gameplay to create a final, polished game. Let’s get started!

Rotation

Similar to the move method, we prepared the rotation behavior with the Tetromino class’ rotate method:

func rotate(rotation: Rotation = .Counterclockwise) {
   switch rotation {
   case .Counterclockwise:
       let bitmapSet = bitmaps[shape]!
       if rotationalState + 1 == bitmapSet.count {
           rotationalState = 0
       } else {
           ++rotationalState
       }
   case .Clockwise:
       let bitmapSet = bitmaps[shape]!
       if (rotationalState == 0) {
           rotationalState = bitmapSet.count - 1
       } else {
           --rotationalState
       }
   }
}

Now we need to call this method from the GameScene class. Let’s implement this now.

  1. Add a new method named rotateTetromino after the moveTetrominoTo method.

    func moveTetrominoTo(direction: Direction) {...}
    
    func rotateTetromino() {
        activeTetromino.rotate()
    
        if collidedWith(.None) {
            activeTetromino.rotate(rotation: .Clockwise)
        } else {
            refresh()
        }
    }

    We immediately rotate the active tetromino and check for collisions in its stationary position. If a collision is detected, we immediately rotate the brick in the opposite direction back to its previous state. If there is no collision, we refresh the game scene.

  2. Like lateral movement, we need more touch event handling to perform the rotation on our bricks. The straightforward way to do this is to track touches on the game board itself. Let’s do this now.

Add a third condition in the touchesBegan event handler to check for touches inside the game board then perform the rotation.

if location.x < gameBoardFrame.origin.x {
    moveTetrominoTo(.Left)
} else if location.x > gameBoardFrame.origin.x + gameBoardFrame.width {
    moveTetrominoTo(.Right)
} else if CGRectContainsPoint(gameBoardFrame, location) {
    rotateTetromino()
}

Save then run (⌘ + R) our game. The game now looks and feels like the real thing. If you tried playing the game in its current state and attempted to clear lines, you will be disappointed to find out that this one key rule is still missing. Don’t worry if you’re not familiar with this rule. I will discuss it briefly in the next section followed by its implementation.

Clearing Lines

Clearing lines is the primary goal in our game. To clear a line, you must fill all the spaces of a row with blocks while avoiding creating gaps. The line that was created is removed or cleared from the game. Line clearing happens after a brick lands and is primarily how you score points. Let’s implement this rule now.

  1. Add a new method named clearLines after the collidedWith method.

    func collidedWith(direction: Direction) -> Bool {...}
    
    func clearLines() {
        var linesToClear = [Int]()
        for row in 0..<gameBitmapDynamic.count - 1 {
            var isLine = true
            for col in 0..<gameBitmapDynamic[0].count {
                if gameBitmapDynamic[row][col] == 0 {
                    isLine = false
                }
            }
    
            if isLine {
                linesToClear.append(row)
            }
        }
    
        if linesToClear.count > 0 {
            for line in linesToClear {
                gameBitmapDynamic.removeAtIndex(line)
                gameBitmapDynamic.insert([8, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 8], atIndex: 1)
            }
        }
    }

We loop through all the rows in the game bitmap (except the floor) that contains the active tetromino and check for zero values. A zero value means there is no block in that particular location. If there is at least one zero value then we can safely assume that this particular row doesn’t have a complete line. We skip this row and move on to the next until we find a complete line.

We add the particular row numbers where we found the complete lines to an array named linesToClear. We then loop through each value contained in this variable, deleting each row while adding an empty line on top to preserve the game board’s size.

  1. Let’s invoke this new method in our moveTetrominoTo method in the section where we handle the landing:

    if direction == .Down {
        clearLines()
    
        gameBitmapStatic.removeAll(keepCapacity: true)
        gameBitmapStatic = gameBitmapDynamic
    
        ...
    }

Save and run (⌘ + R) the game. You can temporarily adjust the drop speed by decreasing the value of the defaultSpeed variable if you don’t want to wait too long for the bricks to land. Or we can just add an instant drop feature. That’s our goal next.

Instadrop

An instadrop feature makes the gameplay faster and more exciting but also increases the risk of blundering. Let’s implement this feature.

  1. Create a new method named, you guessed it right, instaDrop. You may add it after our clearLines method.

    func clearLines() {...}
    
    func instaDrop() {
        while collidedWith(.Down) == false {
            activeTetromino.moveTo(.Down)
            updateGameBitmap()
    
        }
        clearLines()
    
        gameBitmapStatic.removeAll(keepCapacity: true)
        gameBitmapStatic = gameBitmapDynamic
    
        activeTetromino = Tetromino()
        centerActiveTetromino()
    
        refresh()
        lastUpdate = NSDate()
    }

We continuously move the active tetromino down as long as there are no obstacles in the way. We use the collidedWith method to check for collisions. We also update the game bitmap to reflect the new positions of the active tetromino.

After the loop, we perform the same landing procedures we have in moveTetrominoTo after the brick has landed. For convenience, we can put this in a separate method to reduce duplication of code. Let’s do this now. Create a function named didLand and update its body using the duplicated piece of code.

func instaDrop() {...}

func didLand() {
    clearLines()

    gameBitmapStatic.removeAll(keepCapacity: true)
    gameBitmapStatic = gameBitmapDynamic

    activeTetromino = Tetromino()
    centerActiveTetromino()

    refresh()
    lastUpdate = NSDate()
}

Now update both instaDrop and moveTetrominoTo to reuse this common function.

func moveTetrominoTo(direction: Direction) {
    if collidedWith(direction) == false {
        ...
    } else {
        if direction == .Down {
            didLand()
            return
        }
    }

    refresh()
}

...

func instaDrop() {
    while collidedWith(.Down) == false {...}
    didLand()
}
  1. Let’s implement another touch event handler for this behavior. Touching at the bottom area of the game board would be the obvious choice but the player should be careful not to inadvertently touch it.

Add a fourth condition in the touchesBegan event handler to check for touches under the game board then perform the drop.

if location.x < gameBoardFrame.origin.x {
    moveTetrominoTo(.Left)
} else if location.x > gameBoardFrame.origin.x + gameBoardFrame.width {
    moveTetrominoTo(.Right)
} else if CGRectContainsPoint(gameBoardFrame, location) {
    rotateTetromino()
} else if location.y < gameBoardFrame.origin.y {
    instaDrop()
}

Save and run (⌘ + R) our game. Now you don’t have to wait too long for each brick to land, you can force it. This is pretty much all the key features of our final game. You can already play and enjoy this version. But there are still a couple of important elements that are missing. Let’s add them now.

Next Brick

Our game, as simple as it is, still involves a lot of strategy and quick thinking especially on the higher levels. Aside from deciding where and in what configuration your bricks should fall, you can carefully plan your moves ahead by looking at the next available brick.

  1. Let’s begin by adding the two values that will hold the visual and structural representations of the queued up tetromino.

    ...
    
    class GameScene: SKScene {
        let gameBoard = SKSpriteNode()
        let nextTetrominoDisplay = SKSpriteNode()
    
        var activeTetromino = Tetromino()
        var nextTetromino = Tetromino()
    
        ...
    }
  2. Create a new method named showNextTetromino in GameScene.swift.

    func showNextTetromino() {
        nextTetrominoDisplay.removeAllChildren()
    
        for row in 0..<nextTetromino.bitmap.count {
            for col in 0..<nextTetromino.bitmap[row].count {
                if nextTetromino.bitmap[row][col] > 0 {
                    let bit = nextTetromino.bitmap[row][col]
                    let square = SKSpriteNode(color: colors[bit], size: CGSize(width: blockSize, height: blockSize))
                    square.anchorPoint = CGPoint(x: 0, y: 1.0)
                    square.position = CGPoint(x: col * Int(blockSize) + col, y: -row * Int(blockSize) + -row)
                    nextTetrominoDisplay.addChild(square)
                }
            }
        }
    
        let nextTetrominoDisplayFrame = nextTetrominoDisplay.calculateAccumulatedFrame()
        let gameBoardFrame = gameBoard.calculateAccumulatedFrame()
        nextTetrominoDisplay.position = CGPoint(x: gameBoardFrame.origin.x + gameBoardFrame.width - nextTetrominoDisplayFrame.width, y: -30)
    
        if nextTetrominoDisplay.parent == nil {
            self.addChild(nextTetrominoDisplay)
        }
    }

    We use removeAllChildren to clear all the sprite nodes of the previous display then we replace it with the new sprites. We use the same nested for in loop technique we’ve seen in the other methods to draw the tetromino. We position the nextTetrominoDisplay to the upper right area of the scene with the help of the calculateAccumulatedFrame method. Finally we add it as a child node of the scene but making sure we only do it once.

  3. Let’s add a couple of new lines to the didMoveToView method.

    override func didMoveToView(view: SKView) {
        /* Setup your scene here */
        ...
    
        nextTetrominoDisplay.anchorPoint = CGPoint(x: 0, y: 1.0)
        showNextTetromino()
    }

We set the anchor point of the sprite node to the upper left hand corner. Then we call the new method we just created.

  1. We also modify the didLand method to handle this update.

    func didLand() {
        clearLines()
    
        gameBitmapStatic.removeAll(keepCapacity: true)
        gameBitmapStatic = gameBitmapDynamic
    
        activeTetromino = nextTetromino
        centerActiveTetromino()
    
        nextTetromino = Tetromino()
        showNextTetromino()
    
        refresh()
        lastUpdate = NSDate()
    }

Instead of creating a new Tetromino object to set as active, we assign the next tetromino as the current active one. Finally we create a new tetromino to add to the queue then call showNextTetromino to display it.

Save and run (⌘ + R) the game. Have fun strategizing and planning your next move. There are still a couple of elements missing. What’s the point of playing the game without going anywhere. We need a sense of progress. That’s what we’re going to implement next.

Scoring and Leveling up

The primary action in our game that earns points is clearing lines. The player may also earn additional points when performing an instadrop as a reward for performing a risky move. The score can also automatically increase as the brick falls. The player levels up when he exceeds the level’s target score and at the same time the drop speed increases.

  1. Let’s begin by adding the values that will hold the current score, level and target. We’ll also define the text elements that will display these values.

    class GameScene: SKScene {
        ...
    
        let scoreLabel = SKLabelNode()
        let levelLabel = SKLabelNode()
    
        var score = 0
        var level = 1
        var nextLevel = 3000
    
        override func didMoveToView(view: SKView) {...}
    }

We start at score 0 and level 1. To level up, we need to exceed a particular score. To reach level 2 for example, we need to exceed 3,000 points.

  1. Next we create a new method named updateScoreWith that takes in the amount of points as parameter to add to the current score.

    func updateScoreWith(points: Int = 1) {
        if scoreLabel.parent == nil &amp;&amp; levelLabel.parent == nil {
            let gameBoardFrame = gameBoard.calculateAccumulatedFrame()
    
            scoreLabel.text = &quot;Score: \(score)&quot;
            scoreLabel.fontSize = 20.0
            scoreLabel.fontColor = SKColor.whiteColor()
            scoreLabel.horizontalAlignmentMode = .Left
            scoreLabel.position = CGPoint(x: gameBoardFrame.origin.x, y: -scoreLabel.frame.height - 50)
            self.addChild(scoreLabel)
    
            levelLabel.text = &quot;Level: \(level)&quot;
            levelLabel.fontSize = 20.0
            levelLabel.fontColor = SKColor.whiteColor()
            levelLabel.horizontalAlignmentMode = .Left
            levelLabel.position = CGPoint(x: scoreLabel.frame.origin.x, y: -levelLabel.frame.height - scoreLabel.frame.height - 50 - 10)
            self.addChild(levelLabel)
        }
    
        score += points * level * level
        scoreLabel.text = &quot;Score: \(score)&quot;
    
        if score > nextLevel {
            levelLabel.text = &quot;Level: \(++level)&quot;
            nextLevel = Int(2.5 * Double(nextLevel))
    
            if dropTime - 150 <= 0 {
                // Maximum speed
                dropTime = 100
            } else {
                dropTime -= 150
            }
        }
    }

We prepare the label nodes that will display our current score and level. Setting the font size, color, alignment and position. Next, we update the score using the current level as a multiplier. The higher the level, the higher the scoring. Then we check if we have exceeded the target score. If we had then we increase the level by 1 and the speed by 150 milliseconds. We also increase the target score for the next level by 150%. We also update the displayed score in our scene. We set a speed cap but realistically there is a very slim chance of someone reaching it.

  1. Let’s now invoke this method in all the actions that generate points. First let’s do an initial call in the didMoveToView method to display the starting values.

    override func didMoveToView(view: SKView) {
        ...
    
        updateScoreWith(points: 0)
    }

    Next, we invoke it in the moveTetrominoTo method to add the automatic points when falling.

    func moveTetrominoTo(direction: Direction) {
        if collidedWith(direction) == false {
            activeTetromino.moveTo(direction)
    
            if direction == .Down {
                updateScoreWith()
                lastUpdate = NSDate()
            }
        } else {
            ...
        }
        ...
    }

Next, we invoke it in the clearLines method which is the most important point generating action. We pass in a cube of the number of lines cleared plus a bonus multiplier if you clear the maximum 4 lines in one go.

func clearLines() {
    ...

    if linesToClear.count > 0 {
        ...

        var multiplier = linesToClear.count == 4 ? 10 : 1
        updateScoreWith(points: linesToClear.count * linesToClear.count * linesToClear.count)
    }
}

Finally, we score twice as usual when performing an instant drop.

func instaDrop() {
    while collidedWith(.Down) == false {
        updateScoreWith(points: 2)
        activeTetromino.moveTo(.Down)
        updateGameBitmap()
    }
    didLand()
}

That’s it! Save and run (⌘ + R) the game and try to beat my high score of 406,858 points. At this stage, feel free to customize the size and colors of the shapes, game board and other elements. Play around with the scoring rules, level targets, touch handling and other mechanics. Make this game truly your own.

High Score

Conclusion

The game itself is now practically complete except for a few missing bells and whistles. I’ll let you take care of adding sound effects, custom app icons, launch screen graphics and handling the game over state as an exercise. Note however that it is not recommended for you to sell this as your own game due to the risk of running into copyright trouble. For now, give yourself a pat on the back for sticking it through until the end.

We only scratched the surface of some of Swift’s most important features including type inference, constants, enumerations and optionals. But your learning doesn’t stop here. Go ahead and read through Apple’s The Swift Programming Language book to gain a much deeper understanding and appreciation of the language.

Thank you and all the best to your app or game development career!

Rico ZuñigaRico Zuñiga
View Author

17+ years in the software industry. Experienced CTO in blockchain and cryptocurrency. Community leader, developer advocate, mentor, entrepreneur, and lifelong learner.

applechriswgame designObjective-Cswift
Share this article
Read Next
Get the freshest news and resources for developers, designers and digital creators in your inbox each week