April 21, 2020

Boids of Prey

my Ludum Dare 46 game

The Ludum Dare Compo is a game jam to make a complete game from scratch, solo, in 48 hours. Ludum Dare 46 was my third time jamming and I really enjoy it every time! The theme for this one was "Keep it alive".

You can play Boids of Prey at https://boids-of-prey.sartak.org or right here! (If you're using Safari some features might not work consistently, so switching browsers might help)

My immediate reactions to having finished making this game are very … mixed! I d on't expect to place in the top 100, or even 200 (since participation is way up this time!). For any category. So, let's call it a learning experience!

What went well

  • I made and shipped a complete game in just one weekend!! With real game mechanics, level progression, art, sound effects, music, dialog, an end screen, and some polish. The gameplay even made me laugh a bunch of times. And I believe I was able to squash all the significant bugs!

  • I'm really happy with how the dash attack turned out! Slowing down time, the particle effects, zooming in the camera, and then downright launching the enemy off into space… they all add up to satisying polish. It's even balanced by its downside: it'll also smash the chickens you're meant to protect. Which I definitely did a bunch of times accidentally … and intentionally. This dash, if anything, is what really makes the game.

  • The zoom/slowdown when you're on the cusp of losing is pretty slick too. It has gameplay value: it offers the player a genuine opportunity to save the day by dashing in at the literal last second. Or, if you're too far away, it at least effectively communicates why you're losing this round (especially since it might happen offscreen). This zoom/slowdown kicks in when there's one chicken left and a ghost is very close to it. That turns out to be overly simplistic, so there's a heuristic for when the chicken escapes e.g. due to momentum causing the ghost to miss. It feels really good when it lets you snatch victory from the jaws of defeat like so:

But for every time I save the day like that, there's also been a time where I accidentally miss and smash the chicken instead :)

  • The game's central mechanic (and its namesake) is the "boid flocking" algorithm. Before this jam, I'd only heard of it, but didn't know anything else about it. I remembered it during the first night after I realized the way I'd tie the theme into my game would be to keep a group of "followers" alive. So I did a little research and found this article which explained it as a pretty straightforward application of vector math! Each boid has a vector pointed toward the centroid of all the other boids to maintain a cohesive flock (purple, below), and vectors away from each nearby boid so that each one clears space around it (averaged as cyan, below). The linked article also describes "alignment", but these chickens aren't going anywhere in particular so I skipped it. A couple times a second, each boid accelerates in the direction of the weighted average of those vectors.

