GameDevJs 2020 is over.

Time for a recap of the lessons I learned.

My plan was to write a game with three parties:

  1. The player
  2. At least one opponent
  3. Visitors

The player and opponent won't see each other, nor a basketball. The visitors can see the whole field, but only interact via emojis. This way, language barriers shouldn't be a problem.

The player and opponent will see the emojis and need to decide, whether it is meant for them or the other party. Then, they need to get the ball and move to the basket while holding it. In this case, the feedback would be an increase in the scoreboard.

The communication between all three parties happens via WebRTC DataChannels.

Day 1, 13th April 2020

As usual, I start my projects with grabbing a .gitignore file, choose a license. For this project, I went with GPL v3 or newer.

Minimal setup

Even before the jam started, I picked a sports pack from OpenGameArt.org, so I added these next. After seeing Phaser 3 crashing my old hardware repeatedly, I switched to kontra.js and got a minimal setup going. In this case, it meant a rollup.js config rendering kontra's tileEngine onto a <canvas>.

Okay, so that works. Time to get a basic UI layout in place. I want the game to be centered, so I wrapped it in a <div> and applied a trick I learned on CSS Tricks screencast: place-items: center;. Tada!

I made sure, that the CSS dimensions match the <canvas> dimensions to avoid some weird calculation issues in kontra.

Also, I took some time to pick the right tiles to actually draw a playing field with the tileEngine here. This way, I got a feeling about the dimensions of the game. Sadly, it turned out to be too large for a smartphone viewport already.

Ready Player One

A game without a player would be quite boring, so I extended the sprites with another spritesheet to draw a figure. For now, it would only have one frame.

I planned to adjust the sports pack „later” to make the player turn around depending on the direction. That eventually never happened.

But … a player alone not reacting on anything is boring, too.

Let's add an opponent! I can use the same spritesheet, but pick another sprite from it.

While I was at it, I registered key listeners on arrow keys and WASD keys, so I could move both sprites individually. That goes a bit into the tradition of hotseat gaming that was popular when I grew up.

Field? Check. Player? Check. Opponent? Check. What's missing? Oh, right, a ball!

Adding a ball

So I added another sprite and wrote some logic to make it move to the closer one of both: player or opponent. Here the spritesheet was a bit nasty, since the ball was not drawn in the same square dimensions like the player sprites, nor was the spritesheet equally intersected. I went with something close enough and reminded myself to edit the sports pack „later”.

Also, I tried to apply some Canvas logic to make the player flip once they turn into the opposite direction. Nothing happened. I tried to make them rotate at least. Nothing happened.

So I added a mental node to try to reach out to the maintainer to get additional ideas. In the meantime, I added console.log()s as reminder.

Day 2, 14th April 2020

Time to deploy it somewhere. For starters, I went with GitHub pages. That meant fixing some paths, so the assets are actually get loaded.

Also, since I deployed it from a repo, I used <base href="/gamedevjs-2020/"> so I could use relative links everywhere. I only had to remind myself to add and remove it between local development and deployment.

Configuring scripts

For deployment, I use gh-pages, since it takes care of the nitty-gritty details.

While I was installing additional dependencies, I made sure to add the requirements of the license (by means of a license banner) and optimise the build with terser.

Score! Fighting kontra.js

Okay, back to the game. What's next? Would be cool to see the scores increasing so I added them as sprite. This way, I can abuse animations instead of writing text. Plus, the sports pack already head numbers. I decided to put the score about centered on top of the playing field, but that meant, I had to adjust the dimensions in CSS and HTML, too.

After deployment, I realised, that it is hardly playable on my smartphone. So I spent some time in trying to adjust the camera object in kontra.js or rescale the playing field based on the viewport. The former had no observable effect (mental node: ask maintainer), whereas the latter caused some weird calculating bugs regarding coordinates.

Later, I opened bug ticket to track the root cause. DO THIS

I also observed, that the whole window scrolled when using arrow keys so I tried to suppress that by disabling overflow and capture mousewheel events.

I moved around some code and added baskets to the game, so I could use kontra's collision detection to increase the respective score.

Playing on mobile

I did some research on how controls are implemented for sports games on mobile and noticed, that it often consists of a virtual dpad. So I looked, but could not find a perfect in the spritesheet. I added a placeholder sprite instead and tried to read the correct coordinates from pointer events. I eventually failed, but promised myself, that there is enough time to tackle this problem „later”.

