Preface
Building a video game during November has become a yearly tradition to me. Save for 2019, I've been casually participating in the GitHub Game Off game jams since 2017. And each time I do, I have so far brought along a different set of technologies to learn and apply in the development of a new video game.
Before I dive in to some notes about this year's game, here is a quick history of my past works:
- GitHub Game Off 2017: Propan, written in Rust with the Piston game engine, is a 2D topdown game inspired by another game from the early 1990's, Helious.
- GitHub Game Off 2018: Pkay is a pong/tower-defense hybrid made in Godot. It also featured a two-mode music loop comprising a combination of synthesised instruments with my own guitar recordings.
- For GitHub Game Off 2019, I had already started working on a one-button platform game with code name "vivo", but the core game mechanics were not all complete, and I got sick during that month, which made me lose valuable time. The unexpected illness and lack of motivation towards the game made me unable to finish it.
- Still, I grabbed Phaser.js again in the following year to make Win in Space, a turn-based physics game for Game Off 2020 written in TypeScript. Fun fact: This one featured a full-length background music with the same name, and I named the game after the track rather than the other way around.
- Last year, for Game Off 2021, I went for quite a different vibe with a full Web experience: 10x Sprint Master is a simulation strategy game where you pretend to be a lead software engineer, written in HTML, CSS, and Rust for WebAssembly. I wrote more about this one here.
Introducting Timely Defuse
In Timely Defuse, dynamites and bombs 💣 are thrown onto the game environment out of nowhere, and your job is to control the hero to defuse the dynamites and disarm the bombs before they explode. Dynamites are defused by grabbing them within reach. Bombs are disarmed by letting the hero tinker with the bomb for a few seconds 🤔. As you successfully stop these from exploding ✂, you gain points. Let bombs explode on you 🤯, and you lose points. The game proceeds in waves of increasing difficulty, as the explosives appear faster and in greater numbers. As a boost bonus, you can grab randomly thrown coffee mugs ☕ for a temporary increase of speed and disarming efficiency ⏫. After the final wave, a summary of your performance appears, including the final score 🏆.
Conceiving the idea
The theme for GitHub GameOff 2022 is cliché. Participants are free to interpret it the way they want.
So how did I get from cliché to the game that we have here? Well, it was far from trivial. The official page provided a link to a comprehensive list of clichés, but I tried to write down a short list of my own:
- Where do you keep your inventory?: a very common question in point-and-click adventures.
- Jump onto enemies to kill them
- flappy bird clone (FWIW would provide a good mobile game experience)
- break things to get money
- "press start to play"
- mute protagonist (and the talkative sidekick who says everything for you, of course!)
- miraculous food (have a burger, it'll seal that stab wound in no time)
- Saved at the last moment
And not long after, I felt like reflecting deeper into the last one: a very common cliché in films and games, sometimes some characters are saved just before something bad happens. The associated TV trope is Just in Time.
So at this point I kept thinking about some mechanics in which the player had to act at the last second. I even pondered turning this into a puzzle game, where you are expected to push buttons and drag boxes until you could finally disarm a bomb one second before it explodes. However, I did not pursue this idea, for a puzzle game would have required me to devise each level manually with intricate pieces that I did not even know what they were in order to make the game interesting.
By making it more action oriented, the game idea was starting to direct itself towards the player having to touch lit explosives before they blew up. Pretty much like a point-and-click reaction game. But I still did not know how to make it interesting from here.
I had already tinkered with the game engine so as to understand whether I would be able to implement a mobile Web game with touch recognition easily. The answer was yes, the game engine allowed this with ease. And only after I had this answer, I came up with the idea of the player not being the one grabbing the explosives, but handing that job to an in-game protagonist instead! This made room for noteworthy mechanisms:
- By letting the player tell where the protagonist should move, actions on any object would take additional time depending on the distance between the protagonist and an object, so the player had to act fast and decide which objects to take care of first, at the risk of not being able to handle others on time.
- The protagonist would have a set of characteristics, namely maximum movement speed and bomb disarming efficiently, which could be temporarily boosted using power-ups.
- The protagonist would also be vulnerable to explosion damage, affecting the hero by blasting them away from the explosion and needing time to recover. I had thought of having a life counter which would decrease when taking a hit, but I felt that the game was fun enough to play with just the stunning effect.
- As a "stress" mechanism, the protagonist disarms bombs substantially faster if they are very close to exploding, making it sometimes advantageous to disarm them later.
With this, most of the idea had been set. It was time to get my hands dirty.
Technical notes
Next I will just write an assortment of topics about the development of Timely Defuse.
Mobile first
This time, after the disappointment of making a game which was not mobile friendly (also too philosophical, but I digress...), I had decided that this time I wanted the game to be not only Web first, but also mobile first. Picking a fixed resolution in (scaled) pixels which would fit on the great majority of devices seemed to do it well, although one cannot be fully certain of complete compatibility with all devices in this manner. For what it's worth, there were no complaints so far from close relatives and friends.
Using Bevy
I added an extra pinch of challenge and learning opportunities by trying out the Bevy game engine. Bevy has been rising in popularity in the Rust game development circles as a simple but capable data-driven engine, with support for many platforms, including WebAssembly, backed by its own Entity Component System (ECS) framework. I had heard many times about ECS, even back in 2017 after I had finished Propan, but this was my first hands-on experience in this pattern.
Easy to bootstrap
Setting up a barebones Bevy application was easy, especially considering that the official site provides a small book so many examples to experiment with, including ones covering full Web support. What may take longer to process is how one should develop complete projects in Bevy from start to finish, and for that we would need more references than just the book, which is still a bit thin at the moment. The Bevy Cheatbook ended up being a very valuable resource, which I kept some tabs open most of the time. Despite not being official, I really cannot understate how important this cheatbook was in working with Bevy.
Systems, systems, syssytsetmsems
In ECS, game logic is extended by adding systems. In Bevy, systems are plain functions receiving an assortment of compatible parameters, in a way that reminds me of dependency injection systems. It was very clever of Bevy to take advantage of Rust's trait system to describe system functions. They can even describe whether components are to be queried with immutable or mutable access. Whenever we had to adjust a system function, changes to the system inclusion point were seldom needed. We would just change the signature and use it accordingly within the function.
However, using ECS was not always a walk in the park.
Systems can be pretty powerful in describing game logic, and I quickly ended up writing a bunch of common patterns:
- Removing a component from an entity after some time;
- Removing the whole entity after some time;
- Sending an event after some time;
- Adding a component to a specific entity after some time.
And yet, I must have overlooked something, as there didn't seem to be an easy construct for creating these kinds of systems other than just a Timer
. This means that a lot of these systems ended up looking like this:
/// system: do stuff when the time is right
pub fn do_stuff(
mut commands: Commands,
time: Res<Time>,
mut query: Query<(
Entity,
&mut DelayedStuff
)>,
) {
for (entity, mut delayed_stuff) in &mut query {
delayed_stuff.timer.tick(time.delta());
if delayed_stuff.timer.just_finished() {
// do stuff
// destroy entity for example
commands.entity(entity).despawn();
}
}
}
The repetition is something I would not encourage, but it was how I got by.
Granted, it is possible to write generic functions as systems. Here is a real example. Given structs ScheduledEvent<E>
and DespawnOnTrigger
, describing components:
/// component to fire an event after some time
#[derive(Component)]
pub struct ScheduledEvent<E: Send> {
timer: Timer,
event: E,
}
/// component to despawn the entity
/// when the trigger in `ScheduledEvent` is fired
#[derive(Default, Component)]
pub struct DespawnOnTrigger;
I had this system:
/// system: run scheduled events if it's their time to trigger
pub fn run_scheduled_events<E: 'static + Send + Sync + Clone>(
mut commands: Commands,
time: Res<Time>,
mut query: Query<(Entity, &mut ScheduledEvent<E>, Option<&DespawnOnTrigger>)>,
mut event_writer: EventWriter<E>,
) {
for (entity, mut scheduled_event, despawn_on_trigger) in &mut query {
scheduled_event.timer.tick(time.delta());
if scheduled_event.timer.just_finished() {
event_writer.send(scheduled_event.event.clone());
if despawn_on_trigger.is_some() {
commands.entity(entity).despawn();
} else {
commands
.entity(entity)
.remove::<ScheduledEvent<E>>()
.remove::<crate::spawner::SpawnerCooldown>()
.remove::<crate::spawner::PendingThrow>();
}
}
}
}
It's incredible how I am just now seeing the Swiss cheese full of holes that this system is. When the DespawnOnTrigger
component is not present, it will remove the scheduled event component. This is so that the component doesn't stick around long after it's no longer needed, and so that it can eventually accept a new scheduled event in the future. But not only that, the function will also remove a bunch of other components pertaining to specific game logic. This is not as generic as I had envisioned.
Not to mention that generic systems cannot be included in the application as is. They need to be instantiated first. In this case, one for each possible event. One can imagine how easy it is to forget to instantiate the system for a new event.
app.add_system_set(
SystemSet::on_update(AppState::InGame)
.with_system(run_scheduled_events::<DynamiteThrownEvent>)
.with_system(run_scheduled_events::<BombThrownEvent>)
.with_system(run_scheduled_events::<CoffeeThrownEvent>)
.with_system(run_scheduled_events::<NextWaveEvent>)
)
(I would be thrilled to be told "You're doing this wrong", so long as there really is a better way!)
There's more about this ECS implementation. The non-deterministic order between systems by default was also a nasty source of bugs. Passing a series of systems like this:
app
.add_system(progress_bar::clear_progress_bar)
.add_system(progress_bar::update_progress_bar)
Means that the two systems can run in any order, and that order can even change between update frames! In this case, updating the progress bar would also make it visible, thus cancelling the effect of clearing it in the first place. I had to explicitly require one to be executed after the other.
app
.add_system(progress_bar::update_progress_bar)
.add_system(
progress_bar::clear_progress_bar
.after(progress_bar::update_progress_bar),
)
Unless you are careful about organizing your systems from the start through labelled system sets and whatnot, inconsistencies could happen, since they can just run in any order.
In hindsight, I would have resorted to tighter categorization of systems and possibly a much tighter execution order from the start, and perhaps relax the constraints selectively at a later stage. In any case, it feels unlikely that I would benefit a great deal from loose system order constraints in this scenario.
I also read that the Bevy CheatBook recommends iyes_loopless
, but this is something which should probably be added from the start rather than having to adapt an existing application. For me, it was too late to make that shift by the time the game was practically done.
My kingdom for an essential resource
Aside from the concept of entity, component, and system, we can have resources! These are intended for things which exist globally in the application, akin to singletons. Examples of resources from the standard bevy crates include the asset loader (AssetLoader
), event readers and writers (EventReader<E>
/EventWriter<E>
), and the application time tracker Time
. Like for entities and components, they can be retrieved from any system.
Remember when I mentioned that system function signatures can just be tweaked to gain access to other components? Well, this applies to system functions only. I often had functions which looked like this:
pub fn spawn_dynamite(
commands: &mut Commands,
texture_atlas: Handle<TextureAtlas>,
bounce_sound: Handle<AudioSource>,
pos: Vec3,
velocity: Vec3,
) -> Entity {
// ....
}
This is not designed like a system because it isn't one. It is a function to be called inside another function, with the following caveats:
- It had to be called with the right set of parameters.
texture_atlas
had to be a handle the texture atlas containing the animation frames of the dynamite, andbounce_sound
had to be a handle to the sound that the dynamite does when it bounces. Each of these are nothing but global dependencies with only one possible (correct) value. - Any dependency in this function needs to be passed along the callers. As such, any system which needed to spawn a dynamite had to declare the need for a dynamite texture atlas resource and for the handles sound effects (which in the latter case I coupled together into a single resource for convenience), so that the handles could be passed here.
/// system: on dynamite thrown, spawn it
pub fn throw_dynamite(
mut commands: Commands,
mut rng: ResMut<Rng>,
texture_atlas: Res<DynamiteTextureAtlas>,
sound_sources: Res<GameSoundSources>,
mut event_reader: EventReader<DynamiteThrownEvent>,
) {
for _ in event_reader.iter() {
let pos = random_xy_position(&mut rng);
crate::dynamite::spawn_dynamite(
&mut commands,
texture_atlas.0.clone(),
sound_sources.thwack3.clone(),
pos.extend(1200.),
random_velocity_variations(&mut rng),
);
}
event_reader.clear();
}
This was awkward in some place, where I even needed to inject not only resources but also specific query objects.
It might be that one could work around this by using system chaining, something which I may have understated when working on the game.
UI nodes
I can appreciate the fact that the engine comes with a user interface module, also running through ECS. With it, you can declare a graph of UI nodes by spawning entities with the right set of components so that they become frames, buttons, and so on.
Timely Defuse was thin on UI, but it was helpful to have a straightforward way to make text appear in the way that was predictable. The use of Flexbox from the CSS world to describe node styling was peculiar, but a good decision overall. It does not mean that you will always get the presentation that you hoped for the first try, but heh.
Safety obstacles
I wouldn't expect to stumble upon such a problem, but it may happen that the game starts with no audio. The latest browser security mechanisms imposes that audio contexts must only be created after user interaction. This means that if you enter the game page through GitHub without clicking on anything, you might get no sound at all.
I did some checking and it's true that even my other Web based submission, Win in Space, will only have sound and music playing as soon as I interact with the page. The difference was that Phaser.js has a mechanism to resume the existing audio context if it was create too soon to be able to engage, but Bevy as of v0.9.0 does not. Maybe I should file an issue.
Compilation times
Bevy boasts having achieved quick compilation times in development mode in comparison with other frameworks, going as far as to suggest that "you can expect 0.8-3.0 seconds with the "fast compiles" configuration".
It is good that the project goes to great lengths to reduce compile times. However, to test the WebAssembly build, some of the options suggested to improve compilation performance are not available. With opt-level = 1
and dynamic linking, it would take at least 10 seconds to build this application with a single change to the source code on my 2019 laptop. I could only get the alleged compilation times by only optimizing the dependencies and leaving the end crate at opt-level 0. The other suggestions to change the linker may be also important, but again, they may be out of reach when working for the Web.
Ultimately, optimization options for release were focused on reducing size. The WebAssembly file for production was 17 MiB large. In-game performance was mostly OK.
Conclusion
As typical in a GitHub Game Off submission, the full source and assets are available. Many of the graphics and sound effects were retrieved and adapted from free sources. You can play the game in a modern browser, using a mobile device or desktop, from GitHub or from itch.io.
Just keep defusing and nobody explodes.
Top comments (0)