Using bevy for the 2024 GMTK game jam

On August 20, 2024 by Sosthène Guédon

I used bevy to build a small game for the 2024 GMTK game jam. Having no prior experience with game development, this was a fun ride!

On Friday 16th August of 2024, a friend of mine who's a professional game developer told me he was going to take part in the 2024 GMTK game jam.

I've always wanted to be able to make my own small games, so this seemed like an opportunity to challenge myself and actually do it. I decided to try building my own game over the week-end.

I'm a professional developer, but I have no experience ever building games. I'm also a big fan of Free and Open Source software, as well as the Rust programming language. This made me naturally gravitate towards Bevy, an Open Source Rust game engine that has made a lot of noise (amongst Rust devs at least).

I had played a bit with Bevy about a year ago, just enough to understand the concept of the ECS and get a character moving, but not much else.

I had a basic idea: build a game that looks a bit like Kerbal Space Program's solar system view, where you control a spacechip in a planetary system with real gravity. The goal would be inspired by the practice of "slingshotting" in the Sci-fi Book and TV series The Expanse, where daring pilots have to make the best out of gravity and their underpowered ships, beating speed records of navigating around Jupiter and Saturn's moons).

To make the UI much simpler, the game would be in 2D.

Friday night

Since I took the decision to participate very late, I started on friday after 22h (Paris time), and stayed up late in the night.

I started pretty simple, with the physics. I could have used a physics engine like Rapier or Avian, but chose to instead use my own for this game, for multiple reasons:

  • I have a STEM background so I already know the equations that would be relevant and how to solve them numerically
  • I wanted to be able to fast-forward, slow down, and rewind physics as I pleased. For that I needed deterministic physics and neither physics engine advertised those functionality.
  • It's fun

At around 2 in the morning, starting from a scratch empty bevy project (no template), I had a working start with:

  • A ship that was influenced by the gravity of planets
  • Planets that have predictable movements (the component for that was litteraly called OnRails),planets being either immobile or in a circular orbit (and recursiveley by orbiting around another planet).
  • A gizmo showing the predicted future path of the spaceship.
  • Zooming and dezooming with the scroll wheel

Saturday

After a good night of sleep, on saturday I got a bit more working:

  • Speeding and slowing time
  • Elliptical orbits

This was a bit more painful than I expected since there is no formula giving you the position of the planet at a given time, there are only formulas giving you the what time it will be when the planet reaches a specific point. My solution was to force the the period of the orbit to be a multiple of the game tick, and then to pre-calculate the position of the planet for each game tick in 1 orbit using a binary search, and storing that in the OnRails component of the palnet. This allowed for very fast lookups of the position at any time.

  • Moving the camera around
  • WebAssembly builds for itch.io

At that point I uploaded the first prototype on Itch.io, but I encountered an issue that the assets (Kenney assets, available on a Creative Commons license) would not load, due to Itch sending the wrong error code. At that point I decided to migrate to the bevy quickstart template so that I would get Itch.io builds working, and basic UI for my game. This was a bit more painful than expected, since initially all was in a single main.rs file, so as I split the parts of the gameplay in their own modules, I needed to fix all the imports and make pretty much everything pup(crate), but it was worth it. The gameplay ended up in the following modules:

  • input.rs handling inputs
  • level.rs spawning the level
  • physics.rs for the physics
  • planets.rs for defining the planets and their orbits
  • ship.rs to define the ship
  • render.rs to render everything

While there is a lot of cross-communication between the modules, everything I did seemed to naturally fall into one of them.

When I went to sleep I had a strong base but no gameplay. You could not impact the ship's movement at all!

Sunday

This was the last day of the jam, I had to finish everything by 19h. I started by implementing pausing the game physics (by setting the interval between ticks to Duration::MAX, I wish I found a better solution).

Then I implemented being able to click on the ship to drag on it and give it some Delta-V. Implementing that was pretty simple once I found the right method to detect clicks (viewport_to_world_2D), though I also had to battle with some logic that make the render of the ship always have the same size. This also pushed me to fix an issues I had with the first implementation of the dragging behaviour of world. Initially this dragging behaviour used MouseMotion, but this meant the dragging movement accumulated drift as each frame had a motion that was rounded to the nearest pixel. Instead, I had to make dragging stateful, so that the camera would not move when the ship is being dragged, and to use the absolute position of the mouse relative to the first click so that no drift would accumulate.

Now that I could control the ship, I added a "checkpoint" mechanism, allowing you to go backward and forward in time across checkpoints. Each time you modified the ship's trajectory, it would create a new checkpoint, and you could go back to the previous state. This is a core mechanic of the gameplay, since the goal will be to optimize the fuel usage. This checkpoint mechanism made it evident that the physics was not deterministic since reloading from a checkpoint did not lead to the same situation. I didn't understand why, and it took me a long time to get it. In the physics engine, I applied the attraction of each planet by iterating over them with Query::iter. But there were 2 problems:

  • I used f64 for the physics, and calculations on f64 can slightly vary depending on their order of application.
  • The iter method on queries do not have stable order