Automation-wise I added a renovate bot to GitHub to keep my dependencies up-to-date.

Day 3, 15th April 2020

Now I want to focus on the tricky part of my game: Communicating via the network. Therefore, I want to send messages back and forth, so I need a way to translate user actions into JSON. As a side effect, I could use those messages to enable something like a „replay” feature in the future.

But there are some problems. The biggest one being the GameLoop, which will call the update the state about 60 times per second. I don't want to record something at every frame. Only, when something happens. How do I determine that?

In a web application, I could have used event listeners. But kontra.js is using <canvas>. They also offer a minimal event API, so I looked closer at that. The idea is, that every time I handle a keypress anyway, I would fire an event and have some other part of my game listen on those.

Dealing with too many events

This still leads to many events fired in short occassion. So I need more.

I went with the idea, that I track the last state I recorded as a local variable. I then compare the incoming event with that state. If something changes, I process the event and update the state.

Processing here means using the Store API and save the action in localStorage.

Introducing WebRTC

With that out of the way, I went looking to implement a minimal WebRTC example. Therefore I followed a tutorial on MDN but then realised, that I will need some means to receive the other end (in the docs called remoteConnection). So this isn't feasible.

I browsed the web and found different libraries. Most of them dealing with media streaming via WebRTC. I want to use DataChannels only. I settled with simple-peer in the end because it has a simple API.

I followed then its tutorial and used a <textarea> for copy-pasting the sent messages. Enough for the day.

Day 4, 16th April 2020

On this day, I was realising that I need a server, so I went to pick a video game name so I could set up a proper Heroku app. Then I could use socket.io for brokering between different party members. I decided to only use a minimal approach for now to preserve privacy.

If I deemed necessary, I could still implement a way to send the recorded actions to the server to generate a clip. Or have a leaderboard there. But then, I would likely extend the server to use a proper database.

Wiring up socket.io and simple-peer took a bit of time, but in the end I was able to make one browser move the character on the other instance!

Storytelling with alpine.js

Time to work a bit more on the story to introduce the player into the game. I shortly thought about using Vue.js. But in the end, I wanted to only have a light touch of JavaScript and let the main JS be kontra.js.

Therefore I grabbed alpine.js and implemented a light touch, where the player can enter a username (which would allow recognition in the party choice later). Also I added a data binding feature to turn user actions into a list of <li> appeneded to the DOM.

Here I thought about using this as kind of commentary. Another idea would be to let those be read out, so that you could follow along with a screenreader.

I did not finish this aspect of the game, though.

Day 5, 17th April 2020

After setting up a Heroku instance and wiring it up with my master branch on GitHub, I was puzzled, why nothing got build on pushes. Then I realised, I forgot to add a Procfile and update my package.json to include the engines property there. This way, Heroku will use the Node.js buildpack and run my build script.

I splitted my code to be able to support my mental model of single- vs. multi-player modes, although this would incur more copy-and-pasting. After all, I planned to use a slightly different tileEngine for each mode, so I had to decide, whether I want to have the complexity in the tileEngine factory or in the code calling it. Since I could estimate, that more differentiations will come, I decided to go with the caller route.

Okay, I deployed to Heroku now. But I couldn't execute the JavaScript code. Taking a look at the network tab of my DevTools revealed to me, that the <script src> couldn't be resolved. In a first attempt, I commented out the configuration line for rollup and pushed again.

With that being resolved, I was looking for a favicon, so I could easily spot my game among the open tabs. I found a free one and added it.

Next, I added more personality using a Web Font.

Day 6, 18th April 2020

Now I looked into Emojis to use. The thinking was, that I could operate independent of spoken language this way. I settled with openmoji and a generator built on top of it.

Then I tried to look at my deployed work on my smartphone. Gross. The viewport was too small to use it. I tried a bit here and there, but ultimately decided, that scaling wasn't the right approach. Perhaps I could make the camera follow a player? That „didn't work” either. As in, the camera didn't move.

Day 7, 19th April 2020

Before I forget about it, I created a „credits” section and filled it with some dependencies I used to build the game.

Since I got some troubles with kontra and my IIFE, I decided to move it out of my bundling and instead use window.kontra it exposes.

Next, I moved the user interaction list out of the viewport, because it caused some irritations when I was testing the game.

