Bevy Jam #1: Beeline

Edward Wibowo,

banner

Beeline is an action-packed game where you play as a missile-dodging, laser-evading, upgradeable bumbly bee.

After 67 commits, 7 days of work, and a 0.05 version release, Beeline was submitted to Bevy Jame #1! The theme for the game jam was Unfair Advantage.

In this blog post, I will outline some of the steps and components that made this game possible.

Beeline’s source code is on GitHub: https://github.com/plamorg/beeline.

Ideation

Coming up with an idea for the jam was one of the biggest hurdles. As a team (consisting of virchau13, Luvam, and I), we first started by trying to interpret the jam’s theme: Unfair Advantage. At first, we discussed if the game should have an unfair advantage for the player or against the player. Both possibilities had their downsides. Firstly, giving the player an unfair advantage would probably make the game too easy. Secondly, rigging the game against the player’s favor would simply make the game difficult, which isn’t very creative when considering the hordes of challenging games already in existence.

So, we opted for a middle ground: give the player a choice to utilize an unfair advantage.

upgrade system

Beeline’s upgrade system grants players the autonomy to choose whether or not they get an unfair advantage. At the time of submission, Beeline had the following upgrades:

  • Double Speed
  • Shrink
  • Teleport
  • Slow Enemies

Upgrade System

The process of developing the upgrade system was one of iteration and revision. I first set my eyes out on using a bit-field to represent upgrades, but I soon encountered some shortfalls.

Bit Field Representation

In Beeline, the player can choose to equip multiple upgrades. My first instinct was to use a bit-field to represent the user’s current upgrades. Each bit in the field would represent whether or not a specific upgrade was active. My first implementation of the upgrade system looked like this:

pub struct Upgrades(u64);

impl Upgrades {
    const DOUBLE_SPEED: u64 = 1 << 0;
    const SHRINK: u64 = 1 << 1;
    // ...
}

In this iteration of the upgrade system, each upgrade is represented as a u64. The value of the nnth upgrade is equal to 1 << n (11 bit shifted leftwards by nn). The code above, however, looked quite repetitive. So I wrote a recursive macro to generate the code for me:

macro_rules! upgrade_definitions {
    ($n:expr ;) => {
        pub fn number_of_upgrades() -> u64 {
            $n
        }
    };
    ($n:expr ; $t:ident $(, $rest:tt)*) => {
        pub const $t: u64 = 1 << $n;
        upgrade_definitions!($n + 1; $($rest),*);
    };
    ($($upgrades:tt),+) => { upgrade_definitions!(0; $($upgrades),*); };
}

impl Upgrades {
    // More upgrades can be defined by simply appending more arguments
    upgrade_definitions!(DOUBLE_SPEED, SHRINK);
}

This macro allowed a new upgrade to be defined by simply adding its name as an argument. Additionally, I added a function named number_of_upgrades to conveniently fetch the number of upgrades defined. When expanded (using the cargo-expand subcommand), the code looks like this:

impl Upgrades {
    const DOUBLE_SPEED: u64 = 1 << 0;
    const SHRINK: u64 = 1 << 0 + 1;

    pub fn number_of_upgrades() -> u64 {
        0 + 1 + 1
    }
}

This Upgrades struct could then be used to succinctly express different combinations of upgrades:

fn main() {
    // A value of 0 means the player has no upgrades
    let _no_upgrades = Upgrades(0);

    let _double_speed = Upgrades(Upgrades::DOUBLE_SPEED);

    let _double_speed_and_shrink = Upgrades(Upgrades::DOUBLE_SPEED | Upgrades::SHRINK);
}

Likewise, checking if an upgrade is currently equipped is trivial using the following has_upgrade function:

impl Upgrades {
    // Returns true if the given upgrade is currently equipped
    pub fn has_upgrade(&self, upgrade: u64) -> bool {
        self.0 & upgrade == upgrade
    }
}

Enum Representation

The bit-field representation worked. However, I soon faced difficulty when trying to limit the number of active upgrades the player could have at a time. In Beeline, the player could have a maximum of 2 upgrades at a time. With the bit-field representation, I had to check or keep track of the number of active upgrades. Although this is possible through evaluating the Hamming Weight of the bit-field or counting the number of times an upgrade was added, this seemed a bit excessive (pun intended).

