Now that we finished up the model, we can finally build our engine!

The Engine

Without any user interaction, our Tetris engine can be very simple. There’s more to this engine that we’ll add later, but we really only need to do 3 things (for now):

If we don’t have a Tetromino, spawn a new one

If we can move the Tetromino down, move it down

If we can’t move it down, place the Tetromino onto the board

We’ll throw all of this logic into its own function. This function will advance the game 1 step every time it’s called. Timers are able to call a function every X interval, so we’ll setup a timer to call our main loop every 1/2 second. When users get to higher levels, we can update this timer to call it faster. Setting up the timer will look like:

class TetrisGameModel: ObservableObject { ... var timer: Timer? var speed: Double init(numRows: Int = 23, numColumns: Int = 10) { self.numRows = numRows self.numColumns = numColumns gameBoard = Array(repeating: Array(repeating: nil, count: numRows), count: numColumns) // Remove spanwing from init() speed = 0.5 resumeGame() } ... func resumeGame() { timer?.invalidate() timer = Timer.scheduledTimer(withTimeInterval: speed, repeats: true, block: runEngine) } func pauseGame() { timer?.invalidate() } func runEngine(timer: Timer) { // Spawn a new block if we need to // See about moving block down // See if we need to place the block } ... }

Spawning a New Tetromino

The first step in our engine is trying to spawn a new Tetromino if we don’t have a Tetromino. This was originally inside our init() function, but we’ll now move this into our engine. If we do spawn a new Tetromino, we’ll want to quit out of this engine.

We also have to check for the game ending at this stage, too. If we spawn a Tetromino on top of an existing block, the game ends. To do this, we’ll create a new helper function. This helper function will take in a Tetromino, and check if it’s within the board’s bounds and not on top of a block.

class TetrisGameModel: ObservableObject { ... func runEngine(timer: Timer) { // Spawn a new block if we need to guard let currentTetromino = tetromino else { print("Spawning new Tetromino") tetromino = Tetromino(origin: BlockLocation(row: 22, column: 4), blockType: .i) if !isValidTetromino(testTetromino: tetromino!) { print("Game over!") pauseGame() } return } // See about moving block down // See if we need to place the block } func isValidTetromino(testTetromino: Tetromino) -> Bool { for block in testTetromino.blocks { let row = testTetromino.origin.row + block.row if row < 0 || row >= numRows { return false } let column = testTetromino.origin.column + block.column if column < 0 || column >= numColumns { return false } if gameBoard[column][row] != nil { return false } } return true } }

Moving Tetrominos Down

Once there’s a Tetromino, we need to try to move it down. Instead of moving the current Tetromino down, we’ll create a new Tetromino one row below where the current Tetromino is. We can then use our isValidTetromino function to check that we could move it down. If this is valid, we’ll replace our current Tetromino with this new Tetromino.

We’ll put this Tetromino creation code inside our Tetromino struct.

class TetrisGameModel: ObservableObject { ... func runEngine(timer: Timer) { // Spawn a new block if we need to ... // See about moving block down let newTetromino = currentTetromino.moveBy(row: -1, column: 0) if isValidTetromino(testTetromino: newTetromino) { print("Moving Tetromino down") tetromino = newTetromino return } // See if we need to place the block } func isValidTetromino(testTetromino: Tetromino) -> Bool { ... } } struct Tetromino { ... func moveBy(row: Int, column: Int) -> Tetromino { let newOrigin = BlockLocation(row: origin.row + row, column: origin.column + column) return Tetromino(origin: newOrigin, blockType: blockType) } }

Placing the Tetromino

Finally, if we don’t need a new Tetromino and we can’t move it down, we need to place it on the board. We’ll figure out where all the block are on the Tetromino, then add a new block at that location on the board. We’ll then set the current tetromino to nil so we’ll spawn a new one the next time the engine runs.

I’ll also add in some extra checks in this function so we don’t accidentally cause a crash…

class TetrisGameModel: ObservableObject { ... func runEngine(timer: Timer) { // Spawn a new block if we need to ... // See about moving block down ... // See if we need to place the block print("Placing tetromino") placeTetromino() } func placeTetromino() { guard let currentTetromino = tetromino else { return } for block in currentTetromino.blocks { let row = currentTetromino.origin.row + block.row if row < 0 || row >= numRows { continue } let column = currentTetromino.origin.column + block.column if column < 0 || column >= numColumns { continue } gameBoard[column][row] = TetrisGameBlock(blockType: currentTetromino.blockType) } tetromino = nil } }

Let’s go ahead and run it now! (Note: I sped up the timer)

Adding More Tetrominos

If any of you have played Tetris before, you’ll know that there are more than just these I blocks. Now that we have our engine working, let’s make the rest of the blocks.

To do this, I’m going to create a new function that returns the blocks given a block type. Using the SRS as a guide for blocks, we’ll have this function return the first rotation for each block. Our block property will just be a wrapper around this function now.

Next, we’ll make a new function to create Tetrominos. This function will create a new Tetromino, then figure out where the origin of the Tetromino needs to be. Some Tetrominos have blocks that lie above the origin, so these Tetrominos will have to get shifted down a row so that they don’t appear above the game board.

Putting this all together, we get:

class TetrisGameModel: ObservableObject { ... func runEngine(timer: Timer) { // Spawn a new block if we need to guard let currentTetromino = tetromino else { print("Spawning new Tetromino") tetromino = Tetromino.createNewTetromino(numRows: numRows, numColumns: numColumns) if !isValidTetromino(testTetromino: tetromino!) { print("Game over!") pauseGame() } return } // See about moving block down ... // See if we need to place the block ... } } struct Tetromino { ... var blocks: [BlockLocation] { return Tetromino.getBlocks(blockType: blockType) } ... static func getBlocks(blockType: BlockType) -> [BlockLocation] { switch blockType { case .i: return [BlockLocation(row: 0, column: -1), BlockLocation(row: 0, column: 0), BlockLocation(row: 0, column: 1), BlockLocation(row: 0, column: 2)] case .o: return [BlockLocation(row: 0, column: 0), BlockLocation(row: 0, column: 1), BlockLocation(row: 1, column: 1), BlockLocation(row: 1, column: 0)] case .t: return [BlockLocation(row: 0, column: -1), BlockLocation(row: 0, column: 0), BlockLocation(row: 0, column: 1), BlockLocation(row: 1, column: 0)] case .j: return [BlockLocation(row: 1, column: -1), BlockLocation(row: 0, column: -1), BlockLocation(row: 0, column: 0), BlockLocation(row: 0, column: 1)] case .l: return [BlockLocation(row: 0, column: -1), BlockLocation(row: 0, column: 0), BlockLocation(row: 0, column: 1), BlockLocation(row: 1, column: 1)] case .s: return [BlockLocation(row: 0, column: -1), BlockLocation(row: 0, column: 0), BlockLocation(row: 1, column: 0), BlockLocation(row: 1, column: 1)] case .z: return [BlockLocation(row: -1, column: 0), BlockLocation(row: 0, column: 0), BlockLocation(row: 0, column: -1), BlockLocation(row: -1, column: 1)] } } static func createNewTetromino(numRows: Int, numColumns: Int) -> Tetromino { let blockType = BlockType.allCases.randomElement()! var maxRow = 0 for block in getBlocks(blockType: blockType) { maxRow = max(maxRow, block.row) } let origin = BlockLocation(row: numRows - 1 - maxRow, column: (numColumns-1)/2) return Tetromino(origin: origin, blockType: blockType) } }

And it works!

What Next?

Now that we have this engine setup, we’re ready for some user inputs. Up next, moving Tetrominos from user gestures!

Feel free to check out my Github Repo, or watch my tutorial about this on Youtube:

Thanks for reading!

– SchwiftyUI