2026-07-05

Localquiz: a real-time multiplayer quiz engine without an SPA

Localquiz is a data-driven game engine for multiplayer quizzes, a.k.a. pub quizzes. You hand it a set of questions written as data in a text format, and it turns them into a live game that people gathered together play from their own internet-connected devices.

I made it as a solo developer in my (limited) free time over the past year. This post is about the tools I used to lower my cognitive overhead and the overall effort making Localquiz: the tools that enabled creating more complex and enjoyable quizzes.

Questions as data

In order for a game engine to be reusable, it needs to run with many sources of game content. Localquiz does it by defining a data format for its supported content: the quiz questions. The format is defined in a clojure.spec and uses the syntax of the EDN format. It allows writing quiz questions as text that is readable to both humans and machines. You can write it with any text editor or even generate the questions programmatically. Localquiz will happily run any questions valid according to this format.

The format supports several kinds of questions:

  • Multiple choice: Players are given multiple choices to pick their answers from.
  • Yes/no: Players can answer either “yes” or “no”.
  • Open question: Players can write any answer to the question. When comparing answers with the expected correct answer, minor spelling differences are ignored.
  • Sorting question: Players must sort a list according to the question’s instructions.
  • Percentage range: Players answer with a percentage, scored by how close they got within a tolerance band.
  • Player choice: Players must pick one of the players as their answer.

To make this concrete, here’s an example multiple choice question:

{
  :type :multiple
  :text [:div
         [:audio {:src "https://cdn.freesound.org/previews/155/155115_199526-lq.mp3"}]
         [:p "What is the animal making this sound?"]]
  :choices [{:text "Ferret" :correct? true}
            {:text "Makak rhesus"}
            {:text "Chipmunk"}
            {:text "Your uncle"}]
  :note [:p [:a {:href "https://freesound.org/s/155115/"} "Ferret by J.Zazvurek"] " - License: Attribution 4.0"]
}

A question can contain any (safe) HTML a browser can render. The question format uses Hiccup to express HTML. This way, a question can embed an image, an audio clip, or a video. If a question contains <audio>, it is played when the question appears. The optional :note is shown when the answer is revealed.

Perhaps more interesting is the ability to define how to score answers to each question. As the default, if no :scoring is specified, players who gave the correct answers score points.

When :scoring is set to :consensus, players score points in proportion to how many of the other players answer the same as them. For example, if all players answer the same, everyone scores 1 point, achieving full consensus. If a player’s answer matches half of the other players, they score half a point. Consensus scoring requires you to think about how other players think. The winners are likely those players who know their co-players best and can predict their answers.

The :majority scoring goes further towards consensus, and perhaps compromise: players score a point when they answer the same as the majority of the players. If they don’t reach majority consensus, none of them scores any points.

Consensus scoring works well with questions that don’t have a right answer, for example, questions about taste or opinion. Also, the player choice questions work only with a consensus scoring. While these questions may work the best among friends, hedging your bets when playing with strangers can be an appealing challenge too.

The data format for quiz questions is designed to be extensible. It uses Clojure data structures, such as maps, and keywords for multiple dispatch, for example, to allow adding scoring methods. If you want to make your own quiz questions, follow the instructions in the documentation.

Back-end reactivity with Datastar

The engine that animates the questions into life is built using Datastar. For the “innovation budget” I spent on Datastar, I got the key lever that lowered the cognitive overhead of building Localquiz. Datastar is unlike the dominant paradigm of JavaScript frameworks for single-page applications (SPAs), such as React. It lets you keep application state on the server and update client views reactively when the state changes. The Tao of Datastar sums it best.

When a client joins Localquiz, it uses Datastar to open a long-lived HTTP connection receiving Server Sent Events (SSE). The server subscribes each connected client to a Clojure core.async channel. The server listens to changes of its database (Datahike, an embedded Datalog database in the Datomic lineage), determines which clients are affected by the changes, and publishes events to the clients’ channels, signalling that the clients should refresh their views.

If a client’s channel receives a refresh event, the server re-renders its view using the current database state. If the hash of the new view differs from the hash of the previous view, it is sent to the client via a Brotli-compressed stream. Since the sequence of views mostly evolves via small changes, using stream compression is particularly effective due to large overlaps between successive views, greatly reducing the volume of data to be transferred. Back on the client-side, Datastar receives the new view and morphs its changes into the current view, achieving reactivity without full-page reloads.

Datastar also supports client-side reactivity via signals. Signals are $-prefixed variables that automatically track and propagate changes to any HTML expression that references them. Localquiz uses signals for ephemeral UI state, such as flags telling whether to display loading indicators. The server can patch signals directly over SSE (patch-signals!) without regenerating the full view HTML and morphing the DOM, and clients can mutate them through data-bind and data-on event handlers.

The architecture of Localquiz follows Command Query Responsibility Segregation (CQRS). Commands and queries go through their separate one-way channels. Commands travel from clients → server via HTTP POST requests, mutate state, and return no data. Queries open long-lived SSE connections via HTTP GET requests, read the state, and update HTML views by pushing data from server → client. Commands go as prayers to the clouds, views come down as rain. Client-side view is a function of server-side state. This unidirectional data flow keeps clients consistent with one source of truth and makes reasoning about state changes simple.

The implementation of Localquiz borrows heavily from Hyperlith by Anders Murphy. It rests on the shoulders of the Datastar community.

History, or how I got here

Localquiz is the third quiz engine I’ve built. Each one shows what I considered the best front-end tooling available at the time. This brief tour of history can explain how I arrived at Localquiz.

  • 2015: I made DB-quiz based on the Czech TV show AZ-kvíz. It generates quiz questions from DBpedia, using either the English or Czech Wikipedia as a source. It is an SPA implemented in ClojureScript with Reagent. Players take turns in the game using the same browser. As a bonus, I managed to publish a scientific article about it.
  • 2024: I made Dataquiz, a game engine for AZ-kvíz that introduced questions as data. Again, there is no multiplayer and players share the same screen. It is a purely client-side SPA developed in ClojureScript with Re-frame, which offers one of the clearest mental models for unidirectional data flow, nicely explained using the water-cycle metaphor.
  • 2026: I made Localquiz, finally a real-time multiplayer quiz engine built with Datastar.

Even when making Localquiz, I went through several iterations of its data flow. The key lesson I learnt from The Tao of Datastar was to unlearn the old habits and don’t make an SPA. For a whole class of applications - anything where the server can be the source of truth and the network is fast enough to ship HTML - the multiplayer quiz among them, the browser can go back to being a browser. The result is less code, fewer bugs, and a snappy game that just works when a room full of people open it at once.

No comments :

Post a Comment