#StandWithUkraine

Russian Aggression Must Stop


Linux Game Jam 2023: Collateral Damage Post-Mortem

2023/07/08

Tags: linux game dev gaming programming

It is time for the yearly blog post looking back at the latest Linux Game Jam and peforming a post-mortem on our entry into the jam.

Our entry this year was Collateral Damage, developed by me and Tuubi with art work contributed by Maija.

Our entry was a game inspired by the many Vampire Survivors type games, where most of combat is automated for you with the exception of movement and where killing more enemies either lets you pick up more weapons or increases their potency.

Collateral Damage ranked 26th on the jam out of 47 games and therefore was one of the worse performing entries we've submitted in Linux Game Jams. In contrast:

The game concept

In order to begin our post-mortem, I think it's a good idea to give you a proper view into what we were trying to build, since some sacrifices had to be made on the way.

We get a lot of satisfaction from subverting expectations one way or another. We either use interesting technologies, such as ncurses graphics with Cursed Pl@tformer or we build our music using tracker software such as in the case of BEAT NOID, or include only a single verb in a text adventure game as was the case with ENDUSER. Bending technology or a concept in unexpected directions is really fun for us and usually allows us to focus on a simple core idea and then build up around it in interesting ways.

When it came to Collateral Damage, we latched onto one of the optional themes for the jam: "aftermath". The main inspiration for the gameplay we would build upon came from a personal claim of mine, which is that it's not that hard to build a Vampire Survivors type of a game. So, our game concept aimed to marry those two ideas together into a game that would subvert a typical Vampire Survivors game.

In Vampire Survivors and its many imitators, the aim is to become stronger and stronger to the point that you mow down massive hordes of enemies and at the end your screen is increasingly filled with weapon effects and distinguishing individual enemies becomes wholly unnecessary and even difficult. One of my favourite games in the genre, Soulstone Survivors, often gets so ridiculous that it's almost impossible to see anything clearly on the screen. It's all just filled with explosions and weapon swings and projectiles and scores that keep climbing as more and more enemies meet their demise.

So, we got to thinking: what if instead of merely killing loads of enemies, you had a secondary task of keeping friendly people safe? Becoming more powerful would certainly make it easier for you to destroy the enemies, but you would also pose a threat to those you are trying to protect. This is also aided by the fact that you have very little direct control over your weaponry in the genre, your weapons fire automatically and often target enemies by themselves.

/img/lgj2023/game_over.png
Collateral Damage game over screen

The plan was to lure the player into gaining more and more power, which would eventually culminate to them essentially wiping out all of the innocent civilians in the city. The climax of this would be unlocking the nuclear bomb, which kills everything on the level and halts the enemy attack, leaving you standing alone in the wind, contemplating your "victory".

Our technology stack

Since we were aiming for mechanical subversion rather than technological, we mostly settled on technologies we'd already had success with. Therefore we basically just took the tools we used to construct BEAT NOID in order to get a running start.

Our technology stack therefore consisted of the following:

Tiled was the biggest addition, since we hadn't used it before. However, we considered it to be a good addition to help us create the level for our game. Previously we've used custom map formats such as storing them in ASCII characters, but proper map editing tools that we wouldn't need to create ourselves seemed valuable for a game jam.

Additionally we once again used Codeberg as our code forge and took advantage of its issues system to track completion of work.

Cost of hubris - our failures

As far as game concepts go, this was definitely our most ambitious attempt yet. Previously we have always started with a simple concept and then built up, but in the case of Collateral Damage, we started out with a big concept from the start. On top of this, we were only able to mobilize two of us to work on the code for the game and we had to do so while balancing with long work days. At the end, this would turn out to be a fairly big problem for us.

Because we were implementing a fully graphical game, there was a need to create map tiles and sprites for a lot of things. This work was luckily possible to do in parallel with building the game mechanics. While Tuubi set out to work on some sprites and to figure out how Tiled works, I started crafting the necessary systems and components to represent basic gameplay, such as movement, weapons and projectiles, hitboxes etc.

At the beginning I felt like we had a decent velocity, but we also lost some time due to just being kind of tired after work and the amount of mechanics to implement was quite huge, not to mention all the graphical assets that were needed.

We also found out that there were certain limitations to the Tiled ecosystem on the Rust side and some features we'd expected simply weren't possible. I believe we tried to use rotated tiles to achieve more with fewer tiles, but that simply wasn't implemented in the libraries we browsed through. So, some tiles had to be duplicated and rotated manually for them to work correctly.

