DEV Community

Cover image for I Built an Offline Kahoot Clone Because Wi-Fi at Conferences Kept Ruining the Fun
Pulkit Midha
Pulkit Midha

Posted on

I Built an Offline Kahoot Clone Because Wi-Fi at Conferences Kept Ruining the Fun

If you have ever tried running a live Kahoot quiz with 50+ people in a conference room, you know the pain. Half the audience is fighting for bandwidth, some can not even load the game, and the host is standing there awkwardly while everyone stares at a loading spinner.

I kept running into this problem at developer meetups and workshops. The irony was not lost on me - here we are, all sitting three feet apart, and we need a round trip to some cloud server just to play a quiz together.

So I built KahootP2P. It is a Kahoot-style quiz game for iOS that runs entirely over local peer-to-peer networking. No internet. No router. No cloud. Just devices talking directly to each other, powered by Couchbase Lite Enterprise's P2P replication.

This post walks through why I built it, the architecture decisions I made, and how Couchbase Lite made the hard parts surprisingly simple.

The Problem with Cloud-Dependent Multiplayer

Kahoot is brilliant. The speed-based scoring creates real tension, the simple four-color answer grid works on any screen size, and the leaderboard after each question keeps everyone engaged. But it has a fundamental dependency: every single interaction goes through the internet.

For a classroom with solid Wi-Fi, that is fine. For a packed conference hall where 200 people are sharing a hotel Wi-Fi network? It falls apart fast.

The latency also matters more than you would think. Kahoot awards more points for faster answers. When your correct answer takes 800ms to reach the server instead of 200ms because of network congestion, you are literally losing points through no fault of your own. That is not a great experience.

I wanted something where network latency was measured in single-digit milliseconds, not hundreds. And the only way to get there is to cut out the internet entirely.

Why Couchbase Lite P2P?

When I started thinking about offline multiplayer, my first instinct was Apple's MultipeerConnectivity framework. It handles discovery and basic message passing between iOS devices. But there is a catch - it is designed for mesh topologies where every device talks to every other device. For a quiz game with a host that controls the flow, this creates headaches around consistency and ordering.

What I actually needed was:

  • A single source of truth - The host device should be the authority on scores, question timing, and game state
  • Automatic data sync - When the host writes a new question or updates scores, every player should see it without me manually serializing and routing messages
  • Reliable delivery - If a player's answer takes a moment to sync, it should not get lost

Couchbase Lite Enterprise's peer-to-peer replication fit this perfectly. Here is why:

The URLEndpointListener lets one device (the host) act as a replication target. Player devices connect to it using a standard Couchbase Lite replicator in push-and-pull mode. This gives me a star topology with the host at the center - exactly what a host-authoritative game needs.

The key insight is that I do not have to build a message protocol at all. Instead of thinking about "send question to all players" and "receive answer from player 3," I just write documents to the local database. The replication layer handles getting those documents to the right places.

Architecture: Documents as Game State

The entire game state lives in Couchbase Lite documents. Here is how I modeled it:

Game - A single document that tracks the game title, join code, phase (lobby/asking/closed/finished), and question count. When the host changes the phase, replication pushes it to all players.

class Game: Codable, Identifiable {
    @DocumentID var id: String?
    var title: String
    var joinCode: String
    var phase: GamePhase
    var questionCount: Int
    var createdAt: Date
}
Enter fullscreen mode Exit fullscreen mode

QuestionState - One document that tells every player what question we are on and whether it is still open for answers. The host updates this document to advance the game.

Questions - Individual documents for each question, synced from host to players when the game starts.

Answers - Each player writes their answer as a document. The replication pushes it to the host for scoring.

AnswerResults - After scoring, the host writes result documents back, which replicate to the relevant player.

Leaderboard - A single document with ranked entries, updated by the host after each question.

The beauty of this approach is that the sync is bidirectional and automatic. Players push answers up, the host pushes results down, and Couchbase Lite handles all the conflict resolution and ordering.

Discovery: Finding Games Without a Server