(I wish I'd built this debug view during the jam!)

  • As soon as I really internalized the boid algorithm, I knew that I just had to extend flocking into the gameplay! And to use the algorithm for the enemies too. Thus the game was born. Each chicken wants to stay vaguely close to the player for protection, so there's a weakly-weighted vector toward the player. If there's an enemy nearby, the chicken will have a strongly-weighted vector in the other direction. And so on. Some of the ghosts' concerns are the same (they also want to move toward the centroid of nearby chickens), but others are the opposite (they want to move away from the player). Each of those desires is a vector calculated dynamically, averaged together with multipliers I'd tuned. Also, when the "chase" vectors kick in for the ghost and chicken, both get an extra boost of acceleration and maximum velocity, to help sell the effect. Here's what all thirteen of those vectors are doing in the real game. Quite frenetic! There's some interesting emergent behavior too! Here the ghosts surround a chicken then cruelly juggle it for a couple seconds.
  • I was able to adjust the weights in realtime for each of those vectors (along with all the other "magic constants" in the game). The UI for it is an extension of the dat.gui library, plugged directly into my game engine. My extensions include: filtering what fields are currently visible, highlighting changed fields, resetting or exporting those changes, hot reloading, bundled properties (e.g. for the many, many options of particles and tweens), automatically hooking into game state (in both directions), performance optimizations, etc.
  • The game ended up feeling pretty balanced in terms of difficulty. Which is definitely lucky, since it leans heavily on both the (ironically unpredictable) physics engine, and an unfamiliar algorithm with lots of fussy tuning factors. It's also generally challenging to get difficulty balanced under the pressures of a game jam. In Jumpcoins (my previous jam game), the difficulty curve looked like a stairstep. The dramatic jump in difficulty had one streamer commenting: "And now suddenly, it's Celeste" (an amazing platformer notorious for its difficulty). Boids doesn't feel too hard, or easy.

  • The scene transitions punch above their weight for this game. They even support transitioning between different-sized levels. I had to do some ugly hacks to pull this off—essentially, lining up the two animations on the old and new scenes precisely, so the two player sprites, one in each scene, overlap perfectly. If I got it wrong, the player would appear cut in half. I was directly inspired by playing a bit of Link to the Past recently. Though, I am surprised to notice LttP's screen transitions have a linear curve, so the screen moves at a constant speed, starting and stopping abruptly. I replicated the linear curve in Boids in the middle gif below. This feels both flat and jarring compared to the cubic curve I shipped (the last gif here). So I got that going for me, which is nice. (If it's hard to see the difference due to being a compressed gif, try tracking your finger along with the vertical line of red bricks. If you're like me, you can do so easily with the linear curve, but cubic is challenging. The duration and distance are exactly the same for both!)

  • Hot reloading everything (game code, shader code, configuration, level maps, graphics, audio, etc) without refreshing or losing game state worked super well. The iteration time was effectively instant, which qualitatively changes your relationship with the thing you're making. Being able to get to this point required a bunch of work up front to convince Phaser to apply these updates. It works, but I'm definitely feeling the strain of JavaScript and Phaser here.

  • I got much more competent using ChipTone to make sound effects. In previous jams, I mostly just clicked "randomize" and arbitrarily changed values until it landed on something I could use. But now, I kinda understand the frequency/speed/acceleration/jerk and attack/decay/sustain/length/release aspects of sound design, and some of these filters like harmony! So I was able to think of a specific goal - e.g. make a wolf howl - and iterate effectively towards that.

  • I got a little better at generating music with Abundant. At least this time I found the tempo controls! One of the funniest pieces of feedback from Jumpcoins asked if the music was a "compilation of 'Game Over' songs", due to all the songs being somewhat downbeat and slow. The music feels a little more thematically-appropriate in Boids.

  • I worked on the game for about 31 of the 48 hours, and even slept both nights (5 and 9 hours respectively). I logged all my keystrokes, here's a chart of 'em. You can see on the third day I perked way up after getting a full night's rest. (It dropped back off because I'd switched from writing code to making art and sound)

  • I streamed the whole jam on Twitch, though (intentially) without any audio. I find it really challenging to code while giving commentary! At the very least I'm able to save the vod. Also a few friends dropped in to offer encouragement—shout outs to Jordan, Jay, Brock! I also took a screenshot every ten seconds throughout the jam, so I'll put together a timelapse of the game's development.

  • In previous jams, as the game code got larger, vim would start regularly segfaulting(!!) or, at least timing out on syntax highlighting. Which is, frankly, embarrassing - these files top out at a small single-digit number of thousands of lines of code. This time, I was able to divide the compute load between my laptop (playtesting the game, making art and sound, and streaming to Twitch) and another machine (editing the code, and running the builds). I used a wired connection to get ping times down to fractions of a millisecond, which was nice, but also to avoid wifi hiccups, which was essential. This division of compute responsibility worked well (no vim crashes!) except for one tremendous hiccup with rsync (see below)…

  • As always my wife was super supportive and understanding. I'm a lucky guy! Gotta restock the husband points I just spent though. :)

