DEV Community

Amol Srivastava
Amol Srivastava

Posted on

Building Local Multiplayer iOS Experiences Without a Server

Every "local multiplayer" tutorial you'll find assumes two devices on the same Wi-Fi network, then walks you through Bonjour service discovery. That's the easy 80%. The part nobody covers is what happens when there's no Wi-Fi at all — two phones, a restaurant with no guest network, both parties expecting the app to just work. That gap is where MultipeerConnectivity actually earns its keep, and it's the backbone of every phone-to-phone experience I've shipped.

Why MultipeerConnectivity over a backend

For a two-person local game or icebreaker app, a server buys you nothing. It adds latency, a cost line item, an account system nobody wants to sign up for, and a single point of failure for a feature that's fundamentally "two people in the same room." MultipeerConnectivity uses Bluetooth and peer-to-peer Wi-Fi automatically, falling back between them without you writing that logic yourself. No network required, no accounts, no server bill.

The three objects you actually need

import MultipeerConnectivity

final class LocalSession: NSObject, ObservableObject {
    private let myPeerID = MCPeerID(displayName: UIDevice.current.name)
    private let serviceType = "my-app-sync" // lowercase, 1-15 chars, no special chars beyond hyphen

    private lazy var session: MCSession = {
        let session = MCSession(peer: myPeerID, securityIdentity: nil, encryptionPreference: .required)
        session.delegate = self
        return session
    }()

    private lazy var advertiser = MCNearbyServiceAdvertiser(
        peer: myPeerID, discoveryInfo: nil, serviceType: serviceType
    )

    private lazy var browser = MCNearbyServiceBrowser(peer: myPeerID, serviceType: serviceType)

    @Published var connectedPeers: [MCPeerID] = []
}
Enter fullscreen mode Exit fullscreen mode

MCSession is the actual pipe data flows through. MCNearbyServiceAdvertiser broadcasts "I'm here, invite me." MCNearbyServiceBrowser looks for those broadcasts. In practice, both devices run both roles simultaneously — you don't design a client and a host, you design two peers that happen to connect to each other.

The delegate methods that matter

Most of MCSessionDelegate is boilerplate you'll paste once and forget. Two callbacks are worth understanding properly:

extension LocalSession: MCSessionDelegate {
    func session(_ session: MCSession, peer peerID: MCPeerID, didChange state: MCSessionState) {
        DispatchQueue.main.async {
            switch state {
            case .connected:
                self.connectedPeers.append(peerID)
            case .notConnected:
                self.connectedPeers.removeAll { $0 == peerID }
            default:
                break
            }
        }
    }

    func session(_ session: MCSession, didReceive data: Data, fromPeer peerID: MCPeerID) {
        // Decode and publish into your app's state on the main thread.
        // This fires on a background queue — never touch @Published state
        // without hopping to main first, or SwiftUI will silently drop updates.
    }
}
Enter fullscreen mode Exit fullscreen mode

The didChange callback is your entire connection-state model. Don't build a separate "is connected" flag elsewhere — derive everything from connectedPeers.

What actually breaks in production

Advertise and browse at the same time, always. Early versions of these apps had a "Host" and "Join" button. Users pick the wrong one, or one person's app is slow to open the browser and misses the advertisement window entirely. Have both devices advertise and browse simultaneously from the moment the feature screen opens — whichever one the OS connects first wins, and neither user has to make a choice that can be wrong.

Handle .connecting as a real UI state. There's a window — sometimes a couple of seconds — where a peer is found but not yet connected. If your UI jumps straight from "searching" to "connected," a slow handshake reads as a frozen app. Show a distinct "connecting to [name]..." state.

Data messages are unordered relative to each other unless you make them not. If you send two pieces of state in quick succession, don't assume they arrive in the order you sent them. Either send a single serialized snapshot of state per update instead of granular deltas, or include a sequence number and reconcile on the receiving end.

Test with Bluetooth off and on, and with both. Peer-to-peer Wi-Fi and Bluetooth have different range and reliability characteristics. An app that only gets tested on a desk with both radios full-strength will surprise you in a noisy real-world environment. Specifically test the case where discovery starts working, then drops mid-session — your reconnect logic is the part everyone forgets to build.

Once this pattern is in place, "local multiplayer" stops being a feature you dread building and becomes a five-minute addition to any two-person app idea — no backend ticket required.

Top comments (0)