Before devices can replicate data, they need to find each other. I used Bonjour (Apple's zero-configuration networking protocol) for this.

When the host starts a game, two things happen:

  1. A URLEndpointListener starts on an available port
  2. A Bonjour service is published advertising that port
func startHosting(gameId: String) throws {
    var listenerConfig = URLEndpointListenerConfiguration(
        collections: [collection]
    )
    listenerConfig.port = 0  // Let the OS pick a port
    listenerConfig.disableTLS = true

    let urlListener = URLEndpointListener(config: listenerConfig)
    try urlListener.start()

    // Advertise via Bonjour so players can find us
    let service = NetService(
        domain: "",
        type: "_quizblitz._tcp.",
        name: "QuizBlitz-\(gameId.prefix(8))",
        port: Int32(urlListener.port ?? 0)
    )
    service.publish()
}
Enter fullscreen mode Exit fullscreen mode

On the player side, a NetServiceBrowser scans for the _quizblitz._tcp. service type. When it finds a game, the player taps to join, and a Couchbase Lite replicator connects to the host's URLEndpointListener:

func connectToHost(host: String, port: Int) {
    let url = URL(string: "ws://\(host):\(port)/\(db.name)")!
    let targetEndpoint = URLEndpoint(url: url)

    var config = ReplicatorConfiguration(
        collections: [colConfig],
        target: targetEndpoint
    )
    config.replicatorType = .pushAndPull
    config.continuous = true

    let repl = Replicator(config: config)
    repl.start()
}
Enter fullscreen mode Exit fullscreen mode

That is it. Once the replicator is running, documents flow automatically in both directions. No manual message handling.

Scoring: Speed Matters

Like Kahoot, faster correct answers earn more points. But here is the tricky part - in a P2P setup, you can not just use wall-clock timestamps because devices might not have synchronized clocks (and without internet, there is no NTP server to sync with).

My solution uses the host's monotonic clock (via mach_continuous_time) as the single timing reference. When the host opens a question, it records its own uptime in nanoseconds. When an answer document replicates to the host, the host records the receipt time.

let timeDelta = TimingService.elapsedSeconds(
    from: startNs, to: receivedNs
)
let timeBonus = max(0, 1.0 - (timeDelta / Double(timeLimitSeconds)))
let points = isCorrect ? max(100, Int(1000.0 * timeBonus)) : 0
Enter fullscreen mode Exit fullscreen mode

Is this perfectly fair? No. The replication latency adds a few milliseconds of noise. But in practice, over local Wi-Fi, the replication lag is under 50ms. For a game where the time limit is 15 seconds, that noise is negligible. The person who knows the answer and taps faster will still win.

The scoring works on a sliding scale: answer instantly and get 1000 points. Answer at the very last second and get 100 points. Answer wrong and get zero. This creates the same urgency you feel in Kahoot.

Reactive UI with Combine

The UI needs to update in real-time as documents change. Couchbase Lite's Combine integration makes this clean:

db.collectionChangePublisher()
    .receive(on: DispatchQueue.main)
    .sink { [weak self] change in
        self?.handleCollectionChange(change)
    }
    .store(in: &cancellables)
Enter fullscreen mode Exit fullscreen mode

When any document in the collection changes - whether from local writes or incoming replication - this publisher fires. The handler checks if the change is relevant (same game ID) and updates the appropriate state: new player joined, answer received, leaderboard updated.

On the SwiftUI side, the engines are ObservableObject classes with @Published properties. When the host writes a new QuestionState document and it replicates to a player, the player's ClientEngine picks up the change through the collection publisher, updates its @Published properties, and SwiftUI re-renders the view. The whole chain from "host presses Next Question" to "player sees the new question" takes under 100ms on a local network.

The Codable Integration

One of the things that made development faster was Couchbase Lite's Codable support. Instead of manually mapping between documents and Swift objects, I just defined my models as Codable classes:

// Save a model directly
try collection.save(from: game)

// Load it back as a typed object
let game = try collection.document(id: docId, as: Game.self)
Enter fullscreen mode Exit fullscreen mode

The @DocumentID property wrapper automatically maps the Couchbase Lite document ID to a property on the model. This meant I could work with normal Swift objects everywhere and let the SDK handle serialization.

For queries, I used SQL++:

let sql = "SELECT META().id, * FROM _ WHERE gameId = '\(gameId)' 
           AND displayName IS NOT MISSING"
let query = try database.createQuery(sql)
Enter fullscreen mode Exit fullscreen mode

What I Learned

Documents as game state works surprisingly well. I was initially skeptical about using a database replication protocol for real-time game sync. In practice, the latency is low enough that it feels instant, and you get reliable delivery, conflict resolution, and persistence for free.

Star topology is the right call for authoritative games. A mesh network sounds cool, but when one device needs to be the authority on scoring and game flow, a hub-and-spoke model with the host at the center is simpler and more predictable.

You do not need the internet for most local multiplayer. This might seem obvious, but it is easy to default to "spin up a server" when the devices you want to connect are literally in the same room. Couchbase Lite's P2P replication eliminates that entire dependency.

Bonjour discovery just works. I was prepared for a painful debugging session with network discovery, but Bonjour on iOS is rock solid. Devices find each other within 1-2 seconds consistently.

Try It

The full source code is on GitHub: github.com/midopooler/KahootP2P

You will need Couchbase Lite Enterprise (for the URLEndpointListener) and two or more iOS devices on the same local network. Clone the repo, run xcodegen generate, build, and you are playing within a minute.

If you are building any kind of local multiplayer, collaborative, or offline-first experience on mobile, I would strongly recommend looking at Couchbase Lite's P2P capabilities. The fact that I did not have to write a single line of networking code beyond "start the listener" and "start the replicator" saved me a massive amount of time.

The game is simple, but the pattern applies to a lot more than quizzes. Field data collection with team sync. Collaborative note-taking in airplane mode. Point-of-sale systems that keep working when the internet drops. Any time you need multiple devices to share state without depending on the cloud, this same architecture works.

Top comments (0)