While I was working on the UI, I looked into making the form part look nicer. For example, it should only be shown once alpine.js was loaded and grow over time as the user entered more data.

Day 8, 20th April 2020

Procrastination hit me. Did nothing.

Day 9, 21st April 2020

I only merged automatic Pull Requests to update my dependencies. My builds kept crashing so I was wondering, what was going wrong on Heroku. I discovered, that since there is a clean build every time, I need to ensure that the dist directory exists or my spritesheet generator would throw. That's as easy as adding an empty dist/.gitkeep to git and the directory in .gitignore.

In order to not feel too bad about my low progress, I added a way to choose between a single and multiplayer game and fixed some bugs with the setup form.

Day 10, 22nd April 2020

As you can imagine, this caused more bugs. So I adjusted my asset loading to be able to load only what is necessary for the chosen game mode.

I received some early feedback from a friend, that the user flow was confusing, so I made it timebound to reduce clicks.

Since I couldn't fix the scaling problem, I adjusted the logic, so that if the viewport of the device was too small, the user would get funneled into the new visitor play mode I created. Here, the task was too watch the game and click on emojis to direct the player. Sadly, I couldn't make it work as imagined on time.

At least I could adapt all logic for a local play to this new mode.

Day 11, 23rd April 2020

Procrastination again with no progress on the game.

Day 12, 24th April 2020

The end is near. Time to get at least something playable together!

New role added

I realised, that I need some special treatment for the person starting a party. Thus, I created a new role and adjusted all the needed parts.

Also, since I have now the possibility to create parties, there had to be a way to choose one. I thought initially about creating a link somehow, but that didn't work out the way I hoped for. So to keep things short, I showed a list of all running parties (stored as an in-memory object on the server) and used the entered player name as identificator. Given the relatively low number of players, this would be good enough. Not only the opponent player, but also visitors would have to choose a party now.

I also thought about how to persist a session, so I could refresh and get the same state as before. I'd need to use a GET-parameter for this. Trying it out yield to a page refresh. So another approach would have been needed (via the anchor part of an URL, I guess). I dropped looking further into this as time was already short.

Handling parties

So there are multiple parties running potentially at the same time. How to tell them apart? In a database way, I could have stored them in a table. But this was a game jam. Also, I would like not to expose the ID as integer to the client, since this could lead to enumeration attacks.

Instead, I was looking for something like a uuid - and found nanoid! It ws short enough, that you would be able to type the string in or tweet it if needed. But also relatively collision-free.

If I were to use a table, I would generate a nanoid anyway and use that as identifier externally.

Reordering code

Back at the client I realised a problem with my async code. I would need to establish a socket.io connection first before I could start a WebRTC session, because both ends did not know of each other yet. But my code was running at about the same time. In other words: I had to move the WebRTC logic into callbacks and expose those to the calling code, which would be aware of the correct timing to establish a WebRTC session. Namely after one person opened a party and at least one other joined.

Read more about my detours at the end of this article!

Adding music and sound

Time to juice up the User Experience a bit - with sound. I hopped over to freesound.org and looked for some tracks dealing with basketball. Mainly a blown whistle, dribbling, throwing and some audience cheering up. I found several tracks and cut them with Audactiy, by selecting the start and end, moving it to a new track, muted the rest and exported it as mp3. Thereby I had to watch out to use a high enough bitrate and stereo to make it sound okay.

Adding the whistle on each start of a round was easy. Having the audience play in an infinite loop a bit more tricky. There was an annoying gap between each playback. In the end I followed a tip on StackOverflow and phased the sound in and out to paint over that problem.

Since that got annoying really fast, I added an option to mute background music.

With that being done, I was unsatisfied with the user navigation. Something was odd. I imagined an interface, which was split in four parts. But that would have required fiddling with sizes again. Instead, I picked a plain old list and chose a size, which was okay. Not too high, but still clickable.

Based on feedback from a friend, I added a section explaining the rules of the game.

Also, I painted over the jumping UI until alpine.js loaded by making use of the x-cloak attribute it set and removed respectively.

Day 13, 25th April 2020

Release day!

Okay, what was strictly needed to make it work? An upload! Don't forget to hand in your submission, or everything was in vein.

I already learned, that I had to zip it. I shortly thought about doing that via script but in the end went a manual route.

ZIP too large

But, gosh! The archive was soo hugh, that itch.io wouldn't accept it! Why was that? I thought about some duplicated files left over - but that wasn't it.