What didn't go well

  • Everything feels way underpolished for my standards, even accounting for being a 48 hour game. My previous jam game, Jumpcoins, did better, on just about every axis.

  • The central game mechanic of the boid algorithm is pretty invisible to the player, so I think players aren't going to find Boids of Prey to be particularly interesting. Which is okay for a jam game but obviously not ideal!

  • On the upside, I'm taking those points as two lessons for where to focus my attention for future jams. Polish, polish, polish. And invest the time in the first couple hours to design a compelling mechanic. (Also scope down more than feels reasonable, then scope down again. For example all three of my games have multiple levels - is that really necessary?)

  • The art is just bad. I really need to work on this. Good art is not only a worthy goal in itself of course, but it also absolutely has a halo effect (either positive or negative!) on the rest of the game. On Saturday night I struggled to draw a chicken, gave up, then just went to bed disheartened. Part of the reason the art is bad is self-inflicted—I didn't start working on the player sprite until, literally, fourteen minutes before the deadline. And I spent all of six minutes on it. That's why it's a pumpkin without a stump. But, also, broadly, I have not invested the time to develop this skill. Only six months until the next jam for me to learn and practice! I hope I will be able to report progress here.

  • The camera was a consistent source of toil. For screen transitions, the game shipped with some minor visual glitches if the camera is not in quite the right place. That's why the later levels all have their exits and entrances in corners, because I eventually realized the camera must be in a known-good spot (locked to the screen edges) for the transition to look correct. For the death slowdown, the camera worked against me in several ways. I was able to hack around some of it (by turning off clamping the camera to always be within the level boundaries), and accepted the rest (the camera still temporarily teleports out of place when the slowdown starts and ends… consider it a, uh, "stylistic choice").

  • The physics engine was problematic too. I used Phaser's arcade physics engine, which is lightweight but very limited and glitchy, even by the admission of the author. There is a lot of solid objects tunneling through other solid objects. By the time I realized I should upgrade to a real physics engine like Box2D, it was too late. Instead, I worked around it by having the boid algorithm avoid obstacles and walls with a tight radius and a gigantic multiplier. And if a ghost or chicken does touch a wall, they get stunned for a second, specifically so that they stop trying to accelerate through walls. These worked pretty well to eliminate most of the tunneling (though it also had obvious gameplay implications). And if the player ever leaves the level boundaries, we simply pop 'em back in to the center of the level.

  • I tried to have the ghosts bob up and down by looping an animation of their y-coordinate by a few pixels. But this had the unexpected side effect of also resetting just the y-component of their velocity every frame back to zero. This confused the hell out of me.

  • I've spent a bunch of time retrofitting "looped live code editing" replay system a la Handmade Hero into Phaser. It's super cool. But it still has some lingering bugs with exact frame timings that I haven't been able to pin down. It's still very much good enough for a game like Jumpcoins where the player essentially controls everything, and frame-by-frame differences rarely add up to much. But replays didn't work at all for Boids. Those off-by-one frame errors quickly caused the butterfly effect to produce completely different results in the physics engine and the boid algorithm, which rendered replays almost entirely useless. Oh well!

  • I had to remove the impact pause (essentially just a sleep(20ms)) from the dash attack because it interacted really poorly with time scaling, causing the game to apparently freeze for a while. I had no easy way to exclude the sleep timer from the effects of time manipulation. So, out it goes.

  • To support using two computers here, I'd stitched together a very janky approximation of Dropbox. Each one watched for changes in the ld46/ directory, and as soon as it saw one, it rsync'd the whole directory to the other machine. (Then the destination machine would go hey, some files changed, and rsync right back, but this was a no-op every time). The intent here was I could edit images and audio on my local machine, and edit code on the remote machine, and have all this syncing happen automatically to keep the latest version of everything on both machines. And it did work well in all my testing. Until jam time and I started adding git into the mix. git would get confused about the state of the repository, and I didn't have time to waste in figuring that out, so I just killed the rsync-watcher process. Except I accidentally only did it on the "write code" machine. So about two hours later, on my laptop when I hit ⌘S on a new sprite, it also instantly synced the two-hour-old code, overwriting my progress!! Luckily though I was able to recover, if slowly. I copied some fragments of the code directly out of my terminal buffer. I also transcribed more fragments out of the automatic screenshots I'd setup. And I'd literally just written all this code, so I could remember the rest of what I'd lost. So I ended up burning only about thirty minutes. But oh my god was that some unnecessary self-inflicted stress!

  • I can't count how many times I typed y when I meant x, or x when I meant dx. Ugh. These caused sometimes subtle bugs because things mostly still worked, until they very didn't.

  • I had to burn at least an hour to improve the performance of this simple 2D game. First by replacing the 24x24-pixel grass tiles with one large background image. Then by combining the hundred-or-so 24x24-pixel wall tiles to be just four long rectangles, one for each side of the level. Not a great use of the very limited time. But really, this should not be necessary in the first place given how powerful computers are nowadays. Oh well!


My grade for myself is that this is a C- game. Not quite a failure, but definitely not pinning it up on the fridge either. The aspect that pulled my grade down the most was the artwork, and the rest of the game couldn't compensate. Edith has offered to work on this with me, even to go so far as to learn pixel art in solidarity. \o/

Like I said at the top, this result was definitely a learning opportunity for me. I feel like I now have the space to make something truly experimental next time.

Here's how I did in the compo!

Overall 421st (3.538 / 5)
Fun 51st (4.038 / 5)
Innovation 568th (3.231 / 5)
Theme 334th (3.865 / 5)
Graphics 931st (2.673 / 5)
Audio 313th (3.380 / 5)
Humor 81st (3.820 / 5)
Mood 292nd (3.52 / 5)

I'm blown away by how well I did on Fun and Humor! Maybe I can amend my own C- up to a B-. :)

Graphics and Innovation landed about where I expected as my trailing scores. As mentioned art in particular is definitely my weak spot!