The code is on Github.
I wanted to code and ship something. Between work and my 11 month-old daughter, I don't have a ton of time for side projects so success would require a project of minimal scope. My free time is also highly volatile: some days I get 4 hours of free time, others I get closer to 0.
So, really, my personal goal was to ship a game before returning to work in January.
It's important to note that it's never been a dream of mine to build this little 2D space shooter. I've dreamt of building huge, persisted 2D gaming worlds connected together like websites from planet to planet. I decided to work on something that was 0.1% of the scope of that dream, and the minimal scope was a huge reason why I was successful. Dream big, but start small.
I've been writing small arcade games for more than 10 years. Typically I use libSDL in C++, but since I was making a web game I wanted to see if the game and UI development skills I've developed in C++ could translate effectively to writing a websocket game in Go. I took a similar "game loop" approach to writing a game as I would in C++, where the world state is updated 30 times a second. This is natural in a single-threaded game rendering to a local screen, but in the async nature of the web would require some quality complexity encapsulation.
I implemented the game approximately using the Actor design pattern I've been experimented with in Go lately. The basic idea is to build software as a series of independent goroutines that communicate only via message passing. Go's channel and goroutine primitives service this pattern well.
Since maps are not thread safe in Go you can come up with simple rules about when to make something an Actor: if you need to access the data in >1 context, extract it as it's own goroutine that only exposes a message passing interface. For a caller to receive a value back from another actor, you include a "return channel" in the original message being sent. Now actor A can ask a value from actor B by creating a 'response channel', including it in the message to B, then waiting for a response on the created channel. That's a bit of overhead but it forces you to codify your coupling between modules as the struct being passed from actor to actor, and the benefits outweigh the costs. In some cases, the Game loop actor can keep a reference to the "return" channel to continually send updated world states to the connected players.
The core components of the game are:
- Finds an available game, or creates a new game
- Can generate a 'summary' of all active games
- Websocket Handler
- Connects player to game
- Receives keypresses from websocket, attaches player Id to game event and sends on the game's event channel.
- On connect, passes the gameloop actor it's own channel that subscribes the player to world updates for every new 'frame'.
- Game loop
- Handles player states (who is joined, connected, temporarily disconnected, keys pressed)
- Handles game state (where are the ships, where are the bullets)
- 30x a second it receives a 'timer' event, it reacts to the input keypresses, serializes the game state, and passes it back to all subscribed players.
- Connects over websocket to server
- Draws game & UI when a new game state is received
- sends keypresses back over websocket
The biggest reason I use an Actor / message passing pattern is because I've found in the past it was easy for me to accidentally make highly coupled programs in Go, and the coupling would show up as code that's hard to test. By having modules interconnect with channels, it's very easy to initialize a module and simulate messages on the "wire" to test it's domain of responsibility.
I'm quite happy with the modularity of bitarcade at this point. I've almost finished adding a second multiplayer game, and it was easy to extend the matchmaker to be able to handle >1 game without copying much code.
Pitfalls I avoided
I was able to ship on time because I avoided a huge number of potential pitfalls.
I ignored the completely non-essential elements, like "social media integration", login, player handles, high scores, etc, etc. I focused on the essentials by asking the question "Could I ship without X?".
When I started writing the game, the absolute first thing I did was get a ship drawn on the screen. I ignored everything else that would be needed. This compelled me to keep going because I had a positive feedback loop from very early on. I've dropped so many side projects by not having something tangible within the first 10 hours of development.
This is similar to how to build an MVP for customers:
But it's important to note that the same smiley face applies to you, the developer. Having nothing to show for your effort is not positive feedback when you're contemplating investing more of your free time.
While I had plans for how to modularize the application, I first focused on making the app serve a single playable game by hard-coding one game "world". Once I had a game playable, I extended the implementation to support many games.
I avoided adding a database to the game. Everything's in memory right now. The downside is that a server restart wipes the games, but the upside is that I haven't had to write a migration, set up a database, try to think about the future schema. All that debt has yet to be incurred, and it's awesome.
I avoided third-party libraries (with the exception of gorilla/websocket). This is a somewhat popular approach to writing Go, and I love it because my application kept a linear cognitive overhead throughout implementation. Frameworks that try to do too much for you have a high onboarding cost to begin using. KISS.
Pitfalls I did not avoid
The "1 player" experience sucks. You are all alone and there's nothing to do.
When I originally posted it to Twitter, players who had loaded the HTML but not connected over websocket yet took up a spot in the game. Turns out when you post a link to twitter it gets ~8 requests automatically, so I had a ton of "dead" players. This meant most people who clicked on the link got a game by themselves.
I posted it to hacker news at some silly time like 9am EST on a holiday. It's not a huge deal because this project was for me. However I did kind of want to stress test the server and see what an 8 player game would look like.
A few of my fellow dad friends have played solid 20-minute games with me, and really that was the target audience for this: people like myself who sometimes want to shoot some lazers but don't have time to get involved in anything more complex than loading a browser tab.
I reached that "programmer bliss" state hacking from ~8:30-11:30 one night implementing the matchmaker actor. Go's strict compiler helps with big refactors - I implemented my ideal usage for the matchmaker, then followed the compiler errors to implement everything that was missing, and went to bed with a functioning game.
A recruiting email from a tech giant (nice but I'm happy at Shopify thanks).
- More games. I'm nearly done being able to reuse the web code, so each new game only requires writing the game loop and the frontend code.
- Using cgo, I could write an SDL driver that routes input from the websocket as SDL_event's, and streams the rendered screen SDL_Surface back to the web client. Imagine porting existing SDL games like DOOM to run multiplayer from your browser. Or a full DOSBox running. Silly stuff like that.
- Conversely, I could write a separate I/O driver that lets you play these games locally on your windows/mac/pc. We have a 4-player arcade machine at work that could turn these games into fun party-mode experiences, and it would run the same core game code as the web version.
- Continue to use the Actor pattern to implement more concerns like players with a saved history and configurable handles, high scores, GIF rendering of kills, twitch streaming, etc, etc..