So, upon reflection, I realized I had been overcomplicating the upgrade system. Typical.

The final iteration of the upgrade system revolved around expressing upgrades as an enum and an UpgradeTracker struct:

#[derive(Debug, Display, EnumIter, PartialEq, Copy, Clone)]
pub enum Upgrade {
    DoubleSpeed,
    Shrink,
    Teleport,
    SlowEnemies,
}

#[derive(Debug, Default)]
pub struct UpgradeTracker {
    pub primary: Option<Upgrade>,
    pub secondary: Option<Upgrade>,
}

Each variant of the Upgrade enum represents a unique upgrade. The Upgrade enum derives an EnumIter trait from the strum_macros crate, making iterating through upgrades trivial.

Although this approach made it more difficult to change the maximum number of upgrades (which is hard-coded at 22), I realized that the maximum upgrades would not change that often.

The submitted game used this enum representation rather than the bit-field representation.

Mechanics

Player Movement

One of the defining characteristics of Beeline is its movement mechanics. The player moves the bee around using the mouse. A greater distance between the player and the mouse cursor leads to a greater player velocity. This allowed the game to have simple controls (only relying on the mouse) with a subtle layer of dynamic movement. Player movement was implemented by virchau13 in this commit.

Pursuit Curves

The first enemy we implemented was the missile.

missile sprite missile sprite missile sprite

In Beeline, the missile follows the player using a pursuit curve. Essentially, the direction of the missile’s velocity vector always points towards the player. Using pursuit curves made the missile’s movement seem more missile-like (whatever that means).

pursuit curve

The logic behind pursuit curves can be explained using vectors. Firstly, let the missile’s position and the player’s position be M\textbf{M} and P\textbf{P} respectively.

The components of both of these position vectors can be expressed as functions with respect to time tt:

M=[Mx(t)My(t)] \textbf{M} = \begin{bmatrix} \textbf{M}_x (t) \\ \textbf{M}_y (t) \end{bmatrix} P=[Px(t)Py(t)] \textbf{P} = \begin{bmatrix} \textbf{P}_x (t) \\ \textbf{P}_y (t) \end{bmatrix}

Hence, the velocity vector of the missile could be represented as dMdt\frac{\mathrm d \textbf{M}}{\mathrm dt} (the rate of change in the missile’s position with respect to time) where:

dMdt=βMPMP, βR+\frac{\mathrm d \textbf{M}}{\mathrm dt} = \beta \cdot \frac{\overrightarrow{\textbf{MP}}}{\lvert \overrightarrow{\textbf{MP}} \rvert},\ \beta \in \Reals ^ +

As mentioned before, the missile’s velocity must always point towards the player. Thus, the velocity’s direction vector is proportional to the direction from M\textbf{M} to P\textbf{P}:

    dMdtMP=PM\implies \frac{\mathrm d \textbf{M}}{\mathrm dt} \propto \overrightarrow{\textbf{MP}} = \textbf{P} - \textbf{M}

Furthermore, the missile’s speed is inversely proportional to the magnitude of MP\overrightarrow{\textbf{MP}} to ensure the missile does not arbitrarily speed up as the distance between M\textbf{M} and P\textbf{P} increases:

    dMdt1MP=1(PxMx)2+(PyMy)2\implies \left\lvert \frac{\mathrm d \textbf{M}}{\mathrm dt} \right\rvert \propto \frac{1}{\lvert \overrightarrow{\textbf{MP}} \rvert} = \frac{1}{\sqrt{(\textbf{P}_x - \textbf{M}_x)^2 + (\textbf{P}_y - \textbf{M}_y)^2}}

The components of dMdt\frac{\mathrm d \textbf{M}}{\mathrm dt} are then added to the missile’s position every game tick.

Level Loading

I’ve never been a fan of hard-coding levels in source files. Using a file format like a .csv or a .tsv is a much better alternative. In Beeline, each level is saved as a tab-separated values (.tsv) file.

Here is an excerpt of one of Beeline’s levels in TSV format:

#	#	#	#	#	#	#	#	#	#	#	#	#	#	#	#	#	#	#	#	#	#	#	#	#	#
#	#	#	#	#	#	#	#	#	#	#	#	#	#	#	#	#	#	#	#	#	#	#	#	#	#
#	#																							#	#
#	#		M																					#	#
#	#		M																T					#	#
#	#		M																T	G				#	#
#	#		M																T	T	T			#	#
#	#		M																					#	#
#	#		M																					#	#
#	#		M																					#	#
#	#		M																					#	#
#	#		M																					#	#
#	#		M																					#	#
#	#		M																					#	#
#	#		M																					#	#
#	#		M																					#	#
#	#		M																					#	#
#	#		M																					#	#
#	#		M																					#	#
#	#		M																					#	#
#	#		M			*																		#	#
#	#		M																					#	#
#	#		M																					#	#
#	#		M	M	M	M	M	M	M	M	M	M	M	M	M	M	M	M	M	M	M	M		#	#
#	#																							#	#
#	#	#	#	#	#	#	#	#	#	#	#	#	#	#	#	#	#	#	#	#	#	#	#	#	#
#	#	#	#	#	#	#	#	#	#	#	#	#	#	#	#	#	#	#	#	#	#	#	#	#	#

Each value in the TSV represents a tile.

Here is the corresponding level loaded and rendered in Beeline:

level

Essentially, each tile type is associated with a unique character in the .tsv file. The game then reads the .tsv file and parses it by iterating over each substring separated by a tab character ('\t'). The relations between the tiles and their corresponding characters are summarized in the following table:

TileCharacter
Empty' ' or '.'
Wall'#'
Laser Spawner'L'
Missile Spawner'M'
Trap'T'
Goal'G'
Spawn position'*'

The laser tile also has a custom direction parameter defined in the .tsv file. Specifying L:3.14 in the level file would create a laser spawner pointed π\approx \pi radians away from the positive xx-axis.

laser sprite laser sprite

Takeaways

From the added pressure of a looming deadline to the motivation given by teammates, the process of creating Beeline was different from developing games alone. Here are some of the things I learned throughout the development process.

Use external libraries

Modularity is one of Bevy’s design goals; it allows individual engine components to be optionally used or neglected. Consequently, adding third-party libraries is as easy as calling .add_plugin().

Taking advantage of third-party plugins granted more time to be spent on implementing Beeline’s more unique mechanics. For example, Beeline depends on external crates such as benimator and impacted. Both of these crates provide direct interfaces to integrate with Bevy. By including the aforementioned crates, collision detection and sprite animation were successfully implemented early on in the jam.

Prioritize making a release early

As required by the game jam:

“Submissions should be run-able on Windows, Mac, and Linux. This can be done by publishing native builds for each platform, by submitting a WASM/Web build, or both.”

While we were able to provide Beeline builds for Windows, Mac, Linux, and WASM, it was something that we left to the last day of the jam. Technically, when using Bevy, making cross-platform builds is simply a matter of adding a GitHub workflow. Bevy even provides a CI template.

However, there were some slight bumps when building Beeline for WASM. For example, the file system could not be used in the same way when building for WASM. Thus, we used the include_str macro to read level files:

pub const LEVELS: [(&str, &str); 8] = [

    (
        "Closing Doors",
        include_str!("../assets/levels/Levels_-_Beeline_-_Closing_Doors.tsv"),
    ),
    ("Cornered", include_str!("../assets/levels/cornered.tsv")),
    (
        "Serpentine",
        include_str!("../assets/levels/Beeline_-_Serpentine.tsv"),
    ),
    (
        "Snakes on a Plane",
        include_str!("../assets/levels/snakes-on-a-plane.tsv"),
    ),
    ("Chicken", include_str!("../assets/levels/chicken.tsv")),
    ("Maze", include_str!("../assets/levels/maze.tsv")),
    (
        "Down The Road",
        include_str!("../assets/levels/down-the-road.tsv"),
    ),
    (
        "Drift",
        include_str!("../assets/levels/Levels_-_Beeline_-_Drift.tsv"),
    ),

];

Although small, the discrepancies between different platforms may be the difference between submitting a game and failing to meet the deadline. Thankfully, Beeline was still submitted almost 8 hours before the actual deadline.

beeline cover