Building a Real Time iOS multiplayer game with Swift and Websockets

Published May 15, 2018
Building a Real Time iOS multiplayer game with Swift and Websockets

Introduction

In this tutorial, I am going to explain how one could create a multiplayer real time game using Swift and WebSockets. Swift will be used both on the client and the server.

So what are WebSockets? Well, according to Mozilla:

WebSockets are an advanced technology that makes it possible to open an interactive communication session between the user's browser and a server. With this API, you can send messages to a server and receive event-driven responses without having to poll the server for a reply.

Of course, we will not communicate from a browser, but use a native client instead.

For this example, we'll create a Tic Tac Toe game. To keep the server code simple, the server will only support a single game for two players at any time.

To keep the article somewhat succinct, I will explain mainly how to set up the project and the code used for communication between the server and the client. The code regarding game logic is out of scope.

Code for the full project can be downloaded from my GitHub.

mp-tictactoe.png

Tools of the trade

We will use the following tools and libraries for this project:

  • Starscream: for websockets support on the client.
  • Perfect: a webserver and toolkit that is written in Swift and supports websockets.

It would have been awesome to use Perfect on the client as well, but as of yet, this is not (easily) possible. The Perfect library relies on the Swift Package Manager to be able to include it into your own projects, but for now, the Swift Package Manager is not supported for iOS. Luckily, Starscream supports Cocoapods, which is what we'll be using for the iOS app.

Workspace setup

For this game, we'll create a single workspace that includes three projects:

  • The TicTacToe server
  • The TicTacToe iOS client
  • A shared library for the server and client apps

The shared library includes some shared code, mainly a Message object that is used to wrap communication between the server and client.

To set up the project, we create an iOS project called TicTacToe. Use the SpriteKit game template for this project. After the project is created, create a Podfile in the TicTacToe directory and make sure to include the Starscream cocoapod into our TicTacToe target:

source 'https://github.com/CocoaPods/Specs.git'
platform :ios, '9.0'
use_frameworks!

target 'TicTacToe' do
  pod 'Starscream', '~> 3.0.2'
end

After running pod install, we should now have a TicTacToe workspace. The project should build without any issues.

Now it's time to create the server project. Inside the TicTacToe directory, we create a directory called TicTacToeServer. Download the Perfect template project into this directory as follows:

cd TicTacToeServer
git clone https://github.com/PerfectlySoft/PerfectTemplate.git .

Now, open the Package.swift file and edit it as follows:

  • Rename target to TicTacToeServer.
  • Include the Perfect WebSockets library dependency.

Run swift package generate-xcodeproj to generate an Xcode project. Make sure the projects build and run correctly. Afterwards, the server project can now be included into the previously created workspace.

The last step would be to create a shared library project that is used by both the server and client apps. Navigate back to the directory that contains the workspace and add a directory TicTacToeShared. For this project, we will use the Swift Package Manager as well. Create a Package.swift file that contains the following:

import PackageDescription

let package = Package(
    name: "TicTacToeShared",
    products: [
        // Products define the executables and libraries produced by a package, and make them visible to other packages.
        .library(
            name: "TicTacToeShared",
            targets: ["TicTacToeShared"]),
    ],
    dependencies: [
        // Dependencies declare other packages that this package depends on.
        // .package(url: /* package url */, from: "1.0.0"),
    ],
    targets: [
        // Targets are the basic building blocks of a package. A target can define a module or a test suite.
        // Targets can depend on other targets in this package, and on products in packages which this package depends on.
        .target(
            name: "TicTacToeShared",
            dependencies: []),
        .testTarget(
            name: "TicTacToeSharedTests",
            dependencies: ["TicTacToeShared"]),
    ]
)

Again, run swift package generate-xcodeproj to create the Xoode project. For the server project, we can now include this shared library by updating the Package.swift file of the server project by including this project as a dependency:

.Package(url: "../TicTacToeShared", majorVersion: 1)

