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:
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.
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.