The simple solution I used was to sort the query results by their Entity ID by collecting them in a Vec, which is ugly but works. 1

After that I wanted to have the real gameplay. So I implemented collisions between the ship and the planets, which roll you back to the last checkpoint. Then updated the gizmos showing the predicted path of the ship to show collisions with planets with a red circle for where the planet will be , and an orange circle for the next close call.

After that I added targets, which are attached to planets, but with a larger radius, with a green Gizmo showing them.

Finally I added some basic UI showing:

  • The Deta-V consumed, you must try to use as little of it as possible
  • The number of remaining targets

At that point I realized that my checkpoint system did not restore the targets or the Delta-V, so I did 2 things:

  • Store the used Delta-V in the checkpoint so it could be restored
  • When a target is reached, instead of just removing it, it is stored in the latest checkpoint, so that the checkpoint system can be lazy and only restore the targets that have changed.

Finally, I added a winning screen, following the example from the template. I did not actually know how to despawn all gameplay entity even though the template seems to suggests that there are ways to scope systems (and also entities maybe?), so I just added a GameplayEntity component to everything so I could despawn them when changing to the winning screen.

All this was just in time for the soft deadline of 48h (the total game jam is 4 days, but I said 48h for my game).

I might polish the game a bit more in the future to actually make it enjoyable but for now that's it.

Feedback on using bevy

Overall bevy is a real joy to use. My impression is that video games are extremely complex to organize because everything interacts with everything else, easily leading to a big bowl of mutable spaghetty mess, but Bevy makes it trivially easy to separate very small bits of logic in their own components, which makes the bridges between the modules very clear. With Rust-Analyzer, it's pretty easy to reason about a specific component by just checking everywhere it's used. My IDE (Helix), can give a very good overview to the references of a component, and I suppose other IDEs do too.

I'm not sure whether this is a Rust Analyzer feature or a Helix feature, but my IDE often suggests autocompleting function argument definitions based on other arguments in other functions in the same module. This makes very easy for resources, which are often queried the same way. For example, typing tick in a function definition argument, it would suggest either: tick: Res<PhysicsTick> or mut tick: ResMut<PhysicsTick>, because that's what I used elsewhere.

I was also impressed at how little I had to think about lifetimes. While I'm way past the point of fighting with the borrow checker, I usually still have a couple times where I tell myself "wait this won't work" because of lifetimes when using Rust. This did not happen once during this jam. The only issue I encountered was that Mut does not support reborrowing, which might be confusing for new Rust users, but can be easily fixed by adding a let component = &mut*component; statement, so that the result becomes a real mut reference and not a struct that implements Deref.

At one point I had a system that would have to have 2 different behaviours for entities that have a component and for those that didn't. At first at though about doing 2 queries, one with the component and one without, which would have been ugly:

fn my_system(
    query_with: Query<(&ComponentA, &ComponentB)>,
    query_without: Query<(&ComponentA )>,
) {}

But then I just had the intuition to try with Option and it just worked:

fn my_system(
    query: Query<(&ComponentA, Option<&ComponentB>)>,
) {}

It's great when this "just works" as you would expect. Thanks Bevy devs!

On the other hand I had a similar intuition that didn't work. When spawning a bundle, it's not possible to optionally spawn the entity with a component (or I didn't find how). The following does not work.

let bundle: (ComponentA, Option<ComponentB>) =;
commands.spawn(bundle);

I wish it did, because I wanted a simple interface to spawn planets with or without a Target attached.

One issue I also had was a silent error when implementing the detection for target collision. I had the idea to only run the system checking for the win condition (no more targets left) when the system for detecting collisions with targets detected one. This would be a premature optimizations but why not? So I used both systems by running the target collision system as a condition:

app..add_systems(FixedUpdate, detect_win.run_if(remove_colling_targets))

For some reasons this broke the remove_colling_targets system, and the targets would always stay. At first I thought it was because Condition systems must be read-only, and this systems used commands that may have interacted weirdly, but I haven't been able to reproduce it. I was probably too tired and missed something :). I solved that simply with events.

Conclusion

Overall this was a very fun experience, and I might retry that in the future. Maybe with a team, so that the end game would be more complete, and with a smaller jam so that I can actually relate to the other entries. Some games of the GMTK game jam are so polished they look like real games!

Here's the game you can play in your browser on Itch.io: https://janali.itch.io/slingshot, the source code is available on codeberg, and here's a sneak peek screenshot:

Screenshot of the slinshot game. The UI shows: Delta-V consumed: 0.00 m/s 5 targets remain x1.0 (paused) a ship is orbiting a planet that has 3 moons, one moon has its own moon and one moon is off-screen, we only see the path its orbit takes


1 While writing this article I discovered that QueryIter has a set of sort methods that would have allowed me to avoid that unnecessary step. ↩︎