For the iOS project, we have to manually include the library. In order to do this, we first have to create an iOS target. After this target is created, make sure to update the target name to TicTacToeShared. Now the target can be included in the iOS app project.

Setup the shared library project

The shared library project will define three objects:

  • A Message object that is used for communication between the server and client regarding game state.
  • A Player object that represents a player in the game. A Player object will only define an id field and comparison methods.
  • Finally a Tile object (more accurately an enum) that contains the three valid states a tile can have: X, O or none.

We define the following communcation messages: join, turn, finish, and stop. A message might need additional data, for example a player object or the board state. In order to always create proper messages, we add a few factory methods. The Message, Player and Tile objects all conform to the Codable protocol, making it easy to encode to and decode from JSON.

The resulting Message class looks as follows:

public enum MessageType: String, Codable {
    case join = "join"
    case turn = "turn"
    case finish = "finish"
    case stop = "stop"
}

public class Message: Codable {
    public let type: MessageType
    public let board: [Tile]?
    public let player: Player?
    
    private init(type: MessageType, board: [Tile]?, player: Player? = nil) {
        self.type = type
        self.board = board
        self.player = player
    }
    
    public static func join(player: Player) -> Message {
        return Message(type: .join, board: nil, player: player)
    }
    
    public static func stop(board: [Tile]) -> Message {
        return Message(type: .stop, board: board)
    }
    
    public static func turn(board: [Tile], player: Player) -> Message {
        return Message(type: .turn, board: board, player: player)
    }
    
    public static func finish(board: [Tile], winningPlayer: Player?) -> Message {
        return Message(type: .finish, board: board, player: winningPlayer)
    }
}

Set up the server project

We will be making quite a few changes to the template server project. First step would be to add a file Handlers.swift that will contain the web socket handlers. For communication, a custom protocol tictactoe is defined. When the client connects with the server, the server validates the protocol. If the client uses the incorrect protocol, the client will be disconnected.

Inside the Handlers.swift file, we also define a class GameHandler which is a subclass of WebSocketSessionHandler. The main logic of the connection happens here. When a client connects, we first check if we received a string.

If no string was supplied, something must have gone wrong, and in that case, other clients are informed that a player has left the game and the socket is closed. But if we did receive a string, we will try to decode it into a Message object and respond appropriately.

We only need to handle two message types here: join for when a player is trying to join a game and turn to handle a player playing a turn. The finish and stop messages are only of interest for iOS clients.

class GameHandler: WebSocketSessionHandler {
    // The name of the super-protocol we implement.
    // This is optional, but it should match whatever the client-side WebSocket is initialized with.
    let socketProtocol: String? = "tictactoe"
    
    // This function is called by the WebSocketHandler once the connection has been established.
    func handleSession(request: HTTPRequest, socket: WebSocket) {
        // This callback is provided:
        //  the received data
        //  the message's op-code
        //  a boolean indicating if the message is complete
        // (as opposed to fragmented)
        socket.readStringMessage { (string, op, fin) in
            // The data parameter might be nil here if either a timeout
            // or a network error, such as the client disconnecting, occurred.
            // By default there is no timeout.
            guard let string = string else {
                // This block will be executed if, for example, the game was closed.
                if let player = Game.instance.playerForSocket(socket) {
                    print("socket closed for \(player.id)")
                    
                    do {
                        try Game.instance.handlePlayerLeft(player: player)
                    } catch let error {
                        print("error: \(error)")
                    }
                }
                
                return socket.close()
            }
            
            do {
                let decoder = JSONDecoder()
                guard let data = string.data(using: .utf8) else {
                    return print("failed to covert string into data object: \(string)")
                }
                
                let message: Message = try decoder.decode(Message.self, from: data)
                switch message.type {
                case .join:
                    guard let player = message.player else {
                        return print("missing player in join message")
                    }

                    try Game.instance.handleJoin(player: player, socket: socket)
                case .turn:
                    guard let board = message.board else {
                        return print("board not provided")
                    }
                    
                    try Game.instance.handleTurn(board)
                default:
                    break
                }
            } catch {
                print("Failed to decode JSON from Received Socket Message")
            }
            
            //Done working on this message? Loop back around and read the next message.
            self.handleSession(request: request, socket: socket)
        }
    }
}