Another problem was that the delay of the arrival of the map tiles meant that the game spent a large amount of time in a state, where the player and enemies only moved on an empty plane. This meant that the playtesting of the mechanics was performed in a very simple environment. This turned out to be quite insufficient in terms of figuring out balance and getting a good feeling of how the game would play in practice. When we eventually got the map tiles integrated, we immediately noticed that the enemies were getting stuck on terrain a lot. This necessitated the introduction of pathfinding logic, which was hastily pulled out of one of my earlier public and open source games and integrated into Collateral Damage. However, because this was done essentially during the last two days of the project, the results were fairly disappointing. Enemies still occasionally got stuck due to difficult to debug reasons.

Us running out of time also meant that we had to focus on an ever-narrowing set of gameplay mechanics in order to pull off a complete game at all. The first sacrifice we had to make was destructible terrain, which we'd hoped would make the environment feel more alive and which would affect the spawn rates of enemies and innocent civilians. Then we had to basically scrap good parts of the civilian interactions too. At the concept level we'd even entertained ideas about the civilians eventually turning against you when you caused enough damage, but that simply wasn't possible within the time limits.

This had the unfortunate effect of making the civilians more like set dressing rather than the core part of the game as we'd envisioned earlier. The result was that killing the civilians is hardly even noticeable and the only real effect it has is a counter that goes up over time.

Our weapon balance was also heavily compromised due to lack of proper testing. At the end we realized that stacking multiple SMGs on top of each other basically was an objective winning strategy and therefore there is little point in using anything else. We also didn't have the time to create more difficult enemies, which means that realistically you will only die if you play really poorly. The enemies also don't scale and their spawn rates remain fixed, which means that instead of a growing horde, you just have to walk from spawner to spawner to kill the enemies, which makes for a boring slog through the game on your way to the nuke.

Therefore we weren't too surprised to hear that our game felt lacking to the players, because it most definitely was. While we had managed to scrape together all of the basics by the end of the game jam, all of the parts that made our concept interesting had to be scaled back or scrapped. And the limited gameplay and problems with the balancing of the arsenal caused the game to simply not hold up very well.

Our victories

Although we didn't really meet the mark we'd set out, there were a number of small victories as well.

This was our first game utilizing graphical map tiles and despite some of the problems, the map tiles turned out really well. Apart from pathfinding issues, they also integrated decently well into the game. This opens up a lot of options in the future when we're either jamming or building games on our own free time.

Secondly, we were able to pull off some moderately interesting technical tricks to improve the performance of the game. Probably the best trick I learned was rendering the enemy corpse sprites onto a texture and then rendering the texture as an overlay on top of the map to reduce processing times on rendering the dead bodies. Since you do rack up a lot of kills over the course of the game, getting corpse rendering to be efficient was considered quite important. This trick isn't really complicated, but learning how to do it with Macroquad was fun and previously I probably would have naively tried to just hold onto a bunch of dead entities.

/img/lgj2023/gameplay.png
Gameplay of Collateral Damage

Furthermore, I was really happy with how much variety we were able to put into the arsenal with very little code.

The weapon related components of our game look like this:

  
  pub struct Weapon {
      pub timer: f32,
      pub reset: f32,
      pub projectile_expiration: f32,
      pub number_of_projectiles: u8,
      pub spread: f32,
      pub sfx: String,
      pub sprite: String,
      pub projectile_props: ProjectileProperties,
      pub projectile_type: ProjectileType, 
      pub weapon_type: WeaponType
  }

  pub struct ProjectileProperties {
      pub visible: bool,
      pub color: Color,
      pub hitbox: Hitbox,
      pub particles: Option<ParticleSpawner>,
      pub velocity: f32,
      pub velocity_multiplier: VelocityMultiplier
  }

  #[derive(Copy, Clone, PartialEq)]
  pub enum ProjectileType {
      Kinetic { damage: f32 },
      DamageOverTime { damage: f32 },
      Explosive { lifetime: f32, blast_radius: f32, damage: f32 },
      Nuclear
  }

  pub enum WeaponType {
      Singlefire,
      Burst { fired: u8, projectiles: u8, burst_timer: f32, burst_timer_reset: f32 },
  }

