From Trivia to TypeScript: QuiP's Domain-Driven Development with Convex
From Trivia to TypeScript: QuiP's Domain-Driven Development with Convex is a guest Stack Post from the community by Charlie McGeorge.
Building a shared language
"There are only two hard things in computer science: cache invalidation and naming things."
– Phil Karlton
A few years ago, my sister introduced me to the world of online trivia. After years of applying to be on Jeopardy!, she finally appeared on the show in 2015 – but that was just the beginning, a rite of passage. The Jeopardy! alumni community is the core of the trivia (or should I say "quizzing"?) world, a community full of brilliant, nerdy, passionate people.
They play LearnedLeague (which is no joke – I only lasted two seasons), they attend SporcleCon, and ever since 2020, they've been competing in weekly quiz leagues on Zoom.
Last summer, six of them set out to create a new league. They wanted to make the quiz format more inclusive and accessible, write questions stretching beyond the trivia canon, and improve on the UX of playing over Zoom. They called themselves Quizzing in Progress, or QuiP. They decided to hire a developer, my sister made the intro, and I got the gig.
From our first meeting, I knew this would be a fun and rewarding project, and I knew that I wanted to build it on Convex. First off, QuiP needed a live-updating screen to show questions, categories, and scores in real time, and I knew I could build that easily with Convex's sync engine. But more than that, I'd be building from scratch, and Convex's end-to-end TypeScript makes it a great platform for domain modeling.
You don't have to take a domain-driven approach to use Convex, but I think it's worth doing. The process of learning everything you can about a domain (in this case, trivia) is satisfying. It builds trust with the community you're serving. And by building a shared language with that community, you end up designing an app that they find intuitive.
For example, with QuiP, my first instinct was to call the person running the game the "host", since it reminds me of a game show. But the online quiz community already has a word for it: the "reader". So when we discussed the app's UI, we would talk about the "player view" and the "reader view", and I used those names for the corresponding React components.
The QuiP team already knew how they wanted their game to work. Two teams, three players each. Rounds 1 and 3 would be a board with multiple categories and questions of escalating difficulty. Round 2 would be something different: a set of lists, like Post-Transition Metals on the Periodic Table or Best Actor Oscar Winners with First Names Starting with "J", which players would take turns trying to fill out, shouting out "Bismuth!" or "Joaquin Phoenix!"
Thus the words list game and board game entered our shared language and began making their way through the codebase. To avoid confusion with the term "game", we decided to refer to a specific gameplay instance as a match. And since the list game doesn't really have "questions", we started calling a question-and/or-answer an item. A set of three rounds' worth of items, pre-written for all teams to play in a given week, we called a quiz.
A natural first question when building a backend: what should the database tables be? We all know what it's like to start building on the wrong abstraction, realize later you really needed something else, and be faced with the agonizing choice between heavy technical debt and a time-consuming refactor. So you want to get this question right the first time if you can.
Once your shared language is established, Convex's frontend-first approach lets you figure it out by working backwards: from words, to screens, to types, to queries/mutations, and finally to tables.
QuiP's player view is a read-only screen that players look at during the game, side-by-side with Zoom video chat. Meant to improve on the Zoom screenshare function, it needed a few basic elements: the scores, a timer, the current question, some categories, and a way to indicate whose turn it is. The reader view looks exactly the same, plus a few extra buttons for the reader to control the game and keep score.
To design the UI, I had to translate these words into visual components – extending the shared language into a visual language. For this, I turned to TypeScript: every component exists to display information, which can be broken down into details using data types. Each detail can be expressed visually as text, color, position, size, visibility... the list goes on. I did my best to keep those expressions simple and direct.
For me, the hardest part was making the design look fun. I asked my friend Olivia for help, and she expertly worked out the fonts, color scheme, and spacing that would produce the right vibe.
With the screens figured out, I moved on to queries and mutations.
Everything on the screen in Convex should be a
query, and those queries
should be lightweight, so I
combined all of the onscreen data into a match state object, which
would be a single row in a matchStates
table, indexed by match ID for
fast lookups. To prevent players from cheating by inspecting WebSocket
messages, I wrote two different queries: one for the player view that
only included the questions/answers that had already been revealed, and
another for the reader view that included everything.
To populate a new match state with content, I needed the items and
categories from the given quiz, as well as the player and team names, so
I wrote an initializeMatch
mutation and added tables for players
, teams
,
items
, and quizzes
.
Finally, I gave each of the buttons on the reader view its own mutation
to update the match state, and voilà, we had a working app. The QuiP
team and I played a few sample games together – they loved the
automatic countdown timer (just a single timerExpirationMs
field in the
match state!) and felt the design was intuitive.
Emboldened to launch and ready for user feedback, they began reaching out to the community to sign up players for a three-week "taster tournament" while I built out the final views: match results and standings.
Competitive rankings can be fraught with emotion, but they're an all-important part of a trivia community: narratives are built, stars are born, players are motivated to keep coming back, and the nerds among us get plenty of numbers to ponder.
We went back and forth a bit on what the stats pages should look like, but for our shared language, we settled on match results, player stats, and team stats; and we defined standings as sorted lists of stats.
For the first version of the page, I cribbed stats table columns from other quiz leagues, like "average percent contribution to team score" – but the QuiP team felt these were a bit too biting for the league they wanted to build, and asked for a different set of columns that were more specific to the QuiP format.
I began to realize that if the stats columns were going to evolve over time, we might not always be able to compute new columns from the old match state data.
At the same time, feedback started coming in from the tournament readers: "we love the design, but we need an undo button."
For these two reasons, the mutable match state was no longer enough. We had to start keeping a history log.
This was the dreaded refactor I had sought to avoid, but Convex's
mutation pattern made it a lot easier – I just had to turn all the
mutations (like selectCategory
and markAnswerCorrect
) into actions
(in the
Redux
sense, not the
Convex
sense). A single mutation, applyAction
, would take the current state and
modify it according to the action (much like a Redux reducer), then save
the new state, and store the action itself in an actions
table indexed
by match ID. Writing applyAction
was easy – I just had to cut and
paste each mutation's code into one big switch
statement.
This also made it possible to implement an applyUndo
mutation: fetch all
the actions for the current match, initialize an empty game state,
sequentially apply all of the actions except for the most recent one,
and save the resulting state. (This pattern doesn't always work: if we
had needed redo, or if undo needed to be super fast, or if we needed
different users to be able to undo just their own changes, we'd be
getting into
CRDT
territory. But in QuiP's case, it works beautifully!)
Now that we had an actions
table, we could stop trying to store stats
info in the match state, and instead just compute the stats directly
from the actions: a computeMatchResults
mutation could pull all the
actions for a match, then count up the total points scored (among other
stats) for each player and team. We'd be able to run that at the end of
each game, and if we needed to redefine the stats, we could just run it
again – the historical actions data served as a ground truth that
would stay the same no matter what.
Sometimes, after a match, the QuiP team will get a dispute report –
someone was marked incorrect but they can prove their answer should have
been accepted, for example. Editing history is easy enough with an
actions
table: just go in and find the action and alter it from
"markAnswerIncorrect"
to "markAnswerCorrect"
, then run
computeMatchResults
and recomputeAllPlayerStats
.
But to my surprise and delight, instead of emailing me to do this for
them, the QuiP team is perfectly happy to log into the Convex
dashboard, look up the player and match IDs, edit the actions
table,
and re-run those functions themselves! Can you imagine a customer
volunteering to remote into a Postgres db and do that?
I asked Hannah from QuiP for her thoughts on her experience with the Convex dashboard, and she replied:
"It's clear that thought has gone into every single detail. From the colour-coding of different entered information, to the automatically-generated chains that allow linked information in a table to be seen easily, to the error messages that are clear and descriptive, at every step I've found the Convex interface to be easy to use and understand. And, the easy backups gives me even more confidence that should I make a mistake, I can easily correct it by reverting to a previously saved version of our database.
In an industry that often feels unwelcoming to outsiders, it was such a refreshing change of pace to have an interface that can easily be understood by someone like myself without a technical background."
Hannah and the QuiP team understood how to update these materialized
views because the functions computeMatchResults
and
recomputeAllPlayerStats
were phrases in our shared language. The
shared language connects everything about the project – the visual
design of the app, the TypeScript codebase, and the backend tables and
functions – back to the community it's meant for, making the whole
system intuitive and understandable to members of that community.
Season 1 of QuiP ran for eight weeks from October to December 2024, with 60 teams (all paying customers!) and overwhelmingly positive feedback. Season 2 will start at the end of January 2025, with 98% of players returning and 17 new teams joining.
So if you're a trivia nerd who wants to try something new and play online with a team of your friends, head over to quizzinginprogress.com – and if you're a developer, or thinking of becoming one, try building a shared language with Convex!
Convex is the sync platform with everything you need to build your full-stack project. Cloud functions, a database, file storage, scheduling, search, and realtime updates fit together seamlessly.