Since we don't handle more than two clients, we can use a singleton Game instance. The Game singleton manages a GameState that contains the current state of the board and the players. A GameState can only be created if two players are connected and, if that happens, will initialize an array with nine tiles.

The Game instance itself will communicate back to the iOS clients with Message objects. In order to do this, it contains a hashmap of Players and their respective WebSockets. The Game instance is responsible for two-way communication. The methods prefixed with handle... are used for incoming traffic and the methods prefixed with notify... are used for outgoing traffic.

The Game instance makes use of a GameState object to keep track of the current state of a game as well as the game logic. In order to notify the players of messages we use the following code:

private func notifyPlayers(message: Message) throws {
    let jsonEncoder = JSONEncoder()
    let jsonData = try jsonEncoder.encode(message)

    guard let jsonString = String(data: jsonData, encoding: .utf8) else {
        throw GameError.failedToSerializeMessageToJsonString(message: message)
    }

    self.players.values.forEach({
        $0.sendStringMessage(string: jsonString, final: true, completion: {
            print("did send message: \(message.type)")
        })
    })
}

Since a Message object conforms to Codable, it's trivial to encode it into a JSON string. The JSON string is then sent to each connected player using their socket connections.

Set up the iOS project

The iOS project will be pretty simple as well. By default, there will be a GameScene class. We will be adding a TicTacToeClient class that will handle communication with the back-end. There will also be a Game class that will handle some game logic (most logic will happen on the server).

The TicTacToeClient will implement the WebSocketDelegate from the StarScream library and it really only needs to implement three delegate methods: for connection, disconnection, and whenever a message (JSON string) is received. The client also contains logic to convert a JSON string into a Message object and vice versa.

protocol TicTacToeClientDelegate: class {
    func clientDidConnect()
    func clientDidDisconnect(error: Error?)
    func clientDidReceiveMessage(_ message: Message)
}

class TicTacToeClient: WebSocketDelegate {
    weak var delegate: TicTacToeClientDelegate?
    
    private var socket: WebSocket!
    
    init() {
        let url = URL(string: "http://localhost:8181/game")!
        let request = URLRequest(url: url)
        self.socket = WebSocket(request: request, protocols: ["tictactoe"], stream: FoundationStream())

        self.socket.delegate = self
    }
    
    // MARK: - Public
    
    func connect() {
        self.socket.connect()
    }
    
    func join(player: Player) {
        let message = Message.join(player: player)
        writeMessageToSocket(message)
    }
    
    func playTurn(updatedBoard board: [Tile], activePlayer: Player) {
        let message = Message.turn(board: board, player: activePlayer)
        writeMessageToSocket(message)
    }
    
    func disconnect() {
        self.socket.disconnect()
    }
    
    // MARK: - Private
    
    private func writeMessageToSocket(_ message: Message) {
        let jsonEncoder = JSONEncoder()
        
        do {
            let jsonData = try jsonEncoder.encode(message)
            self.socket.write(data: jsonData)
        } catch let error {
            print("error: \(error)")
        }
    }

    // MARK: - WebSocketDelegate
    
    func websocketDidConnect(socket: WebSocketClient) {
        self.delegate?.clientDidConnect()
    }
    
    func websocketDidDisconnect(socket: WebSocketClient, error: Error?) {
        self.delegate?.clientDidDisconnect(error: error)
    }
    
    func websocketDidReceiveMessage(socket: WebSocketClient, text: String) {
        guard let data = text.data(using: .utf8) else {
            print("failed to convert text into data")
            return
        }
    
        do {
            let decoder = JSONDecoder()
            let message = try decoder.decode(Message.self, from: data)
            self.delegate?.clientDidReceiveMessage(message)
        } catch let error {
            print("error: \(error)")
        }
    }
    