Even though on the surface this only allows you to create quite basic weapons, you can cheat a little bit to create effects that seem more complex. For example, the artillery effects in the game are implemented by creating a weapon with a 360 degree random spread and with invisible explosive projectiles. This creates the illusion that there are explosions being spawned around the player, but really you are just firing a bunch of projectiles in random directions which explode after a while. The gas attack and the flamethrower were also all basically the same thing, simply some projectiles with particle spawners attached to them.

I've previously tried to implement similar style of weapon systems that would allow interesting combinations of effects to be represented, but with this system we accomplished quite a lot with very little and I'm proud of how far this simple system managed to take us.

I was also very happy with how well the tech stack we used served us again. While you may not initially think of Rust as a good option for quickly throwing together a video game, it actually has a number of characteristics which are handy. Firstly, the compiler is wonderful to work with and simply eliminates a lot of problems you might run into otherwise at runtime. And when combined with libraries with few external dependencies it allows you to quite easily produce binaries that just work and even cross-compile to other platforms easily. Thanks to Macroquad only having very basic platform dependencies, both the Linux and Windows versions of the game just ship a simple binary without needing to bundle DLLs or .so files with it.

Macroquad and Hecs are also really great for building a game quickly. They have very little boilerplate and simply let you get to building quickly without imposing too many architectural choices on you. Macroquad will happily abstract things like textures to simple handles that you can copy and store throughout the program and Hecs makes you ECS system just a bunch of simple function calls.

Note: there's a simple pattern that we used a lot with Hecs in order to bypass the problem of multiple mutable references when iterating through objects in the world. It looks like this:

pub fn civilian_run_away_system(world: &mut World) {
    let mut buffer = CommandBuffer::new();

    for (id, (civvie, position, velocity)) in world.query::<(&Civvie, &Position, &Velocity)>().iter() {
        let nearest_enemy_position = world.query::<(&Enemy, &Position)>()
            .iter()
            .map(|(id, (enemy, position))| position)
            .min_by(|a, b| a.distance_to(position).total_cmp(&b.distance_to(position)))
            .cloned();

        if let Some(enemy_position) = nearest_enemy_position {
            if position.distance_to(&enemy_position) > 300.0 {
                buffer.insert_one(id, Velocity { x: 0.0, y: 0.0 });
                continue;
            }

            let angle = position.angle_to(&enemy_position) - PI;

            buffer.insert_one(id, Velocity { x: angle.sin() * 50.0, y: angle.cos() * 50.0 })
        }

    }

    buffer.run_on(world);
}

I call it the buffered mutation pattern. To avoid multiple mutable references, which are disallowed in safe Rust, we simply convert those references into immutable ones and use a CommandBuffer to buffer the component mutations using CommandBuffer::insert_one(), which will cause the existing component to be overwritten. When all of your iterations have concluded, you can just run all of your buffered mutations on the world in one go!

What have we learned?

Even though Collateral Damage is a flawed game in many ways, I am happy that we managed to complete it at least somehow. Even though it isn't a very good game, it has a beginning, a middle and an end and generally speaking it functions well enough to be recognizable as a game.

It has also served as a good learning experience, sometimes you learn best from your mistakes after all!

The primary takeaway that we've gotten from Collateral Damage is the importance of properly scaling our game concepts. Especially when we are working essentially as a team of two, we need to decide on a concept that is quick to reach early and upon which more complexity can be poured as time allows. I believe this has been the primary ingredient for the success of ENDUSER, Cursed Pl@tformer and BEAT NOID: they are all simple games at their core but with enough interesting stuff built around them to elevate them higher. We focus more on achieving a lot with little and double-down on simplicity.

Furthermore we've learned that we should try to get a representative piece of gameplay together quite early in order to have enough time to tune things like game balance and to identify issues such as enemies getting stuck due to pathfinding issues.

Finally, for the future game jams we plan to take it a lot easier than we did this time. During this jam there was a lot of cursing and desperate last minute rushing to get the game together, which ended up being quite draining, especially when having to balance work alongside the jam. So, in 2024 we'll hopefully be the chillest Linux Game Jam team. Hopefully we'll also be able to participate with a more complete team as well.

But, that is all for now. You can go and try out the game on Itch if you want to or peruse the source code over on Codeberg. Until next time, thanks for your interest and thanks to Cheeseness for organizing the jam once again!

>> Home