Building a Multiplayer Game
Building multiplayer games requires a lot of synchronization logic and event systems. However, using Convex, we get a lot of this for free! Follow along here as we build a complex multiplayer game on Convex, leveraging its reactive-by-default queries, transactional mutations, backend storage, and scheduled functions.
So what’s the game?
We will be building a game where you generate an image by submitting a prompt. We use Dall-E 2, a service by OpenAI, to turn the prompt into an image. Then, without showing your description, your friends submit alternate prompts that seem plausible. Once everyone has submitted an alternate caption, everyone votes on which prompt they think was the real one and you get points if other people choose your prompt. It’s a ton of fun. Check out the prototype here and the code here.
My steps for building this game:
- Sketch a UX prototype on paper.
- Validate any technical challenges with targeted demos.
- Identify the actions and state in each phase of the game.
- Model the server data in the schema.
- Build the serverless functions, adjusting the data model iteratively.
- Build a rough UI, wired up to the serverless functions.
- Polish the UI and UX iteratively.
Prototyping
The beauty of low fidelity prototyping is in the speed of iteration. When I use tools that are designed to allow a high level of polish and control, I can lose a lot of time in trying to make it look professional. By committing to throwing away the prototype, it frees you up to cut a lot of corners to answer the more existential questions. Is this idea any good? What is the storyline or user journey? I started with paper and pen, annotating ideas and behaviors, and playing through the experience in my mind. A trick I learned from my friend Bernardo, the most creative designer I know, is the power of using Keynote for rapid prototyping. I know it sounds crazy, but I gave it a shot for this project and am glad I did. Having something concrete to discuss with engineers and designers grounds the conversation, and having it in low fidelity keeps the discussion away from UI bikeshedding. This is the slideshow I put together:
Keynote Prototype
Validating technical challenges
Using the Dall-E API to generate images
The first unknown is working with the OpenAI API to generate an image from a prompt. We tackle this early since there won’t be a game if we can’t get this working. To read more about using Dall-E with Convex, check out the post here. Instead of needing to build the whole game to test this, I just copied the Convex tutorial so I could spend most of my time on the critical code.
Using sessions for anonymous users
Following up on the post on using sessions in Convex, we are enabling users to play this game without logging in by creating ephemeral users when creating a session. This is important, as asking someone to log into a service before they’ve gotten any benefit is a challenge to adoption. Read this post to learn more about different approaches to representing anonymous users. In a future post, we’ll tackle the tricky business of transitioning anonymous users to authenticated users when they log in, and keeping data consistent in the process.
Screenshot of the chat app in use
Outlining actions and state
As part of building the prototype, I was able to think through the various stages of the game, and by having a rough draft of the UI, I could plan out what the interaction points are and what information needs to be surfaced. This differs from the data in the database, as it is optimized for presentation and hiding information that would allow clients to cheat the game. For instance, when a user is submitting an image with a prompt, they should be able to see who else has finished submitting, but not receive their prompts or images. In hindsight I wish I had gone even deeper here, defining the typescript types for each stage, as a target when implementing the queries and mutations later.
Modeling data with schema.ts
Once we know what information the client needs, it’s easier to decide a convenient and consistent format to store the data at rest. One decision I made was to have a single game with references to a “round” for each generated image. I planned to re-use UI components to build a public version of the game on the homepage, and decided that was the right level of abstraction to represent both. Convex allows you to store both documents, and relationships between documents.
When working in typescript, it is very convenient to have auto-complete. I modeled these in convex/schema.ts
instead of raw typescript types, since that would give me types from all database queries, and warn me if I was inserting incompatible data. By having end to end typing, iterating on the data schema is as easy as modifying schema.ts
and letting typescript tell me where I needed to update things, both in the server code and in the frontend React code!
Serverless functions
For my functions, I leveraged zod validation when I received data from the client, and segmented my functions between a few files separated by function. Submissions, rounds, games, users, etc. all get their own namespace for functions. To model the game data, I decided to have a single function that returned different state based on what stage the game was in. Because convex queries are reactive, as the game state updates the new state is automatically pushed to clients.
Keeping a scheduled jobs table
While building the demo to generate Dall-E images, it became clear that the OpenAI API is slow. Sometimes the requests would take over thirty seconds and time out. In our demo we waited on the request from the client. In this post we discuss managing async work by keeping track of a background job’s state in a table.
Screenshot of code
Sharing types between the client and server
While we get types on the client and server by default with Convex, the return type of a function can be very complex, and if you start passing it around, it’s convenient to have it defined as a typescript type you can reference in both places. It’s not a good idea to import server code into the client, as it can expose server logic, so I put types that I want to import from the server and client in to convex/shared.ts
.
Composing zod types with unions
For retrieving the round data, the data is pretty different depending on the phase of the game. By using a shared typescript type, I could use a type as props for a page on the frontend and also compose it as a return type on the server. Furthermore, by defining it in zod, I could validate the server’s response like:
1export const getRound = queryWithSession(
2 withZodArgs(
3 [zId("rounds")],
4 async ({ db, session, storage }, roundId) => {
5 ...
6 },
7 z.union([LabelStateZ, GuessStateZ, RevealStateZ])
8 )
9);
10
To get the typescript type for the frontend, I have this in shared.ts
:
1export type ClientGameState = z.infer<typeof ClientGameStateZ>;
2
Building a rough UI
In the spirit of iterating quickly, I decided to write zero css to start. I used pico.css so it would look usable for playing with friends and coworkers, and just focused on getting each page wired up to the state coming from the server. If there had been parallel frontend and backend development, I would have probably made stub versions of the serverless functions that just returned static data for each stage, so this work could have happened in parallel.
Play the prototype here!
Stay tuned for more updates as we polish the game!
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.