    func websocketDidReceiveData(socket: WebSocketClient, data: Data) {
        // We don't deal directly with data, only strings
    }
}

As can be seen, the TicTacToeClient has its own protocol to ease communication with the rest of the app.

Finally there is the Game class, a singleton that keeps track of the game state. It's a pretty simple class that really only has 2 main functions:

  • Manage the connection state using the client (connected, disconnected, etc...).
  • Whenever a user touches a screen and if it's the user's turn to play, communicate this turn with the client.
enum GameState {
    case active // the player can play his turn
    case connected // connected with the back-end
    case disconnected // disconnected from the back-end
    case playerWon // the player won
    case playerLost // the player lost
    case draw // the game ended in a draw
    case waiting // waiting for the other player's turn
    case stopped // game stopped, perhaps because the other player left the game
    
    var message: String {
        switch self {
        case .active: return "Your turn to play ..."
        case .connected: return "Waiting for player to join"
        case .disconnected: return "Disconnected"
        case .playerWon: return "You won :)"
        case .playerLost: return "You lost :("
        case .draw: return "It's a draw :|"
        case .waiting: return "Waiting for other player ..."
        case .stopped: return "Player left the game"
        }
    }
}

class Game {
    static let sharedInstace = Game()

    // We use an array of tiles to represent the game board.
    private(set) var board = [Tile]()
    
    // We use this client for interacting with the server.
    private (set) var client = TicTacToeClient()

    // The state is initally disconnected - wait for the client to connect.
    private(set) var state: GameState = .disconnected

    // This player instance represents the player behind this device.
    private (set) var player = Player()
        
    // The tile type for the currently active player
    private (set) var playerTile: Tile = .none
    
    // MARK: - Public
    
    func start() {
        self.client.delegate = self
        self.client.connect()
    }
    
    func stop() {
        self.client.disconnect()
    }
    
    func playTileAtPosition(_ position: CGPoint) {        
        let tilePosition = Int(position.y * 3 + position.x)
        
        let tile = self.board[tilePosition]
        if tile == .none {
            self.board[tilePosition] = self.playerTile
            self.client.playTurn(updatedBoard: self.board, activePlayer: self.player)
            self.state = .waiting
        }
    }

    // MARK: - Private
    
    private init() { /* singleton */ }

    private func configurePlayerTileIfNeeded(_ playerTile: Tile) {
        let emptyTiles = board.filter({ $0 == .none })
        if emptyTiles.count == 9 {
            self.playerTile = playerTile
        }
    }
}

// MARK: - TicTacToeClientDelegate

extension Game: TicTacToeClientDelegate {
    func clientDidDisconnect(error: Error?) {
        self.state = .disconnected
    }
    
    func clientDidConnect() {
        self.client.join(player: self.player)
        self.state = .connected
    }
    
    func clientDidReceiveMessage(_ message: Message) {
        if let board = message.board {
            self.board = board
        }
        
        switch message.type {
        case .finish:
            self.playerTile = .none
            
            if let winningPlayer = message.player {
                self.state = (winningPlayer == self.player) ? .playerWon : .playerLost
            } else {
                self.state = .draw
            }
        case .stop:
            self.playerTile = .none

            self.state = .stopped
        case .turn:
            guard let activePlayer = message.player else {
                print("no player found - this should never happen")
                return
            }
            
            if activePlayer == self.player {
                self.state = .active
                configurePlayerTileIfNeeded(.x)
            } else {
                self.state = .waiting
                configurePlayerTileIfNeeded(.o)
            }
        default: break
        }
    }
}

Conclusion

While it might be a bit of a hassle to setup a project for a real time multiplayer game using using Swift and WebSockets, in the end, it's quite trivial to write the game itself using this technology. While I am not sure if WebSockets are the best tool for this purpose (I am not a game programmer), it should definitely be good enough for prototyping.

Discover and read more posts from Wolfgang Schreurs
get started