I spent the past few weekends building a multiplayer minesweeper game on the web. It’s now live, so get a nerdy friend and try it out! In this post, I’ll list out the things that I picked up along the way. I started roughly from scratch, so if you also have little background but want to do something similar, you can use this post as a starting point.
Overall, it was easy if you know how to build it, but knowing how was not easy.
The project is to build a web game. The game play is like regular minesweeper, except that there are 2 players mining the same map, and the goal is to race to flag 50 mines out of 99 instead of clearing the whole map. When a player clicks on a mine, it is a free point for the opponent; and when a player flags a grid that isn’t a mine, they get frozen for a few seconds. When a flag is correctly placed, it shows up on both clients, but cleared grids are only visible locally.
Project objectives in descending order of importance:
- Finish the project
- Do it right
Ultimately, the deliverables are a front end web app and a websocket back end to handle the real time messages between front end clients. For both front and back end, there are many ways to build them.
After some research, I settled on vue.js (Vue 3) for the client, and NestJS for the server. None of the decisions here, or below, are obvious by any means. I picked the stack based on the following criteria.
- Popularity. Solutions to common problems can be googled, and tooling is probably better.
- Nice APIs. Static typing, concise syntax, modularity, etc.
- Easy (for me) to learn. This is important for actually finishing the project.
Some metrics that I did not care about at all: performance, security and bloat.
Here are some of the other choices I considered:
- Native apps instead of web. This is a no go because few people want to install apps these days, especially on the desktop, and performance is extremely unimportant given how simple the game is.
- OCaml client+server, using bonsai (incr_dom, js_of_ocaml) for front end. I am most proficient in OCaml and I like the language. But frankly, the popularity checkbox is extremely unchecked, and the syntax isn’t even nice. Unless you already work in an OCaml repo, using OCaml for web clients is pretty hard to justify.
- React client. I mean, you can’t go wrong with react, since it’s the most popular choice. But I find Vue simpler to use and more opinionated. In this case, Vue having a designated way to do things is a plus, since I’m here to build a game, not to form opinions about how to build a game.
- Other client frameworks like angular, svelte, solid, etc. I didn’t seriously consider these, since they seem to be less popular that React and Vue.
- Other languages for the server, e.g. python, go. For simplicity, I think using the same language for both the client and the server is good, and in this case that language is TypeScript. So I didn’t seriously consider these.
- Not using a back end framework. NestJS is designed to have opinions on how to do things, which again is good when I don’t have opinions.
Here is a grab bag of things that I used.
- VS Code
- socket.io for websocket communication in both client and server
- Tailwind css
- TypeScript for both client and server
- Font awesome for icons
- Google font
- Vuex (to persist player name)
Things that weren’t obvious to me at first:
- For servers to be able to push updates to clients, either you need a websocket connection between the two, or you use the server push event API. I didn’t look closely at the latter, as I believe it is much less used.
- There are actually 2 web servers: one to serve the front end through a GET request, and one to serve the websocket back end. It is possible to serve both from the same server, but as far as I can tell people don’t do that.
The server handles:
- Creating/joining game rooms
- Generating new game map
- Message relaying between clients
- Tracking which client flagged/bombed a grid first
And the client does the rest.
- The client tracks the state of each grid, which can be one of the following: unclicked, clicked, flagged by local player before server ack, flagged by either player after server ack, bombed by either player.
- Flags before server ack are displayed as flagged by the local user, but ignored in score counting. This ensures the user sees no lag and there is no race for deciding the winner.
- The client requests a new game automatically a few seconds after the game ends.
This design is similar to rollback netcode, although greatly simplified because the game is very simple. To explain what that is, we first have to explore a few alternatives.
A multiplayer game is like a distributed state machine. You have a state, and all players provide inputs to change it. There are a few designs that achieve this.
- Server as the only master: Clients send user inputs to the server, server sends back the current state for display.
- Designated client as the only master: Server only relays messages between the master client and other clients. Only the master client can change the state and disseminate it to other clients.
- All clients run the state machine, and proceed only when all inputs from all clients arrive. (This is called deterministic lockstep.)
- All clients run the state machine, and guess the inputs from other clients before they arrive. When they arrive, replay the state machine to correct wrong guesses. (This is called rollback netcode.)
1 is perhaps the simplest. But it suffers from two issues: players experience a lag between making an action and getting a response due to waiting for the server, and the server, which is a shared resource, has to do more work. 2 and 3 also have the issue of lag.
Even though there is no explicit state machine, the design explained above is like rollback netcode, because by displaying local flags immediately, we are basically guessing that the opponent did nothing unless we later learn otherwise. Since the game is simple, using the above state tracking logic greatly simplifies the code, while achieving the same behavior as rollback netcode.
After a few weekends, I got the game working on localhost. Excluding boilerplate, the client took around 800 lines and the server under 200. But deployment is another inevitable battle.
I ended up pushing both the client and server code to Github private repos. I linked the server repo to AWS CodePipeline and Elastic Beanstalk, linked the client repo to AWS Amplify and got a domain name from Route 53 to sign SSL certs. Now I just have to git push my code and everything works automatically, which is nice.
For my previous hosting experience, I’ve been renting an EC2 instance and doing things like ssh, scp, cron jobs, Let’s Encrypt and so on. This time, once I figured out what AWS services I needed, setting them up was much less painful than what I did in the past manually. Again, easy once you know how, because there are so many AWS products and just knowing which ones are relevant is already a bunch of work.
There were a few more issues I had to deal with at this stage.
- Client needs to talk to different servers depending on prod/dev mode. This was solved using environment variables.
- AWS instance ran out of memory building the docker image for the server, because building takes more memory than running it. This was solved by adding a prebuild hook to add swap space to the instance.
- Client couldn’t talk to server unless the server has https. This was solved by buying a domain name from AWS and signing a cert for the load balancer of the back end. For this reason I have to use a load balancer even though there can only be one server (otherwise clients may not see each other).
I did not look into AWS competitors, as I was already an AWS customer, and the popular sentiment seems to be that AWS is still the leader in cloud services. Self hosting was a nonstarter, because there is no concern about data integrity (the server persists nothing on disk), and I only care about building the game, not everything else that runs it.
When I set up the repo and deployment flow, I made sure that I would not have to do this again for the next game. When I build the next little thing, I should be able to just write some more code, test it locally, push to Github, and then I’m done.
Now I just need an idea…