skip to content
busstop.dev

Bevy Jam 7

/ 7 min read

After a week of late nights, falling asleep at my desk, and more shader debugging than I’d like to admit - I shipped something! Or more accurately, we shipped something! My partner has been very supportive and also even volunteered to do the music and some of the art, it would definitely be a different game without her. We’re happy with how it turned out.


The Game

The concept was an isometric spell-based roguelite. You play as a mage, kill enemies, level up, unlock spells, and try to survive increasingly difficult enemies. In hindsight, I should’ve implemented enemy waves, but anway… The dream state mechanic tied progression together — your lucidity accumulated across runs, letting you unlock permanent upgrades between deaths. There were also spell downsides, which caused some negative effects, this didn’t produce the effect I wanted - definitely need more refining.

I wasn’t sure how doable it was for a solo dev, but I tried anyway. Looking back at it now, I think it is doable if you are keeping up with Bevy’s development and have a collection of crates, helpers and knowledge about the ecosystem. I definitely overestimated my knowledge of Bevy (I dicovered observers halfway into the jam).


What Went Well

Isometric Rendering

Honestly, this was the thing I was most nervous about going in, I didn’t know the math well enough. I still don’t, but I scraped by. I mainly had to figure out how to transform between my world coords and screen space, which is isometric.. which took me way too much time.

The main problem I found was that, you can lose the isometric perspective very easily. I suspect this is because moving in the Z direction and moving in the Y direction (in game world coordinates) was the same operation. To help sell the isometric perspective, I tried a few things:

  • no movement in Z direction - everything moved in a 2D plane. This also meant collision detection was 2D
  • use shadows - this probably made the most difference. Some of this I did by hand, others was computed based on the objects radius and elevation. Basically, the higher the Z coordinate, the smaller the shadow should be. There are a few areas that were problematic (like emissive objects), but it looked serviceable.
  • add a grid floor - I was originally planning a more organic look for the floor, but it looked confusing. The grid look, helped sell it IMO.

Custom Shaders

Writing shaders is probably the activity I look forward the most, when coding a game, and this was no exception. However, I tend to spend too much time on this, neglecting the actual game. This is what happened in my previous entry, it looked nice, but it was more of a tech demo than a game.

First shader I wrote was for the “terrain”. It’s basically just an isometric grid. My original plan for this was to be very dynamic and each tile would react to what is happening, enemy dying, spawning, etc. But I never got to it, and in hindight, it probably wouldn’t produce the effect I was after. Instead, what I ended up with is a grid that showed one of the variables I use when determining the stats of the enemies being spawned, it was just for debugging, but it looked decent enough that I decided to keep it.

The other notable shader that I made was probably the fireball explosion effect. The other spell effects didn’t really care about the isometric projection since they were spheres. But the explosion needed to look like an explosion at ground level, so like a dome. It took me almost the whole of Saturday afternoon I think, but I got there and I think it looks good. Here’s the bit that matters:

let num_slices = 5.;
let cuv = (in.uv * 2.0 - 1.0) * radius;
var brightness = 0.0;
var color = vec3(0.0);

for (var i = 0.0; i < num_slices; i += 1.0) {
  let z = i/num_slices;

  // screen to world coord transformation
  // world coords are uv.x, uv.y, and z
  let cy = cuv.y + (z * 0.4);
  let frag_world = vec2<f32>(
      cuv.x + cy / 0.5,
      cy / 0.5 - cuv.x
  );

  var dist_2d = length(vec3(frag_world, z));
  let dome_height = sqrt(max(0.0, 1.0 - dist_2d * dist_2d)) * 0.5; <-- adjust the height of dome
  brightness = brightness + dome_height;
}

This does a few passes, I initially did 20, but reducing it to 5 still produced a good effect. Each pass slices the world space into multiple 2d planes. The first pass, is where z=0, which is the base of the dome. The last pass would be plane tangent to the the top of the dome. So in each pass, it computes the point where a ray from the fragment intersects with the plane. If this point is inside the dome, it adds to the brightness. This produces the basic dome shape. Then you can apply the usualy noise textures or whatever shader technique using this new brightness value.

fireball

There are a bunch more shaders on there, but this were the memorable ones for me.

Spell System Architecture

The spell system ended up being well-designed, I did take a few shortcuts closer to the deadline, but I think it’ll hold up if I clean it up and continue developing the game (probably won’t). Each spell is defined by a generator struct, upgrade rolls are configured in RON files, and the whole thing composes cleanly. Building the level-up UI on top of it — with readable descriptions of each upgrade generated at roll time — felt like the design was paying off.


What Was Hard

I am usually up to date with Bevy releases, but I missed the last few release, which were big. Thanks to that, things were much harder than it should have. Messages, observers, component hooks, easing curves, animation graphs! These were just a few things that I wish I knew at the beginning. We even have animation blending now, which was one of the blockers for me when trying to make something in 3D. Maybe I will try a 3D game next!

Another difficulty I have is with the UI, I haven’t nailed down a pattern that works for me yet. But I have seen some impressive UI’s this Jam, so I’m sure it’s just me. The timing for this Jam has been a bit bad for me, hopefully I can prepare for the next one.

Other than that, Bevy has been awesome as usual. I think the addition of observers and component hooks is a game changer, now I can pick which composition makes more sense.

A week is not a lot of time. I had a full-time job running in parallel, and there were days where I could only put in a couple of hours. The game I shipped was maybe 60% of the game I had in my head at the start. The enemy AI is simpler than I wanted. The audio is minimal. There are rough edges I know about and couldn’t fix in time.


Would I Do It Again?

Probably.

These jams give me the opportunity to work on things that I like, music, sfx, rust, games etc, but more importantly it forces me to ship something. There’s something about the constraint of a deadline that cuts through indecision and makes you commit to solutions rather than endlessly refining them.

The game isn’t what I imagined at the start. It’s rough, slow sometimes, but it runs and it’s shipped!

Hopefully, I can take this momentum and finish my other side project (and the other one after that). And if time allows, I’ll do this again.