Codementor Events

Building a Real Time iOS multiplayer game with Swift and WebSockets (4)

Published Jul 03, 2018Last updated Dec 30, 2018
Building a Real Time iOS multiplayer game with Swift and WebSockets (4)

Please note: this is the final part of the tutorial. For part 3, look here.


In the previous tutorials we've setup a shared library for use with the clients and the server and the server project. The final step is to create the iOS client.

Set up the iOS project

The iOS project will be pretty simple as well. As a starting point, the app is based on the SpriteKit game template for iOS. The game will make use of the following types and classes:

  • GameScene: To render the scene and handle touches.
  • GameViewController: To manage the GameScene.
  • GameState: An enumeration that will contain all possible client states.
  • Game: A singleton that manages the game state.
  • TicTacToeClient: This Game instance makes use of this client to interact with the server.

Assets for the game can be downloaded here.

The file Actions.sks can be deleted. We will not make use of this. The scene file GameScene.sks will be adjusted in order for it to show a game board and a status label using the provided assets.

The app will make use of the StarScream library, since Perfect is not available for iOS. The StarScream library is included as a cocoapod.

The TicTacToeClient Class

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.

The client provides a delegate to communicate with other classes that make use of the client.

import Foundation
import Starscream
import TicTacToeShared

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
    }
}

The GameState Type

The GameState enumeration lists all possible states that we might want to use on the client. The enumeration also includes status messages for display depending on the state.

import Foundation

enum GameState {
    case active // the player can play his turn
    case waiting // waiting for the other player's turn
    case connected // connected with the back-end
    case disconnected // disconnected from the back-end
    case stopped // game stopped, perhaps because the other player left the game
    case playerWon // the player won
    case playerLost // the player lost
    case draw // the game ended in a draw
    
    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"
        }
    }
}

The Game Class

We can then implement the Game class. The game keeps track of the state using the GameState enumeration.

Internally the game makes use of the TicTacToeClient to send messages to the server. The TicTacToeClientDelegate is implemented as well, to handle messages sent from the server to the client. The game creates it's own Player instance, keeps track of the board state and assigns a tile type (X or O) to the Player if no tile type has been assigned already.

import Foundation
import TicTacToeShared
import CoreGraphics

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.board = [Tile]()
            
            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
        }
    }
}

The GameScene Class

Now we need to implement the GameScene and adjust the GameScene.sks file in order for it to show a board and status label. First open GameScene.sks and make it look as follows:

gamescene-sks.png

The game board asset as well as the X and O piece assets can be taken from my GitHub project. These assets should be added to the Assets.xcassets directory in the project and be named GameBoardBackground, Player_X and Player_O.

Make sure the board size is 300 x 300 and it's anchor point is in the bottom left position. The positioning is important for placing the player tiles properly. The board should be centered in the screen. The node name should be GameBoard.

At the bottom of the scene, add a label. As font I've used Helvetica Neue Light 24.0 and text color is white. This label node should be named StatusLabel.

As for the GameScene class itself, we need to add a function to handle touch events and a render function. As for the rendering we will basically remove all nodes each frame and then re-add nodes based on the game board. This is not very efficient of-course, but it works fine enough for this simple app. When a player touches the view, depending on the current state we might start a new game, do nothing (e.g. it's the other player's turn) or play a tile.

import SpriteKit
import GameplayKit
import TicTacToeShared

class GameScene: SKScene {
    var entities = [GKEntity]()
    var graphs = [String : GKGraph]()
    
    private var gameBoard: SKSpriteNode!
    private var statusLabel: SKLabelNode!
    
    lazy var tileSize: CGSize = {
        let tileWidth = self.gameBoard.size.width / 3
        let tileHeight = self.gameBoard.size.height / 3
        return CGSize(width: tileWidth, height: tileHeight)
    }()
    
    override func sceneDidLoad() {
        Game.sharedInstace.start()
    }
    
    override func didMove(to view: SKView) {
        self.gameBoard = self.childNode(withName: "GameBoard") as! SKSpriteNode
        self.statusLabel = self.childNode(withName: "StatusLabel") as! SKLabelNode
    }
    
    func touchUp(atPoint pos : CGPoint) {
        // When a user interacts with the game, make sure the player can play.
        // Upon any connection issues or when the other player has left, just reset the game
        switch Game.sharedInstace.state {
        case .active:
            if let tilePosition = tilePositionOnGameBoardForPoint(pos) {
                Game.sharedInstace.playTileAtPosition(tilePosition)
            }
        case .connected, .waiting: break
        default: Game.sharedInstace.start()
        }
    }
    
    override func touchesEnded(_ touches: Set<UITouch>, with event: UIEvent?) {
        for t in touches { self.touchUp(atPoint: t.location(in: self)) }
    }
    
    override func update(_ currentTime: TimeInterval) {
        self.statusLabel.text = Game.sharedInstace.state.message
        
        drawTiles(Game.sharedInstace.board)
    }
    
    func tilePositionOnGameBoardForPoint(_ point: CGPoint) -> CGPoint? {
        if self.gameBoard.frame.contains(point) == false {
            return nil
        }
        
        let positionOnBoard = self.convert(point, to: self.gameBoard)
        
        let xPos = Int(positionOnBoard.x / self.tileSize.width)
        let yPos = Int(positionOnBoard.y / self.tileSize.height)
        
        return CGPoint(x: xPos, y: yPos)
    }
    
    func drawTiles(_ tiles: [Tile]) {
        self.gameBoard.removeAllChildren()
        
        for tileIdx in 0 ..< tiles.count {
            let tile = tiles[tileIdx]
            
            if tile == .none {
                continue
            }
            
            let row = tileIdx / 3
            let col = tileIdx % 3
            
            let x = CGFloat(col) * self.tileSize.width + self.tileSize.width / 2
            let y = CGFloat(row) * self.tileSize.height + self.tileSize.height / 2
            
            if tile == .x {
                let sprite = SKSpriteNode(imageNamed: "Player_X")
                sprite.position = CGPoint(x: x, y: y)
                self.gameBoard.addChild(sprite)
            } else if tile == .o {
                let sprite = SKSpriteNode(imageNamed: "Player_O")
                sprite.position = CGPoint(x: x, y: y)
                self.gameBoard.addChild(sprite)
            }
        }
    }
}

Update GameViewController

Finally we can make some adjustments to the GameViewController. For example our app will only provide the portrait orientation.

import UIKit
import SpriteKit
import GameplayKit

class GameViewController: UIViewController {
    override func viewDidLoad() {
        super.viewDidLoad()
        
        if let scene = GKScene(fileNamed: "GameScene") {
            // Get the SKScene from the loaded GKScene
            if let sceneNode = scene.rootNode as! GameScene? {
                
                // Copy gameplay related content over to the scene
                sceneNode.entities = scene.entities
                sceneNode.graphs = scene.graphs
                
                // Set the scale mode to scale to fit the window
                sceneNode.scaleMode = .aspectFill
                
                // Present the scene
                if let view = self.view as! SKView? {
                    view.presentScene(sceneNode)
                    
                    view.ignoresSiblingOrder = true
                    view.showsFPS = true
                    view.showsNodeCount = true
                }
            }
        }
    }

    override var supportedInterfaceOrientations: UIInterfaceOrientationMask {
        return .portrait
    }
    
    override var preferredStatusBarStyle: UIStatusBarStyle {
        return .lightContent
    }
}

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
post comments1Reply
sagar harish
6 years ago

Get the most fun game try the link and found the http://robloxfreerobuxgenerator.com/ roblox game i am the big fan this online board game i lie the board games thanks this awesome shear.