Monday, January 17, 2022

I improved (a tiny bit)

The logo

I've been using the framework to create a multiplayer game as of late. It's been going very well, but I did trip over a few quirks of the library. One in particular, I was able to chase down and submit a patch to tweak, which was graciously accepted by the framework maintainers! I thought I'd record just a bit of how that went.

The issue

The library includes a lobby management system that lets the game code host multiple simultaneous games on the same server. There is a convenient Lobby React component that exposes this feature to the client and lets a player choose their name and create / join games. One issue with the component is that it polls the server every two seconds to get the list of games, and this polling continues even when the player is playing a game. It's unnecessary traffic; anecdotally, having the UI open for a half hour consumed about 1MB of data.

I set about adjusting this. After forking the project and downloading a copy of the source from GitHub, I let it build and ran the unit tests to confirm I had a good development environment. One convenient thing about Node.js projects is that they're very consistent in this regard; npm is a fairly mature package management solution, and a well-constructed Node package describes both how to build a package for production and how to build an environment to develop it, so you generally have what you need to do testing after one npm ci run. The developers have also helpfully included contribution guidelines, which make it much easier to know how to develop changes to the framework.

The obvious place to start was the component itself, and after a bit of fishing I found the React component definition, containing functions _startRefreshInterval and _clearRefreshInterval. Sure enough, these functions got called at component mount and unmount, but not when the component transitioned from listing games to playing a game (or from choosing a player's name to listing games; it would start polling before the list was displayed). I was able to make some tweaks and build out a unit test, and then I set about manual end-to-end testing. This proved very valuable; there were a couple corner cases in my initial attempt that manual testing caught before I pushed a bad patch up to the maintainers.

To make clear my intention, I filed an issue against the main project to give some context on why I was making a change. Even when I think the change is self-explanatory, I find filing an issue to be a good step when proposing a change to someone's GitHub project; it makes it clear why the change is happening and serves as a place for others to comment on the problem itself, not just the proposed fix. I like to follow the "What I did / expected / observed" pattern (where appropriate) to make clear why I think something needs to be changed.

As per the contribution guidelines, I pushed my changes to a new branch and created a pull request to propose the change. I expected to go a couple rounds with the maintainers on getting it just right (in particular, I didn't know how they'd feel about what I'd done with the unit tests), but to my surprise they were happy with it and accepted it on the first pass! The change is now in the latest minor revision, so hopefully others will benefit from fewer poll requests while their games are running.

I haven't done many pull requests on other people's GitHub projects, and this was the smoothest I've experienced. I credit part of it to a lot more practice in my day job on the flow of the process. Hopefully, this write-up can serve as a template for others to make changes they hope to see in code they use. Things that really worked in my favor here, I think:

  • This project has a well-defined README, community standards, and contribution guidelines. Read all of those.
  • Node.js makes working on somoene else's code very, very simple. It's probably my favorite package management system nowadays, and that's really saying something; I'd pretty much written off package management as unsolvable, and npm is proving me wrong.
  • Full unit test coverage never hurts (nor does it hurt that the maintainer put a coverage-check infrastructure in!).
  • Detailed and followable end-to-end testing that checked on corner cases increased confidence in the change (and found some bugs). Do this. Think about corner cases.
  • If you take the time as a maintainer to make changes easy and are responsive to changes, you give submitters a positive experience and they're likely to come back!

Monday, January 10, 2022

Making a turn-based game: Heroku is unfairly good, yo

The Heroku logo

So over the course of several weeks (and several blog posts), I've created a multiplayer turn-based color-guessing game hosted on DreamHost. I've been pretty happy with it, but there's still a couple annoying quirks in the situation:

  • The server is HTTP only
  • supports websockets, but attempting to use them fails

Both of these are related to my host: I have an old shared-hosting solution on DreamHost running Passenger Phusion, which makes it slightly trickier to support these features. It doesn't appear to e impossible: there is, for example, some discussion and a code example for setting up both SSL and websockets on DreamHost. But I think there's a simpler approach that I wanted to try first.

Friends, Heroku is unfairly good.

Setting up Swătch on Heroku

Heroku is a "Cloud Platform as a Service" (PaaS) that lets you very quickly host an app using one of several formats and frameworks, including NodeJS. Once configured, deploying changes is as easy as git push to a relevant target.

The documentation includes some pretty clear directions for setting up a Heroku instance to host a game... If you already know how Heroku works. This was my first Heroku app, so I took a few more steps to get there.

For starters, I set up two new apps on Heroku (swatchgame-dev and swatchgame) and arranged them in a pipeline so that I can get one-click promotion of staging candidates at swatchgame-dev to production at swatchgame. "Every project has a development environment... Some are privileged enough to have a production environment separate from the development environment." I then installed the Heroku CLI tools on my work machine (sudo snap install --classic heroku). That gave me the tooling to configure my git repo to work with Heroku (heroku git:remote -a swatchgame-dev, which set up a remote that would push to my Heroku app).

To prepare my app to work on Heroku, I only needed to do a couple of things:

  1. In package.json, clean up my build rule so it built both the client and the server code:
    "build": "npm run build-server && npm run build-client"
  2. Add a declaration to package.json to let Heroku know what Node version is expected:
    "engines": { "node": "12.22.7" }
  3. Add a Procfile to the root of my project that told Heroku what to do to launch the project once it was installed. The file is a single line, web: node build/app.js, which tells Heroku to set up handling web connections by running the app.
  4. Make the app read from a PORT environment variable to check what port to bind to. Heroku sets that variable to let Node.js apps know what port to listen on for incoming connections. This required only a few tweaks to the setup logic in the server's app.ts file

The necessary code change was very simple:

import 'process';
. . .
const MAYBE_PORT = Number(process.env.PORT);

const PORT = isNaN(MAYBE_PORT) ? 8000 : MAYBE_PORT;, () => {
    console.log('server on');

Once all that was done, I simply had to push to Heroku. Heroku is watching for changes to only a couple of branches (master or main), so I just had to make sure my push matched the correct branch name (since I develop in a local develop branch):

git push heroku develop:main

I then visited and... It just worked! No further changes needed. Both HTTPS access and websockets are handled via Heroku's default configurations.

Seriously, Heroku made this ridiculously easy, and it's free to use for a project this small. I couldn't recommend anything else at this juncture.

The game is now playable at Try it out!

Monday, January 3, 2022

Making a turn-based game: Don't prematurely optimize, and different types of turns, and code available on GitHub :)

One of the things I wanted to add to Swătch was the ability to play different types of games. In addition to the initial game of "choose the shade of a color based on its name," I wanted to have the following:

  • Given a shade and several options, pick the correct name
  • "Trick" mode: each player makes up a name for a displayed color shade. Then, they're shown the names provided by other players in addition to the correct name. Choose which name was the correct name (players also get points for tricking the other players into choosing their name).

Supporting this atop proved extremely possible, with just a bit of work.

But first, a digression...

Don't prematurely optimize!

To support the "choose name based on shade" mode, I needed to extend the JSON file listing the set of all colors to include references to several nearby colors in the dataset. There are several clever approaches to this, but I chose the simplest one: for every color, find the distance to every other color and assign the N nearest as nearby colors. This is an O(n2) algorithm; pretty inefficient.

But since it was easy to code, I ran it anyway. On a 1,500 line input, it took about 0.1 seconds.

Friends, computers are fast nowadays. For heavy-lifting computation, don't burn time on being clever when you can use the sledgehammer of modern clock speeds.

Different types of rounds provides a fairly robust framework for distinct game turns, as well as describing what moves are valid in different stages of play. The framework also supports hidden state (both global and player-specific) for each player. Originally, I constructed the game with just a single type of turn, the guess-the-shade-from-the-name turn. To support multiple different types of turns, I made the following changes:

  1. Added a Round interface, which described a round of play by providing the following methods:
    • initState: Prep the public and private state for the round (set to initial, empty values)
    • onBegin: Begin the round, choose hidden values (such as what color to guess), set up player state.
    • scoreRound: Calculate and update scores and assign "best" (and possibly "second best") answers for later display.
    • buildPreviousRound: Every round, upon conclusion, bundles its results into a previousRound data structure used to describe how the round went to the players. This is the method that turns the round state into that structure.
    • moves: A map from the valid moves in this round to the code to execute them
    • getPlayerState: Accessor for the per-player state for this round.
    • getPublicState: Accessor for the public state for this round
    • getLastRoundState: Accessor for the previous-round state for this round
  2. With the interface in place, I moved around the existing Context state to carry different types of context depending on the current round (and reworked the client-side code to pull from those new contexts and show a specific view based on what the current round was).
  3. Now, I could refactor the existing turn logic into a GuessShadeRound implementation and generalize the onBegin and onEnd methods to select a round to play (with only one option currently available) and score the round when everyone had taken a turn. At this point, the refactor could be tested.
  4. I built out a Rounds.ts file to consolidate the separate round implementations.
  5. With all of that out of the way, I could write three more Rounds:
    • GuessNameRound, which shows a shade and asks players to select the right name for the shade.
    • A pair of sibling rounds: MakeUpNameRound and GuessMadeUpNameRound. These are special-cased: instead of the turn ending when MakeUpNameRound completes, it transitions directly into the GuessMadeUpNameRound and calls that round's initState to prep up the round for it.

This is all tough to follow from description alone, which is why I've made all the code available on GitHub to see. Feel free to run it locally and give it a try!