With only the simplest of mechanics, our game will be able to offer engaging gameplay with high replay value. Looking at the game animation in the Introduction once again, we can easily identify most of these rules. The most obvious of which include the rules on falling, movement and collisions. We also have rules on clearing lines, scoring and levelling up. Let’s implement all of them now starting with the falling rule.
Key Takeaways
- Utilize custom gravity settings in Swift to control tetrominoes falling one row at a time, increasing speed with level ups instead of using SpriteKit’s default physics engine.
- Implement array-based collision detection to manage tetromino landing on platforms, ensuring they don’t fall off the screen and consume memory.
- Update game logic to include a bitmap representing the game area with defined walls and a floor, enhancing gameplay structure and collision management.
- Refine game scene setup by dynamically creating and updating a visual representation of the game board using arrays and sprite nodes.
- Introduce lateral and downward movement controls for tetrominoes, allowing player interaction through touch events to direct game pieces left, right, or downward.
- Enhance collision detection to handle new movement directions, ensuring game logic accommodates tetromino rotations and interactions with boundaries and other tetrominoes.
Dropping Tetrominoes
Our game’s world utilizes its own unique gravity. It is significantly different from the typical gravity simulation in other games which produces a smooth, accelerating falling effect. In our game, tetrominoes fall one row at a time with constant speed which increases only when the level increases. This means we can’t rely on SpriteKit’s default physics engine to simulate gravity for us so we have to manually control and time every tetromino drop.
Update the GameScene
class with the new properties and methods in the code below to enable our custom gravity. Keep the existing code marked with ---
.
---
let defaultSpeed = NSTimeInterval(1200)
class GameScene: SKScene {
var dropTime = defaultSpeed
var lastUpdate:NSDate?
override func didMoveToView(view: SKView) {
/* Setup your scene here */
self.anchorPoint = CGPoint(x: 0, y: 1.0)
lastUpdate = NSDate()
}
override func touchesBegan(touches: NSSet, withEvent event: UIEvent) {---}
override func update(currentTime: CFTimeInterval) {
/* Called before each frame is rendered */
if lastUpdate != nil {
let elapsed = lastUpdate!.timeIntervalSinceNow * -1000.0
if elapsed > dropTime {
moveTetrominoesDown()
}
}
}
func drawTetrominoAtPoint(location: CGPoint) {---}
func moveTetrominoesDown() {
let squares = self.children as [SKSpriteNode]
for square in squares {
square.position.y += CGFloat(-blockSize)
}
lastUpdate = NSDate()
}
}
We’ll discuss this code in the next section. For now, run (⌘ + R) our game and touch anywhere on the simulator’s screen to draw a random tetromino that instantly starts falling one row every 1200 milliseconds. Did you notice the number of nodes decreasing as the tetrominoes fall beyond the screen? Contrary to what you’re seeing, the nodes are actually still in memory. Still part of the scene’s node tree, alive and consuming valuable resources in the background. The node counter only shows the number of visible nodes on the scene.
This is not exactly the behavior we want for our game. What we want is for the tetrominoes to land on a platform instead of falling beyond the screen. Handling collisions will help us achieve this.
Collision Detection
We are now on one of the more challenging parts of the game’s logic, collision detection. SpriteKit has great built in physics-based collision handling but we are not going to utilize that for now. Instead, we are going to implement our own simple array based collision detection.
Follow the steps below to update our GameScene.swift file. Again, keep the existing code marked with ---
.
- Add a new bitmap value that will represent the game area (or game board) with walls on both sides and a floor to land our bricks on.
import SpriteKit
let colors: [SKColor] = [---]
let gameBitmapDefault: [[Int]] = [
[8, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 8],
[8, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 8],
[8, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 8],
[8, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 8],
[8, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 8],
[8, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 8],
[8, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 8],
[8, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 8],
[8, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 8],
[8, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 8],
[8, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 8],
[8, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 8],
[8, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 8],
[8, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 8],
[8, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 8],
[8, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 8],
[8, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 8],
[8, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 8],
[8, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 8],
[8, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 8],
[8, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 8],
[8, 8, 8, 8, 8, 8, 8, 8, 8, 8, 8, 8]
]
- Add the new data that we will need for our custom drawing and collision detection.
class GameScene: SKScene {
var dropTime = defaultSpeed
var lastUpdate:NSDate?
let gameBoard = SKSpriteNode()
var activeTetromino = Tetromino()
var gameBitmapDynamic = gameBitmapDefault
var gameBitmapStatic = gameBitmapDefault
---
}
The gameBoard
constant will contain the sprites that will represent the game board as defined by gameBitmapDefault
. We use the activeTetromino
variable to represent the currently falling brick. Unlike in the previous example where we allow any number of bricks to fall, we now limit it to just one.
This means we’ll have to rename the moveTetrominoesDown
method to just moveTetrominoDown
for consistency and correctness. Do this now before you forget.
We added two array values that will represent two different states of the game board. Both are initialized with the default game bitmap. One state will contain all the landed bricks including the falling brick, while the other will have the same set of bricks except the falling brick. This technique will help us with updating our scene later.
- Let’s go ahead and setup our scene. Update the
didMoveToView
method with the following code:
class GameScene: SKScene {
---
override func didMoveToView(view: SKView) {
/* Setup your scene here */
self.anchorPoint = CGPoint(x: 0, y: 1.0)
gameBoard.anchorPoint = CGPoint(x: 0, y: 1.0)
for col in 0..<gameBitmapDefault[0].count {
for row in 0..<gameBitmapDefault.count {
let bit = gameBitmapDefault[row][col]
let square = SKSpriteNode(color: colors[bit], size: CGSize(width: blockSize, height: blockSize))
square.anchorPoint = CGPoint(x: 0, y: 0)
square.position = CGPoint(x: col * Int(blockSize) + col, y: -row * Int(blockSize) + -row)
gameBoard.addChild(square)
}
}
let gameBoardFrame = gameBoard.calculateAccumulatedFrame()
gameBoard.position = CGPoint(x: CGRectGetMidX(self.frame) - gameBoardFrame.width / 2, y: -125)
self.addChild(gameBoard)
centerActiveTetromino()
refresh()
lastUpdate = NSDate()
}
---
}
After setting the scene’s anchor point to the upper left corner, we draw the game board by parsing the integers stored in the gameBitmapDefault
constant. The game board’s anchor point was also set to the upper left corner for convenience. We’ve seen this nested for in
loop code before in the section on drawing tetrominoes. Now we’re applying the same technique to draw the entire game board.
After creating the sprites to represent each block of the game board, we retrieve the game board’s frame by calling its calculateAccumulatedFrame
method. We can’t rely on the game board’s frame
property because it will just return a frame with zero dimensions. What we want is to get the cumulative width and height of all of the game board’s child nodes and the only way to achieve this is by computing it using the calculateAccumulatedFrame
method. After getting this value, we are now able to center the game board horizontally on the scene. Finally, to show the game board, we need to add it as a child node of the current scene.
We now set the active tetromino’s position to the middle of the game board by calling a custom method named centerActiveTetromino
. Go ahead and add this method right after the didMoveToView
method:
class GameScene: SKScene {
---
override func didMoveToView(view: SKView) {---}
func centerActiveTetromino() {
let cols = gameBitmapDefault[0].count
let brickWidth = activeTetromino.bitmap[0].count
activeTetromino.position = (cols / 2 - brickWidth, 0)
}
}
The tetromino’s position value here does not hold a normal CGPoint
coordinate. Instead, it holds a simple tuple value which refers to the row and column number in the game board bitmap where the tetromino will be placed.
After setting our active tetromino’s position, we need to update the game board by modifying the sprites and bitmap values. We create a convenience method named refresh
to handle this. This method calls two other custom methods that perform the actual heavy lifting. Add these three new methods to the GameScene
class now:
class GameScene: SKScene {
---
func refresh() {
updateGameBitmap()
updateGameBoard()
}
func updateGameBitmap() {
gameBitmapDynamic.removeAll(keepCapacity: true)
gameBitmapDynamic = gameBitmapStatic
for row in 0..<activeTetromino.bitmap.count {
for col in 0..<activeTetromino.bitmap[row].count {
if activeTetromino.bitmap[row][col] > 0 {
gameBitmapDynamic[activeTetromino.position.y + row][activeTetromino.position.x + col + 1] = activeTetromino.bitmap[row][col]
}
}
}
}
func updateGameBoard() {
let squares = gameBoard.children as [SKSpriteNode]
var currentSquare = 0
for col in 0..<gameBitmapDynamic[0].count {
for row in 0..<gameBitmapDynamic.count {
let bit = gameBitmapDynamic[row][col]
let square = squares[currentSquare]
if square.color != colors[bit] {
square.color = colors[bit]
}
++currentSquare
}
}
}
}
The updateGameBitmap
method is where we use the two bitmap variables we declared earlier, gameBitmapDynamic
and gameBitmapStatic
. The main purpose of this method is to update the scene with the active tetromino’s new position. The technique we’re applying is to replace the dynamic bitmap with the static bitmap, temporarily removing the active tetromino. Finally, we apply the nested for in
loop technique once again to insert the active tetromino with its new position to the dynamic bitmap.
The updateGameBoard
method on the other hand deals with updating the actual sprites. A sprite is a graphical element that represents a block in the game. We’ve already created these sprites in our didMoveToView
method, so we only need to update their colors based on the tetrominoes that were added. We use the same for in
loop technique to parse each bitmap and use its value to retrieve the correct color from the colors
array.
Clear the touch event handler for now. This means we’re not going to use the
drawTetrominoAtPoint
method anymore so go ahead and remove it from your code.class GameScene: SKScene { --- override func touchesBegan(touches: NSSet, withEvent event: UIEvent) { /* Called when a touch begins */ } }
We handle the brick falling during every frame update but we control how fast the brick falls by checking for the elapsed time since the last drop event. We then call the newly renamed
moveTetrominoDown
method to move the active tetromino down by one block.
class GameScene: SKScene {
---
override func update(currentTime: CFTimeInterval) {
/* Called before each frame is rendered */
if lastUpdate != nil {
let elapsed = lastUpdate!.timeIntervalSinceNow * -1000.0
if elapsed > dropTime {
moveTetrominoDown()
}
}
}
}
Let’s update the moveTetrominoDown
method now to handle landing events.
class GameScene: SKScene {
---
override func update(currentTime: CFTimeInterval) {---}
func moveTetrominoDown() {
if landed() {
gameBitmapStatic.removeAll(keepCapacity: true)
gameBitmapStatic = gameBitmapDynamic
activeTetromino = Tetromino()
centerActiveTetromino()
} else {
activeTetromino.moveTo(.Down)
}
lastUpdate = NSDate()
refresh()
}
}
First, we check if the brick has landed on another brick or on the floor by calling landed
. This custom method we created for detecting collisions returns a Bool
. Let’s discuss what boolean values are.
Boolean Values
In Swift, a Bool
is a very simple value that is represented by either true
or false
, and nothing else. In other languages, a boolean value may be represented by an integer, zero being false and non zero values being true. It may also be represented by the existence or non existence of an object.
This is not the case with Swift because its type safety feature doesn’t allow booleans to hold any other value such as an integer. Booleans must evaluate to either of the two values only so checking if a certain integer is non zero using the shorthand notation if someInt
will not work. Conditions must evaluate to a boolean value so this should be written as:
if someInt == 0 {
// Do something
}
Let’s now add the landed
method right next to the moveTetrominoDown
method.
class GameScene: SKScene {
---
func moveTetrominoDown() {---}
func landed() -> Bool {
let x = activeTetromino.position.x
let y = activeTetromino.position.y + 1
for row in 0..<activeTetromino.bitmap.count {
for col in 0..<activeTetromino.bitmap[row].count {
if activeTetromino.bitmap[row][col] > 0 && gameBitmapStatic[y + row][x + col + 1] > 0 {
return true
}
}
}
return false
}
}
We indicate the return type using the return arrow -> followed by the name of the type to return, in this case Bool
.
Next, we get the position of the active tetromino, but with a y
value that is one block below. This represents the areas under the active tetromino which may include not only the floor but also other bricks.
We loop through the active tetromino’s blocks and check whether it overlaps an already existing block in the game as represented by gameBitmapStatic
. If it does, we immediately stop the loop and return true
. The loop continues until all the blocks are checked, if no collision is detected we return false
.
Going back to the moveTetrominoDown
method, if landed
returns true we update the static bitmap to include the active tetromino which has become part of the non-moving (or static) entities in our game. We accomplish this by overwriting the static bitmap with the dynamic bitmap’s values. We then create a new active tetromino to replace the one that recently landed. We save a timestamp of this moment in lastUpdate
for checking on the next update later. If there is no collision, we move the active tetromino one block down. After all these are done, we perform a refresh
to update the bitmap values and sprites with the active tetromino’s new position.
Run (⌘ + R) our game and watch falling tetrominoes land on the floor and on top of each other:
And that’s how we implement collision detection in our blocks game. From vertical, let’s now move to horizontal movement.
Lateral Movement
No one wants a game with a bunch of blocks falling on top of each other. What we want is to be able to decide and control where the blocks should fall. We accomplish this by enabling left and right movement for the blocks.
Looking back at our Tetromino
class, we’ve already prepared movement behavior beforehand with the moveTo
method:
func moveTo(direction: Direction) {
switch direction {
case .Left:
position = (position.x - 1, position.y)
case .Right:
position = (position.x + 1, position.y)
case .Down:
position = (position.x, position.y + 1)
case .None:
break
}
}
- This makes our job a little bit easier. To accommodate the other directions, let’s rename the
moveTetrominoDown
once again method tomoveTetrominoTo
. This time it will accept a parameter for the target direction. Let’s also update the body of the method to reflect these changes. There will be additional collision detection to accomodate the new directions.
func moveTetrominoTo(direction: Direction) {
if collidedWith(direction) == false {
activeTetromino.moveTo(direction)
if direction == .Down {
lastUpdate = NSDate()
}
} else {
if direction == .Down {
gameBitmapStatic.removeAll(keepCapacity: true)
gameBitmapStatic = gameBitmapDynamic
activeTetromino = Tetromino()
centerActiveTetromino()
lastUpdate = NSDate()
}
}
refresh()
}
The previous version of this method only checks whether the brick has landed. But now that we have two additional directions to account for, we also need to augment our landed
method. Let’s rename it to the more appropriate collidedWith
method that accepts a direction parameter. Also update its body with the following code.
func collidedWith(direction: Direction) -> Bool {
func collided(x: Int, y: Int) -> Bool {
for row in 0..<activeTetromino.bitmap.count {
for col in 0..<activeTetromino.bitmap[row].count {
if activeTetromino.bitmap[row][col] > 0 && gameBitmapStatic[y + row][x + col + 1] > 0 {
return true
}
}
}
return false
}
let x = activeTetromino.position.x
let y = activeTetromino.position.y
switch direction {
case .Left:
return collided(x - 1, y)
case .Right:
return collided(x + 1, y)
case .Down:
return collided(x, y + 1)
case .None:
return collided(x, y)
}
}
We reuse the existing nested for in
loop from the old landed
method by wrapping it in a convenience function named collided
declared inside the collidedWith
method. This technique will help in handling the other directions.
We use a switch
conditional statement to check all the available directions including the state of remaining stationary. This detects whether a newly instantiated tetromino has already collided with existing tetrominoes. If it did then a game over event is triggered. We also use this stationary state to detect rotational collisions.
Going back to the moveTetrominoTo
method, we check if there are no collisions before moving the active tetromino to the requested direction. If a collision is detected, we handle the landing like before, no need to worry about the other directions.
- To make the brick move, we need to handle touch events. The easiest and most straightforward way is to detect which side of the game board the touch registered. Touches on the left side would move the brick to the left, while touches on the right side, to the right.
Let’s fill our empty touchesBegan
event handler with some code:
override func touchesBegan(touches: NSSet, withEvent event: UIEvent) {
/* Called when a touch begins */
for touch: AnyObject in touches {
let location = touch.locationInNode(self)
let gameBoardFrame = gameBoard.calculateAccumulatedFrame()
if location.x < gameBoardFrame.origin.x {
moveTetrominoTo(.Left)
} else if location.x > gameBoardFrame.origin.x + gameBoardFrame.width {
moveTetrominoTo(.Right)
}
}
}
Inside the loop, we get the touch’s location as well as the game board’s frame using the calculateAccumulatedFrame
method we discussed earlier. We check whether the touch is on the left of the game board or on the right. Finally, we pass the appropriate direction value to the moveTetrominoTo
method.
- Let’s not forget to rename the call to
moveTetrominoDown()
in theupdate
method to:
moveTetrominoTo(.Down)
Save and then run (⌘ + R) our game to experience the slightly more interactive bricks. There’s still one more movement behavior that we want our bricks to support and that is rotation. We’ll implement this in the next and final part of this series.
Frequently Asked Questions (FAQs) about Creating a Tetromino Puzzle Game Using Swift Gameplay
How can I create different shapes of Tetrominoes in Swift?
Creating different shapes of Tetrominoes in Swift involves defining the shapes in a 2D array. Each shape is represented by a different array configuration. For instance, the “I” shape can be represented as [[1, 1, 1, 1]]. The “J” shape can be represented as [[1, 0, 0], [1, 1, 1]]. The “L”, “O”, “S”, “T”, and “Z” shapes can be similarly represented. Each 1 represents a block of the Tetromino, and each 0 represents an empty space.
How can I control the movement of Tetrominoes in Swift?
Controlling the movement of Tetrominoes in Swift involves updating the position of the Tetromino based on user input. You can use the SpriteKit’s SKAction class to create actions that move the Tetromino. For instance, to move a Tetromino to the right, you can create an action that adds 1 to the x-coordinate of the Tetromino’s position. To rotate a Tetromino, you can create an action that changes the Tetromino’s rotation property.
How can I detect collisions between Tetrominoes in Swift?
Detecting collisions between Tetrominoes in Swift involves checking if the new position of a Tetromino after a move or rotation would overlap with any existing Tetrominoes. You can do this by iterating over the blocks of the moving Tetromino and checking if any of them would occupy the same position as a block of an existing Tetromino. If a collision is detected, the move or rotation should be cancelled.
How can I clear completed lines in a Tetromino game in Swift?
Clearing completed lines in a Tetromino game in Swift involves checking each row of the game board to see if it is filled with blocks. If a row is filled, it should be removed from the game board, and all rows above it should be moved down by one position. This can be done by iterating over the rows of the game board from bottom to top and removing any rows that are filled.
How can I keep score in a Tetromino game in Swift?
Keeping score in a Tetromino game in Swift involves incrementing a score variable each time a line is cleared. The score can be displayed on the screen using a SKLabelNode. You can also implement a level system where the speed of the falling Tetrominoes increases as the player’s score increases.
How can I implement game over logic in a Tetromino game in Swift?
Implementing game over logic in a Tetromino game in Swift involves checking if there is enough space at the top of the game board to spawn a new Tetromino. If there is not enough space, the game should end. You can display a game over message and the player’s final score using SKLabelNodes.
How can I add sound effects to a Tetromino game in Swift?
Adding sound effects to a Tetromino game in Swift involves using the SKAction.playSoundFileNamed(_:waitForCompletion:) method. You can play a sound effect each time a Tetromino moves, rotates, or lands, and each time a line is cleared.
How can I add a pause feature to a Tetromino game in Swift?
Adding a pause feature to a Tetromino game in Swift involves setting the isPaused property of the SKView to true. While the game is paused, no actions will be executed, and no touch events will be processed. You can add a pause button that toggles the isPaused property when tapped.
How can I add a high score feature to a Tetromino game in Swift?
Adding a high score feature to a Tetromino game in Swift involves storing the player’s score in UserDefaults each time the game ends. When the game starts, you can retrieve the high score from UserDefaults and display it on the screen.
How can I make my Tetromino game more challenging?
Making your Tetromino game more challenging can involve increasing the speed of the falling Tetrominoes as the player’s score increases. You can also add power-ups that temporarily change the game mechanics, such as a power-up that makes the Tetrominoes fall faster, or a power-up that changes the shape of the next Tetromino.
17+ years in the software industry. Experienced CTO in blockchain and cryptocurrency. Community leader, developer advocate, mentor, entrepreneur, and lifelong learner.