Multiplayer game using Group Activities
In this article we are going to create a simple multiplayer Pong game and use Group Activities to handle the in-game realtime communication between both players.
In fact our focus will be more on the communication side and not so on the game logic implementation which is rather simple. If you want to find out more details you can also check out the final code implementation.
Group Activities
The Group Activities framework was introduced in 2021, along with iOS 15. It uses the FaceTime infrastructure and enables realtime, shared experiences.
- Handles users and sessions
- Enables realtime messaging: tcp or udp
Getting started
First of all let's add the Group Activity capability to our app target.
Then let's implement the GroupActivity protocol and define the metadata
struct PongActivity: GroupActivity {
var metadata: GroupActivityMetadata {
var metadata = GroupActivityMetadata()
metadata.title = NSLocalizedString("Pong", comment: "Let´s play pong")
metadata.type = .generic
return metadata
}
}
If there is not yet a FaceTime call active, we can present the user a GroupActivitySharingController
which allows the user to initiate a call.
let controller = try GroupActivitySharingController(PongActivity())
present(controller, animated: true)
Once the call is established the user will move to a isEligibleForGroupSession
state.
At this point we can activate the group activity
try await PongActivity().activate()
Finally we listen if a group session is initiated and configure it
for await session in PongActivity.sessions() {
configureSession(session)
}
In the session configuration we will do three things:
- Subscribe to the group session publishers
$state
and$activeParticipants
. With this we will be able to monitor the current session state (waiting
,joined
,invalidated
) and know if users join or leave the group
- Create a reliable and an unreliable messenger to be able to send messages. The first one will be used when we need to ensure the message will be received, while the second will be used when we want to ensure minimum delay.
let reliableMessenger = GroupSessionMessenger(session: session, deliveryMode: reliable)
let unreliableMessenger = GroupSessionMessenger(session: session, deliveryMode: unreliable)
- Listen to the message types we expect to receive from other participants.
for await (message, _) in reliableMessenger.messages(of: GameStateMessage.self) {
handleMessage(message)
}
With all this in place, we are finally ready to start creating shared group activity sessions. Let's see how this translates to our Pong game.
Players
To define the two players that are going to participate in the game we are going to sink
on the $activeParticipants
publisher
groupSession.$activeParticipants.sink { [weak self] participants in
//handle participants
}
.store(in: &subscriptions)
If the participants count
is equal to 1
, it means you are the only participant in the group session. In this case we mark this user as inControl
and wait until a new player joins to be able to start the game.
What does inControl mean?
This concept is also known as session owner. The owner of the session holds the source of truth of the game and notifies the other player if anything changes.
Why do you need a session owner?
Let's take for example the game score. It's crucial that we only have one source of truth for this. It wouldn't make sense if player A displays a different score (ex: 0 - 0) than player B (ex: 2 - 1). Both have to be in sync.
Once both players have joined the session we can start playing.
Messaging
To be able to keep the game in sync for both players we are going to send two kind of messages.
Note: The message has to conform to Codable in order to be sendable.
- Game state message: This message holds the main game state and the current score. For this message we will use the reliable messenger to ensure the message is received and both players are always in sync
struct GameStateMessage: Codable {
let score: [UUID: Int]
let state: GameState
}
enum GameState: Equatable, Codable {
case notReady(PlayerType)
case ready
case playing
case goal
case gameOver
}
- Game update message: This message holds the in-game information describing the player and ball position. As this message will be sent on every frame, we will use the unreliable messenger to ensure a minimum delay.
struct GameUpdateMessage: Codable {
struct Ball: Codable {
let position: CGPoint
let velocity: CGPoint
}
let player: UUID
let playerPaddle: CGFloat
let ball: Ball
}
Both players send game update messages to notify if the other player has moved. In case of the ball, the player inControl
ignores the data, as it already has the source of truth. The other player updates the position of the ball only if necessary.
unreliableMessenger.messages(of: GameUpdateMessage.self)
.sink { [weak self] message in
guard let self else { return }
self.moveOpponent(x: 1 - message.playerPaddle)
guard !self.isInControl else { return }
let ballVelocity = message.ball.velocity * -1
if self.ball.velocity != ballVelocity {
self.updateBall(position: 1 - message.ball.position, velocity: ballVelocity)
}
}
.store(in: &subscriptions)
There are several ways we could have solved this synchronization. One thing to keep in mind is that ideally we try to do as much as possible on device to make the experience as smooth as possible.
Note: We are inverting opponent position and ball velocity as each player sees a mirror of what the other player is seeing.
Also important to mention is that in this kind of applications it is key not to work with absolute values, as two players can be on different devices with different screen sizes. All positions we send are normalized values (0-1)
With this in place we have all we need to join a group session and synchronize the game play between the two players.
As I mentioned at the beginning, the actual game implementation is not the focus of this article. In fact, the game view is a simple SwiftUI Canvas view overlaying labels and buttons on top.
Note: For a production ready game, we might want to choose a proper game engine like SpriteKit for example.
If you want to find out more details you can also have a look at the final code implementation.
If you take a look at the code you might wonder what is the reasoning behind the project structure and its architecture that was chosen. In this kind of apps it can really make a great impact.
Let's take a look at it in Why app architecture matters.