Instead the dist folder contained every single sprite which was part of openmojis. Not only the parts I carefully picked, but everything.

How to pick only what was needed? Clearly, that would be those I loaded using kontra.js. So I moved those things out into a JSON, added a roleup plugin, so I could read it and wrote a small Node.js script which would translate the relative URLs into file paths I could then consume with rollup.

While I was it, I could finally flatten the dist directory and adjust some imports.

ZIP could be uploaded now!

Making the game run again

But the game wouldn't load :-(

I quickly realised, that it was about the path it looked for. I couldn't go with a full qualified domain and path, but instead tried to „prefix” all routes with a .. That actually worked. Nice hack!

So now I could load the UI. Great. How to deal with the Socket server? I wasn't using Heroku or local server, but had to defer to itch.io for the submission. So I bit into the apple and hardcoded the use of Heroku even for local development. At this time, nobody than me would use it, amirite? That was as easy as passing the URL into the io() constructor.

Next thing I noticed was an odd behaviour regarding background colour on itch.io. So I hardcoded black and white for text and background.

Finishing up sound

Now, I had a playable game. Time to move some code around to have sound and music in every mode.

At this point, I already shared a preview with Discord and could collect some feedback from there as well. I finally made the counter increase on every successful throw (which was running with the ball into the right basket). Since the tile would overflow at some point, the tileEngine would just blank out (and the music continue to play). I dubbed that my „end scene”.

Last minutes!

