Published OnApril 15, 2026April 13, 2026
Build a Multiplayer Emoji Game in SwiftUI with Peer-to-Peer Sync
Build a multiplayer emoji game in SwiftUI that syncs over Bluetooth with no server. Step-by-step tutorial using Ditto's peer-to-peer SDK for iOS.
You can build a fully functional SwiftUI multiplayer game that syncs between devices with no server, no Firebase, and no internet connection. This tutorial walks you through building one from scratch using peer-to-peer mesh networking.
Adding multiplayer to an iOS app usually means setting up Firebase, running a WebSocket server, or wrestling with Apple's GameKit. Each approach pulls you away from what you actually want to build: the game itself. What if you could skip the infrastructure and just have devices talk directly to each other?
That's what we're building today. A two-player Emoji Tic-Tac-Toe multiplayer game where two iPhones in the same room discover each other automatically and sync every move in real time over Bluetooth or WiFi. No cloud. No backend. No internet required.
By the end of this post, you'll have a working multiplayer game and a clear understanding of how peer-to-peer data sync works on iOS. The complete project is on GitHub, ready to clone and run.
Key Takeaways:
- Build a complete two-player iOS game with automatic nearby game discovery
- Sync game state between devices using Ditto's peer-to-peer mesh (Bluetooth, P2P WiFi, LAN)
- No server setup, no Firebase, no internet required
- Handle real-world edge cases: disconnect detection, game cleanup, conflict-free joins
- Game data stays local to nearby devices via P2P sync scopes
Why Peer-to-Peer Instead of a Server?
Most iOS multiplayer tutorials follow the same pattern: set up a Firebase project, configure authentication, write cloud functions, manage WebSocket connections. By the time you've finished the infrastructure, you've barely touched the game.
Here's what the typical stack looks like:
Ditto's Swift SDK handles device discovery, connection management, and data synchronization automatically. You add the SDK, define your data model, and devices find each other over whatever transport is available: Bluetooth Low Energy, P2P WiFi, or local network.
Your game works in a living room with no WiFi. It works at a park with no cell signal. It works anywhere two iPhones can reach each other.
Get started free: Create a Ditto account to get your App ID and playground token. It takes 30 seconds.
What We're Building
Emoji Tic-Tac-Toe is a simple multiplayer game with a twist: instead of X and O, players pick emoji teams. Cats vs dogs. Octopuses vs rockets. Whatever makes the kids laugh.
The game has two screens:
- Lobby: Pick your emoji team, create a game, or tap a nearby game to join it. Nearby games from other devices appear automatically via the P2P mesh.
- Game board: A \"You vs Opponent\" identity bar, a 3x3 grid with pop-in animations, turn indicators, and a win/draw/play-again flow.
The entire game state lives in a single Ditto document:
// One document, one collection, complete game state
{
"_id": "A1B2C3",
"board": ["", "", "", "", "", "", "", "", ""],
"currentTurn": "O", // randomized at creation
"playerXDevice": "...", // creator's device ID
"playerXEmoji": "🐱",
"playerODevice": "...", // joiner's device ID (empty while waiting)
"playerOEmoji": "🐶",
"winner": "", // "X", "O", "draw", or ""
"status": "playing", // waiting → playing → finished → abandoned
"abandonedBy": "" // device ID of player who left
}When Player X taps a cell, the app updates this document. Ditto syncs the change to Player O's device in milliseconds. Player O sees the emoji appear on their board. No polling, no push notifications, no server in the middle.
Step 1: Project Setup
Clone the project from GitHub or create a new SwiftUI project and add the Ditto SDK.
Add Ditto via Swift Package Manager:
- In Xcode, go to File > Add Package Dependencies
- Enter the URL:
https://github.com/getditto/DittoSwiftPackage - Select the latest version and add the
DittoSwiftlibrary to your target
Add required permissions to your Info.plist:
<key>NSBluetoothAlwaysUsageDescription</key>
<string>Uses Bluetooth to sync game moves with nearby players</string>
<key>NSLocalNetworkUsageDescription</key>
<string>Uses WiFi to sync game moves with nearby players</string>
<key>NSBonjourServices</key>
<array>
<string>_http-alt._tcp.</string>
</array>Enable Background Modes in Signing & Capabilities:
- Uses Bluetooth LE accessories
- Acts as a Bluetooth LE accessory
Configure credentials: Copy Config/Ditto.env.template to Config/Ditto.env and paste your values from the Ditto Portal. The .env file is gitignored, so your credentials stay local:
DITTO_APP_ID=your-app-id
DITTO_PLAYGROUND_TOKEN=your-token
DITTO_AUTH_URL=https://xxxxx.cloud.dittolive.app
DITTO_WEBSOCKET_URL=wss://xxxxx.cloud.dittolive.appStep 2: Initialize Ditto with P2P-Only Sync
The DittoManager class handles all sync logic. It's the only file that imports DittoSwift. Credentials are read from the .env file at runtime by DittoConfig.
import DittoSwift
import Observation
@Observable
final class DittoManager {
let ditto: Ditto
var game: Game?
var myRole = ""
var deviceId = ""
init() {
ditto = Ditto(
identity: DittoIdentity.onlinePlayground(
appID: DittoConfig.appID,
token: DittoConfig.playgroundToken,
enableDittoCloudSync: false,
customAuthURL: URL(string: DittoConfig.authURL)
)
)
deviceId = UIDevice.current.identifierForVendor?.uuidString
?? UUID().uuidString
}
func start() async {
do {
_ = try await ditto.store.execute(
query: "ALTER SYSTEM SET DQL_STRICT_MODE = false"
)
// P2P only: game data never leaves the local mesh.
_ = try await ditto.store.execute(
query: "ALTER SYSTEM SET USER_COLLECTION_SYNC_SCOPES = :scopes",
arguments: ["scopes": ["games": "SmallPeersOnly"]]
)
try ditto.sync.start()
} catch {
print("Ditto failed to start: \(error)")
}
}
}Two things to notice here. First, enableDittoCloudSync: false plus the SmallPeersOnly sync scope means game data only flows between nearby devices via Bluetooth, P2P WiFi, and LAN. It never touches the cloud. For a local multiplayer game, this is exactly what you want.
Second, that's the entire networking setup. No server URLs to configure, no WebSocket connections to manage. Call ditto.sync.start() and devices discover each other automatically.
Step 3: Discover Nearby Games
Instead of sharing game codes, we let the Ditto mesh do the work. A lobby observer watches for games with status = 'waiting' from other devices:
private func startLobbyObserver() {
let query = "SELECT * FROM games WHERE status = 'waiting'"
lobbySubscription = try? ditto.sync.registerSubscription(query: query)
lobbyObserver = try? ditto.store.registerObserver(query: query) {
[weak self] result in
let games = result.items.compactMap { item -> Game? in
let dict = item.value.compactMapValues { $0 }
return Game.from(dict)
}
Task { @MainActor in
guard let self else { return }
self.nearbyGames = games.filter {
$0.playerXDevice != self.deviceId
}
}
}
}The registerSubscription tells Ditto to replicate matching documents from the P2P mesh. The registerObserver fires a callback whenever the local store changes. Together, they give us a live-updating list of joinable games from nearby devices.
In the lobby UI, these games appear as tappable cards showing the host's emoji. When no games are nearby, the user sees a friendly empty state with an antenna icon. No game codes to type, no QR codes to scan. Just tap and play.
Step 4: Join a Game Reactively
When a player taps a nearby game, the join flow uses a CheckedContinuation to wait for the game document to arrive from the mesh. No Task.sleep, no polling:
func joinGame(id: String, preferredEmoji: String) async -> Bool {
myRole = "O"
startObserving(gameId: id)
// Wait for the observer to deliver the game document.
let hostGame = await withCheckedContinuation { continuation in
self.gameContinuation = continuation
}
// Don't join if the game already has two players.
if !hostGame.playerODevice.isEmpty {
leaveGame()
return false
}
// Honor the player's emoji unless it conflicts with the host.
let emoji: String
if preferredEmoji != hostGame.playerXEmoji {
emoji = preferredEmoji
} else {
let available = emojiTeams.map(\.emojis[0])
.filter { $0 != hostGame.playerXEmoji }
emoji = available.randomElement() ?? "🐶"
}
_ = try? await ditto.store.execute(
query: """
UPDATE games
SET playerODevice = :device,
playerOEmoji = :emoji,
status = 'playing'
WHERE _id = :id AND status = 'waiting'
""",
arguments: ["device": deviceId, "emoji": emoji, "id": id]
)
return true
}The WHERE status = 'waiting' guard ensures two players can't claim the same slot. If your emoji matches the host's, the game picks a different one automatically. The observer callback resumes the continuation the instant data arrives from the mesh.
Step 5: Make Moves and Detect Winners
Each move places an emoji, switches turns, checks for a winner, and persists the update. Ditto syncs the change to the opponent in milliseconds:
func makeMove(index: Int) async {
guard var g = game,
g.currentTurn == myRole,
g.board[index].isEmpty,
g.status == GameStatus.playing.rawValue
else { return }
g.board[index] = (myRole == "X") ? g.playerXEmoji : g.playerOEmoji
g.currentTurn = (g.currentTurn == "X") ? "O" : "X"
g.checkOutcome()
game = g
_ = try? await ditto.store.execute(
query: "INSERT INTO games DOCUMENTS (:updated) ON ID CONFLICT DO UPDATE",
arguments: ["updated": g.toDictionary()]
)
}Win detection checks eight lines (3 rows, 3 columns, 2 diagonals). First turn is randomized at game creation with Bool.random(), so neither player always goes first. The full multiplayer game logic fits in about 40 lines.
Step 6: Handle Disconnects Gracefully
Real-world multiplayer means players leave. The leaveGame() method handles every scenario:
- Waiting, nobody joined: Delete the game document so it disappears from lobbies.
- Mid-game exit: Mark the game as
abandonedwith your device ID. The opponent's observer detects this and pops them back to the lobby. - Finished game: Delete the document. No stale games accumulate.
func leaveGame() {
// Tear down observers immediately.
observer?.cancel(); subscription?.cancel()
game = nil; myRole = ""
guard let g = currentGame else { return }
Task {
switch g.status {
case "waiting":
_ = try? await ditto.store.execute(
query: "DELETE FROM games WHERE _id = :id",
arguments: ["id": g.id])
case "playing":
_ = try? await ditto.store.execute(
query: "UPDATE games SET status = 'abandoned', abandonedBy = :device WHERE _id = :id",
arguments: ["device": deviceId, "id": g.id])
default:
_ = try? await ditto.store.execute(
query: "DELETE FROM games WHERE _id = :id",
arguments: ["id": g.id])
}
}
}On the observer side, an empty result set (document deleted) or status = 'abandoned' by someone who isn't you triggers a dismiss back to the lobby.
Step 7: Test It
Run the app on two iPhones (or one iPhone and the simulator):
- Device A: Open the app, pick an emoji team, tap Create Game. A spinner shows \"Waiting for opponent.\"
- Device B: Open the app. Device A's game appears under Join Nearby Game. Tap it.
- Play: Take turns tapping cells. Watch moves appear on the other device instantly. A \"You vs Opponent\" bar at the top always shows which emoji is yours.
Now for the fun part: turn off WiFi on both phones. Keep playing. The game still works because Ditto syncs over Bluetooth Low Energy. No internet. No router. Just two phones talking directly to each other.
Try backing out mid-game on one device. The other device pops back to the lobby automatically. Create another game. It appears on the other device's lobby in seconds. This is the multiplayer game experience you can't get with Firebase, WebSockets, or GameKit.
How the Sync Works Under the Hood
When you call ditto.sync.start(), the SDK begins advertising and scanning for nearby Ditto peers on every available transport simultaneously:
- Bluetooth Low Energy: ~100m range, works with zero infrastructure
- P2P WiFi: ~30m range, higher bandwidth, no router needed
- LAN: Full-speed sync when both devices are on the same WiFi network
The SmallPeersOnly sync scope restricts the \"games\" collection to peer-to-peer transports only. Data never leaves the local mesh. For a local multiplayer game this is the right call: you're never going to play tic-tac-toe with someone you can't connect to directly.
When Player X updates the game document, Ditto's sync engine:
- Writes the change to the local database
- Detects nearby peers that subscribe to this document
- Sends the delta (not the full document) over the best available transport
- The receiving device merges the change using CRDTs (conflict-free replicated data types)
CRDTs guarantee that if both players somehow tap at the same instant, the data merges deterministically. No \"last write wins.\" No data loss. Every device converges to the same state. For a turn-based game this rarely matters, but for more complex apps (like a collaborative drawing game or a POS system), it's critical.
Go deeper: Read about how Ditto's mesh networking works or explore the full Ditto documentation.
Get the Code
The complete Xcode project is on GitHub:
github.com/getditto-shared/emoji-tic-tac-toe
git clone https://github.com/getditto-shared/emoji-tic-tac-toe.git
cd emoji-tic-tac-toe
cp Config/Ditto.env.template Config/Ditto.env
# Paste your credentials from portal.ditto.live into Config/Ditto.env
open EmojiTicTacToe.xcodeprojThe project includes five Swift files:
What's Next?
This tutorial covers the basics, but there's a lot more you can build:
- Add an AI opponent for single-player mode (minimax algorithm in ~30 lines)
- Build a tournament bracket that syncs across a group of devices at a party
- Try a more complex game: memory match, drawing game, or tap race
- Deploy on TestFlight and share with friends and family
The same peer-to-peer sync that powers this game also powers point-of-sale systems in thousands of restaurants, tactical data sharing in defense environments, and fleet coordination for IoT devices. The SDK is the same. The architecture scales from a kid's multiplayer game to enterprise infrastructure.
Start building with Ditto for free and see what you can sync.