I added the ability to mute sound (which didn't worked for some reason), uploaded everything and made sure I could play it in Firefox and Opera. Phew. I'm done.

The moment, when you realised, you forgot something essential

… until I realised, that uploading enough wasn't enough. I had to click another button to make it submitted as an entry for this Game Jam!

Luckily, Andrzej was so helpful to allow submission after the deadline via special links. After all, it was the first time for him as well.

Detours

Remember I promised to share some detours? Here are them. As bonus, if you want.

Encode as bip39

I originally was trying to make the game without involving a server. I don't know about you but I hate the thought to depend on a machine somewhere in the Internet, which could go away at any time and thus render my game unplayable.

So I tried to do the WebRTC handshaking by other means. Like a small text message, you would share as a tweet. This did not work out, since the session has about five roundtrips before a connection is established and doesn't tolerate too much time between two of them. Once the Peer was closed, there was practically no way to resume.

Nevertheless, this took me some time to figure out and might help you in other circumstances. You can find the code in the commit with the message Explore WebRTC via SimplePeer and Bip39.

I discovered bip39 while helping a friend with a Firefox extension. It was originally meant as a way to turn hashes or secrets into something you could remember (a socalled Mnemonic).

So I passed a signal (a JSON payload) from WebRTC into a function. I first make sure to convert it to a Latin charset, by using btoa (you can read it as „binary to ASCII”). Since bip39 expects a string of length 16, I padded my converted signal with a value, which is likely not showing up in the original payload.

Then I turned it into an Array of Strings, which got converted to decimals. Here I used the fact, that the ASCII charset fit nicely into a 256 integer range. I merged the Array together and now had a string consisting of hex values.

Afterwards I feed bip39 with slices of 16 and collect the results in an Array. Those will always consist of 12 words, separated by a space. That array is joined by a space to make it look homogeneous.

The opposite direction starts with breaking up into words, slicing them by 12 each and calling bip39 again.

Afterwards, the hex values have to be converted back to decimals. The resulting string is transformed via atob (ASCII to binary) to restore the original payload.

This code actually worked! But it was waaaay longer than just copy-pasting the payload I received in the first place …

Here's it for you to study:

function signalToMnemonic (signal) {
  const size = 32
  const mnemonics = []
  const base64Signal = btoa(signal)
  const power = Math.ceil(Math.log2(base64Signal.length))
  // bip39 expects multiples of 16, so padd with # up to the next power of 2
  const paddedSignal = base64Signal + '#'.repeat(
    Math.pow(2, power) - base64Signal.length
  )

  const signalAsHex = paddedSignal
    .split('')
    .map(charToDec)
    .map(decToHex)
    .join('')

  for (let i = 0; i < signalAsHex.length / size; i++) {
    const part = signalAsHex.slice(i * size, (i+1) * size)
    const mnemonic = bip39.entropyToMnemonic(part)
    mnemonics.push(mnemonic)
  }

  console.log('Encoded words:', signalAsHex.length, signalAsHex)
  return mnemonics.join(' ')
}

function mnemonicToSignal (mnemonic) {
  const size = 12
  const words = mnemonic.split(' ')
  const power = words.length / size
  let entropies = []

  for (let i = 0; i < power; i++) {
    const phrase = words.slice(i*size, (i+1)*size).join(' ')
    const entropy = bip39.mnemonicToEntropy(phrase)
    entropies.push(entropy)
  }
  entropies = entropies.join('')

  console.log('Decoded words:', entropies.length, entropies)
  const parts = entropies
    .split('')
    .map(hexToDec)
    .map(decToChar)
    .join('')

  return btoa(parts)
}

function wordToChars (word) {
  const chars = []
  for (let i = 0; i < word.length; i += 2) {
    const char = word.slice(i * 2, (i+1) * 2)
    chars.push(char)
  }
  return chars
}

function hexToDec (hex) {
  console.log(`${hex} => ${parseInt(hex, 16).toString(10)}`)
  return parseInt(hex, 16).toString(10)
}

function decToHex (dec) {
  console.log(`${dec} => ${dec.toString(16).padStart(4, '0')}`)
  return dec.toString(16).padStart(4, '0')
}

function decToChar (dec) {
  console.log(`${dec} => ${String.fromCharCode(dec)}`)
  return String.fromCharCode(dec)
}

function charToDec (char) {
  return char.charCodeAt(0)
}

Encode WebRTC as <canvas>

Nevertheless, this took me some time to figure out and might help you in other circumstances. You can find the code in the commit with the message Explore WebRTC via Canvas exchange.

Here, I passed a signal from WebRTC into a function. That is a JSON payload. I first make sure to convert it to a Latin charset, by using btoa (you can read it as „binary to ASCII”). Then I turned it into an Array of Strings, which got converted to decimals. Here I used the fact, that the ASCII charset fit nicely into a 256 integer range. For a canvas, I need it to represent a RGB pixel. If you read up about Uint8ClampedArray you fill find, that there is a fourth value, which will be used to compute the alpha value. This is done to save some memory in processing. Sadly, this also led to data loss (rounding errors), which made this approach unusable for my purposes.

To fit into a ImageData nicely, I had to pad the Array to the next number, which can be divided by 64 (= 4 * 16). I simply set those to zeroes.

The Uint8ClampedArray is fed into ImageData, which then can be used to create an image bitmap, which can be painted on a <canvas>.

I think, I even found a bug, when looking at dealing with the color space conversion. I tried to set each fourth value to 0 or 1. That yielded a black or white image for every value. If you read up how this is handled by browsers, it becomes clear why. However, using a second argument for createImageBitmap crashes in Firefox. I filled a ticket with them, because I believe, this is a violation of the specification (also known as bug).

Without further ado, here's the code:

async function signalToPicture (signal) {
  const gapValue = 0
  const base64Signal = btoa(signal)
  const decSignal = base64Signal.split('').map(charToDec)

  // XXX: This is a waste of RAM!
  // But I couldn't come up with a more clever algorithm for now
  let preparedArray = []
  for (let i = 0; i < decSignal.length; i += 3) {
    preparedArray = preparedArray
      .concat(decSignal.slice(i, i+3))
      .concat([ gapValue ])
  }

  // Fill up with gapValues until it can be divided by 64
  const power = Math.ceil(Math.log(preparedArray.length) / Math.log(64))
  preparedArray = preparedArray
    .concat(new Array(Math.pow(64, power) - preparedArray.length).fill(gapValue))

  const clamped = new Uint8ClampedArray(preparedArray)
  const imageData = new ImageData(clamped, clamped.length / 4 / 16)
  console.log(clamped, imageData)
  return createImageBitmap(imageData, { colorSpaceConversion: 'none' })
}

function canvasToSignal (canvas) {
  const width = 32
  const height = 16
  const context = canvas.getContext('2d')
  const imageData = context.getImageData(0, 0, width, height)
  console.table(imageData.data)
}

function decToChar (dec) {
  return String.fromCharCode(dec)
}

function charToDec (char) {
  return char.charCodeAt(0)
}

Conclusion

I'd do it again!

Actually, I received surprisingly positive feedback on this entry, so I'll fill in the missing parts and see how it goes.