Skip to content

I Want to Be Done

With the game working once again, I can continue making forward progress. However, I've been craving something different. Building this game has been somewhat fulfilling over the last year or so, but I can't help but want to work on other projects - or even just not work on anything at all for a little while.

I find myself avoiding starting anything new because I feel obligated to finish what I started. This is a very different feeling for me - I don't normally care so much about abandoning projects, but something about writing this dev log compels me to press onward.

Today happens to be the first anniversary of my brother's death, so I suppose I am reaching for some sort of distraction. I am clearly isolating myself from others, but I also don't want to sit around and dwell on things. I have no idea what the "healthy" thing to do is, but let's see what we can get done.

19.1 Spicing Up the Visuals

Everything in the game is made up of basic shapes, and while I like the "programmer art" aesthetic, I need to differentiate some of the elements. Players don't seem to have much problem understanding that the green collectables are "good", but it hasn't been entirely clear that the red bombs should be avoided. I decided to add custom meshes for both of these elements in order to add clarity and variety to the game.

It would probably be easier to just whip up some textures in Photoshop and sample them from a new shader, but I like the idea of drawing basic shapes with flat colors for this game, so I decided to programmatically create the shapes using custom meshes.

For the power-ups, I created a new PlusMesh class in both the Metal and OpenGL renderers, and updated their respective MeshRegistry classes appropriately. The mesh itself consists of 10 triangles, organized into 5 quads (each made up of 2 triangles). The 5 quads represent the 4 "wings" of the plus sign, as well as the center filling.

For the bombs, I whipped up something a bit more complex. Inspired by naval mines, the new MineMesh is made up of 24 triangles: 16 of them are organized in a spiral around the center to make up a hexadecagon (a 16-sided polygon), while the remaining 8 triangles are organized around the center polygon to create "spikes". I also added a bit of logic to the SpawnSystem to randomize the rotation of the mines.

While updating the MeshType enum, I decided to organize the values alphabetically instead of appending the new types to the end. This had the unfortunate side effect of all Drawables and Selectables defaulting to the Mine type instead of the Quad, so I had to update the components to explicitly use the Quad type by default instead of relying on the implicit default enum value.

You can check out the actual code changes at this commit. It's really not too complicated. Hopefully the change makes the function of the objects more intuitive.

Custom Meshes

Trailing Particles

The parallax effect does a decent job at conveying a sense of motion, but I'd like to do something a little more. I'll add a quick and dirty particle emitter to the player's ship which will create tufts of smoke (simple circles), which will gradually shrink over time.

I'll need to create new Particle and Emitter components. The Emitter will contain a std::function that will be responsible for creating any arbitrary particle; it will also contain a frequency and the elapsed time since the last particle was spawned. The Particle component will similarly contain a duration and an elapsed time, so that the entity can be destroyed after its duration has expired.

This will all be powered by a new (remarkably simple) ParticleSystem.

linguine/src/systems/ParticleSystem.cpp

#include "ParticleSystem.h"

#include <glm/gtx/norm.hpp>

#include "components/Emitter.h"
#include "components/Particle.h"
#include "components/Transform.h"

namespace linguine {

void ParticleSystem::update(float deltaTime) {
  findEntities<Emitter>()->each([deltaTime](const Entity& entity) {
    auto emitter = entity.get<Emitter>();

    emitter->elapsed += deltaTime;

    if (emitter->elapsed >= emitter->frequency) {
      emitter->elapsed -= emitter->frequency;
      emitter->particleFactory();
    }
  });

  findEntities<Particle, Transform>()->each([deltaTime](Entity& entity) {
    auto particle = entity.get<Particle>();

    particle->elapsed += deltaTime;

    auto transform = entity.get<Transform>();
    transform->scale = glm::max(transform->scale - glm::vec3(0.25f) * deltaTime, glm::vec3(0.0f));

    if (particle->elapsed >= particle->duration || glm::length2(transform->scale) <= 0.0f) {
      entity.destroy();
    }
  });
}

}  // namespace linguine

linguine/src/scenes/InfiniteRunnerScene.h snippet

auto emitter = playerEntity->add<Emitter>([this, playerEntityId, &renderer]() {
  auto playerEntity = getEntityManager().getById(playerEntityId);

  auto particleEntity = createEntity();

  auto particle = particleEntity->add<Particle>();
  particle->duration = 5.0f;

  auto playerTransform = playerEntity->get<Transform>();

  auto randomScale = std::uniform_real_distribution(0.1f, 0.5f);
  auto randomX = std::uniform_real_distribution(-0.15f, 0.15f);

  auto transform = particleEntity->add<Transform>();
  transform->scale = glm::vec3(randomScale(_random));
  transform->position = { playerTransform->position.x + randomX(_random), playerTransform->position.y - 0.75f, 2.0f };

  auto circle = particleEntity->add<Circle>();
  circle->feature = new CircleFeature();
  circle->renderable = renderer.create(std::unique_ptr<CircleFeature>(circle->feature));
  circle.setRemovalListener([circle](const Entity e) {
    circle->renderable->destroy();
  });
});
emitter->frequency = 0.05f;

Particle System

A Shiny New Spaceship

The particle effect makes quite a difference in the visual style of the game, but our boring little triangle just isn't cutting it anymore. While I want to stick with the "programmer art" aesthetic, the player's ship should be a bit more intricate, since that's what the player will be staring at most of the time.

I whipped out the old iPad for the first time in several months and started scribbling in Procreate. I started getting a little frustrated with the imperfect shapes (even after using the line-assisting features), so I decided to use something more oriented around shapes: Adobe Illustrator.

I managed to whip up a nice little silhouette, and created a new ShipMesh to represent it within the game. Unfortunately, the plain white silhouette was underwhelming, to say the least. I grabbed the iPad and split the silhouette into several parts, using different colors from our palette.

Ship in Adobe Illustrator

Not bad.

I spent some time whipping up multiple "sub-meshes" in the game: the ShipMesh is just the center white portion; the WingMesh is obviously the gray wings; the CockpitMesh is the dark blue diamond in the center of the ship; the BoosterMesh is the black boosters on the back of the ship.

Many game engines and modelling applications allow you to split your models into multiple sub-meshes in this fashion, usually in order to apply different colors or "materials" to each sub-mesh. In the case of my engine, each of these sub-meshes is just treated as an individual entity, and I simply use the Attachment component to affix each of them to the main ship.

After getting all of that into the game, while the actual model looks nice, a couple of things jump out at me. First off, the model is just too small! It's strange how the basic triangle appeared to be sized appropriately, but when replaced with something more intricate, seems entirely inadequate! I doubled its size, and took care to do the same for its collider.

Secondly, it looks somewhat "empty" without any fire coming from the boosters, even with all that smoke trailing out the back. I created some little orange circles and fixated them to the boosters, and created a new FireSystem (which queries for entities with a new Fire component), which randomly adjusts the scale of the circles on each frame, in order to give the illusion of flickering fire.

I made a couple more positional adjustments, and also made the smoke randomly spawn white and gray circles. It's starting to look like a real game now! The details of the code can be found at this commit, but I warn you, it's a lot of manually-defined vertex positions.

Ship Model

More Particles

I want to juice it up a little bit more, so I'm going to add some explosive particle effects each time the player collides with an object. For the asteroids, I'll have a handful of orange particles quickly scatter from the impact point, as if the remains of the asteroid are bursting outward. For the mines, I'd like to achieve a fiery explosion of some sort. For the shield recovery power up, I think something more orderly would be best.

I suppose I'll make all of these changes within the ScoringSystem. It's not really the best place to do so, but it's currently where the collision logic is handled, so it would require the least amount of work.

I spent my night fine-tuning the effects, but I'm really happy with the results. The asteroids erupt into tiny pieces, each with a random scale and direction. The power ups, on the other hand, explode into uniformly-sized circles, evenly spaced around the impact location.

The mine explosions are definitely the most complicated, using an Emitter to create particles over a short period of time. Each particle is a random color - red, orange, gray, or white. The red and orange particles evaporate quickly, as if they were flames being snuffed out by the lack of oxygen. The white and grey particles linger for a longer period of time, to represent the smoke left by the explosion.

In order to achieve such a dynamic effect, I added a scalePerSecond field to the Particle component (to replace the previously-hardcoded value in the ParticleSystem). The flames reduce their scale by 2.0f per second, while the smoke is reduced by 0.5f per second. I also added a totalLifetime to the Emitter component (along with an elapsedLifetime), so that the Emitter entity is destroyed after a pre-defined amount of time has passed. If the lifetime is less-than-or-equal-to zero (the default value), then it will just never be destroyed.

For the sake of the screen recording, I disabled the damaging ability of the mines, but I've re-enabled it before committing the code. Here is the rather uninteresting commit. It's interesting how the most boring code can be the most impactful to the feel of the game.

Explosions in the Sky

Screen Shake

A common visual enhancement when polishing many games is a screen shaking effect. The effect can really emphasize the impact of the player's actions, and can also convey a sense of relative impact. For our game, I've decided to add the effect based on the size of the asteroid that the player has collided with. I also added a larger effect when the player happens to collide with a mine. Lastly I added an almost imperceivable shake when the player collects a power up.

I achieved the effect using a bolt-on ShakeSystem, which uses a Shake component to randomly adjust the Transform's position based on a given magnitude and duration. This system can technically be used for anything, not just CameraFixture entities, which is pretty cool, although irrelevant. It doesn't bother resetting the position of the entity; instead it relies on other systems to put things back where they belong - in this case, the PhysicalState of the camera is untouched, so the PhysicsInterpolationSystem will always put the Transform's position back where it needs to be, and the ShakeSystem will tweak the position slightly afterward.

I might continue to tweak the numbers later, as it can be a bit overwhelming (sometimes nauseating) when overdone. In any case, here is the current version of the implementation.

Screen Shake

19.2 Persistence and State Management

Today is my 34th birthday, and what better way to celebrate than to write more code?

The game and the underlying engine have come a long way, but there have been a couple of things I have entirely neglected thus far:

  1. Persistence - the game keeps all of its state in-memory, so if the player "swipes away" the app, restarts their phone, closes the browser tab, or otherwise kills the process, then they have to start the game entirely over.
  2. The application lifecycle - the game uses the device's real-time clock to determine how much time has passed since the last frame. As such, the game cannot "pause" when the app is in the background. Instead, when the app is brought back into the foreground, it determines that a long amount of time has passed, and so it must need to run a bunch of iterations of the fixedUpdate() method to catch up! This leads to the game hanging for a long time when the user brings the app back to the foreground as it tries to reconcile the state of the physics simulation. Often, the user (usually myself) closes the app entirely to skip the long pause, leading to back to problem #1.

The engine currently contains SaveManager and LifecycleManager classes, which were intended to eventually solve these exactly problems, but their current implementations are simply insufficient.

I made the SaveManager contain virtual methods to load() and save() so that the platforms could handle them in their own ways. However, I think I'll just stub out the functionality for Alfredo and Pesto, since they aren't actually what I'm targeting, effectively resulting in no persistence on those platforms.

Scampi's new IosSaveManager is very simple, utilizing the NSUserDefaults class to persist the player's accumulated points and upgrade ranks. The first page of documentation that I came across contained a lot of words but wasn't very helpful, though it did point me toward a programming guide that was much more useful.

scampi/src/platform/IosSaveManager.mm

#include "IosSaveManager.h"

#include <Foundation/Foundation.h>

namespace linguine::scampi {

IosSaveManager::IosSaveManager() : SaveManager() {
  NSDictionary *appDefaults = @{
    @"points" : @0,
    @"upgrade_0" : @0U,
    @"upgrade_1" : @0U,
    @"upgrade_2" : @0U,
    @"upgrade_3" : @0U
  };

  [[NSUserDefaults standardUserDefaults] registerDefaults:appDefaults];

  load();
}

void IosSaveManager::load() {
  NSDictionary *values = [[NSUserDefaults standardUserDefaults] dictionaryWithValuesForKeys:@[
    @"points",
    @"upgrade_0",
    @"upgrade_1",
    @"upgrade_2",
    @"upgrade_3"
  ]];

  _points = [values[@"points"] intValue];
  _upgradeRanks[0] = [values[@"upgrade_0"] unsignedIntValue];
  _upgradeRanks[1] = [values[@"upgrade_1"] unsignedIntValue];
  _upgradeRanks[2] = [values[@"upgrade_2"] unsignedIntValue];
  _upgradeRanks[3] = [values[@"upgrade_3"] unsignedIntValue];
}

void IosSaveManager::save() {
  NSDictionary *values = @{
    @"points" : [NSNumber numberWithInt:getPoints()],
    @"upgrade_0" : [NSNumber numberWithUnsignedInt:getRank(0)],
    @"upgrade_1" : [NSNumber numberWithUnsignedInt:getRank(1)],
    @"upgrade_2" : [NSNumber numberWithUnsignedInt:getRank(2)],
    @"upgrade_3" : [NSNumber numberWithUnsignedInt:getRank(3)]
  };

  [[NSUserDefaults standardUserDefaults] setValuesForKeysWithDictionary:values];
}

}  // namespace linguine::scampi

Lifecycle management is a little more complex. It's not too complicated to detect when the game should be paused, but actually implementing a proper "paused" state into the game might take a bit of work.

I mentioned earlier that the root of the problem is that the game always uses the real-time clock to determine how many iterations of the fixedUpdate() method need to be executed. If the engine were to believe that 0.0f seconds have passed, then it wouldn't bother "catching up". We can achieve such a result by introducing a "time scale".

I renamed the existing virtual durationInSeconds() method to realtimeDurationInSeconds(), and created a new non-virtual scaledDurationInSeconds() method, which returns the result of realtimeDurationInSeconds() multiplied by a new _timeScale factor.

On second thought, "scaled" duration isn't "seconds" at all. I'll just call the new method scaledDuration() instead, and revert the renaming of the old method back to durationInSeconds(). The word "seconds" implies the realtime nature of the result.

I also added a new setTimeScale(float) method, and refactored the ScampiAppDelegate to set the time scale to 0.0f within the applicationWillResignActive method, and back to 1.0f in the applicationDidBecomeActive method. Since the ScampiAppDelegate now owns the pointer to the TimeManager, I had to update the ScampiViewController to grab the pointer from the delegate in order to construct the Engine.

The result doesn't have any formal pause screen, but it at least correctly stops the physics simulation when the app goes into the background! I'll save the pause menu for another time.

I don't care much about the game continuing to run in the background with Alfredo, but I would like to give the same treatment to Pesto as I did for Scampi, since the game tends to hang if you happen to leave the browser tab.

Well that turned out to be way more effort than I anticipated. I started by setting the time scale to 0.0f on the browser's "blur" event, and resetting it to 1.0f on the "focus" event, but I couldn't quite get those events working with Emscripten. I did a bit of Googling to discover that there is a new "visibilitychanged" event that I should be using instead, so I switched my code over to that. The event was firing, but since the tab became visible before the actual update "tick", and the time scale was already set to 1.0f, the actual iteration of the game loop behaved as if I hadn't set the time scale to 0.0f at all (resulting in the hanging iterations of fixedUpdate() that I described before).

I decided that I needed to "reset" the state of the Engine's _currentTime in order for the first frame (after the "visibilitychanged" event) to calculate a delta time of 0.0f. Since the Engine itself is not accessible to the platform abstractions, I decided to move all of the time-keeping code into the base TimeManager class.

The result is a more complex TimeManager, a simplified Engine, a functional "background" state, and absolutely no change to the actual game play. You can view all of the changes here if you're interested.

19.3 A New Tutorial

Merry Christmas.

With the game rapidly becoming more and more polished, I think it's time that we revisit the tutorial. I don't want to waste too much of the player's time, but something quick and to the point would be beneficial. At the very least, I want to convey these concepts to the player:

  • Movement (swipe gestures on mobile, "A" and "S" on desktop)
  • How to earn points (plow into asteroids!)
  • How to heal (tap a health bar when it's low)
  • Avoid the mines (kablooey).

I started whipping up some overlay text in a new TutorialSystem. I wrote a funny little bug in which the text was rendered using the "world" camera rather than the UI camera, and that gave me a cool idea: I'll spawn the tutorial text in the world and let it scroll down the screen like Star Wars!

The tutorial is basically one big state machine, backed by a new TutorialState component. There's actually already a TutorialState component in the tutorial/ directory that I'm completely ignoring for now - I'll need to do a cleanup pass at some point. The new component contains a currentState field, which is an enum with a bunch of possible values.

linguine/src/components/TutorialState.h

#pragma once

namespace linguine {

struct TutorialState {
  enum class State {
    Movement,
    WaitingForMovement,
    Scoring,
    WaitingForScore,
    Healing,
    WaitingForHeal,
    Evasion,
    Finished
  };

  State currentState = State::Movement;
  float elapsed = 0.0f;
  bool hasMoved = false;
  int asteroidsSpawned = 0;
  bool hasScored = false;
  bool hasHealed = false;
};

}  // namespace linguine

The state machine is controlled in part by the TutorialSystem, which detects when the player has moved, scored, and healed. The rest of the logic lies within the SpawnSystem, which spawns the tutorial text, as well as the asteroids which are part of the tutorial. The SpawnSystem also refrains from spawning the normal asteroids/mines/power-ups until the tutorial is over.

The code is largely a bunch of conditional statements and entity composition, so it's not worth diving into. The only other thing worth mentioning is the addition of the _isNewPlayer field in the SaveManager, and the associated persistence code. The value defaults to true, and the InfiniteRunnerScene only constructs an entity with the TutorialState when it is true. After the player completes their first round, the value is persisted as false as part of the addPoints() method.

You can check out the rest of the code at this commit.

Scrolling Tutorial

19.4 Scene Sequencing

I'm really happy with the tutorial, but every time I polish one part of the game, the rest of it begins to feel a bit lackluster.

My wife tends to ignore the shop screen entirely because she feels overwhelmed by the wall of text. She winds up playing the game with no upgrades whatsoever, which results in her feeling like there is no progression. I wouldn't consider her a "gamer" by any means, so I'm not sure how seriously I should take that feedback.

On the other side of the spectrum, one of my close buddies had no issues picking up the game, understanding the tutorial, collecting a bunch of points, and unlocking most of the upgrades in pretty short order. The fact that he stayed engaged enough to play several rounds while continuing to unlock more upgrades was very encouraging for me.

I suppose I need to allow a broader audience to play test the game, but I think I need to clean up the sequencing of the game first. There's currently no title screen, and the shop feels a bit like an afterthought. The biggest thing that irks me, however, is that the game switches to the shop scene instantly when the player dies - I'd prefer if the player at least got to see a fiery explosion!

The Death Sequence

So I refactored the LivenessSystem. Instead of immediately going to the shop when the all of the player's shield have depleted, the system destroys the visual elements of the player's ships (marked by a new ShipPart component), removes the player's CircleCollider (to prevent further collisions), adds some intense screen shake, and emits explosion particles from the where the player's ship would have been, had it kept running its course.

I decided that the screen shake should be additive, because it feels a bit jarring when a "big" shake is canceled out for a "small" shake. I refactored the ShakeSystem accordingly.

I created a new GameOver component, and added it to the player entity when the shields are depleted. The new component contains a duration and an elapsed field, similar to a lot of the other components that exist. The LivenessSystem accumulates the elapsed time until it reaches the duration, and only then does it move onto the shop scene.

As a little added effect, I apply some deceleration to the player's Velocity component, equal to 1/8th of its current speed. This gives the appearance that the remains of the player's ship are no longer being accelerated by the engines.

Technically there would be no reason for the remains to slow down in space, since there is no force pushing against them (like friction or wind resistance). Technically, we wouldn't be emitting smoke from our engines at all in space! Then again, there are a lot of things about video games that are unrealistic.

In any case, here are the changes I've made.

Death Sequence

One last touch: I'll update the HUD text to say something like SIGNAL LOST when the player has died, and change the progress bar to red. Here is the code for that little change.

Title Screen and the App Icon

I'm torn on the idea of a title screen. I think they are more nostalgic for me than anything, but they don't really serve much purpose in a mobile game. You could make the argument that a title screen could provide a larger sense of identity for your game, which could be used as a form of marketing. In the modern video game industry, marketing a game often involves getting a popular streamer to broadcast themselves playing the game - in that case, the title screen could be of huge import in spreading your game's brand.

I'm not going to pretend that's why I'm building a title screen though. I just want to, and that's okay. First, however, the game needs a name!

I spent the last day or so coming up with ideas for the title. I like to bounce some of my ideas off my wife - she often has different ideas and opinions than me, so it's a great way to step out of my own headspace for a bit. I started the conversation with the name "AstroDozer", which she hated. We went back and forth with random space-related words, and she mentioned that she was fond of the word "warp", but we ultimately decided that maybe the shields should play a bigger role in the overall theme.

I described the game to ChatGPT and got a lot of suggestions, but it often got stuck into a corner and repeated its suggestions over and over. That's pretty typical of it - you kind of have to learn to tolerate it and formulate your prompts in such a way that it avoids doing so in its responses. I find it tedious, but apparently there are entire "prompt engineering" careers out there.

I decided to name the game Aegis. I toyed around with the idea of using the letters as an acronym - something, something, "galactic intercept system" - but decided that Aegis can just be the name of the ship instead. The ship somewhat resembles the letter A, which makes it a nice option for an icon.

I whipped out Adobe Illustrator once more - this time on my laptop instead of my iPad. I started with the "App Icon Kit 1" template, and made several drafts of a shiny new app icon. I copied the ship from my previous Illustrator file, when I was designing the ship itself, into the new document.

I ended up spending way too much time learning how to actually set the icon for my app (CFBundleIconFiles in the Info.plist, apparently). After getting it working and actually seeing the icon on my phone, I began iterating on the design.

The design started with the ship over a dark blue background. I toyed with using a gradient background, but decided that the flat color was better, albeit boring. I added some circular stars (similar to those found in the actual game), which helped, but I wasn't quite satisfied. I ended up adding a big yellow circle toward the top of the icon, which really made it pop.

After feeling happy with the general layout of the icon, I rebuilt the icon's paths from scratch. The ship that I copied was a sloppy mockup with many imperfections - the new ship icon is actually symmetrical and the sub-paths line up perfectly.

Aegis Icon

With an icon and a working title, I can focus on actually building out the title screen. I'm going to go with a very simple classical look.

It didn't take me much time to figure out a layout that I am happy with, but I definitely spent way too much time thereafter building out some systems to encapsulate some of the layout behavior. I created a new ButtonSystem, which I probably should have done back when I was building the ShopScene. It didn't save me much time this time around, but it definitely reduced the amount of copy/pasting, and makes the process of creating a button much more concise. I also created a FooterSystem, which is capable of aligning a panel to the bottom of the screen regardless of the device's aspect ratio. Finally, I created a Palette class, which just contains a bunch of constants defining colors that I have grown tired of copy/pasting everywhere. Since I was focused on creating buttons, I took the opportunity to define "primary" and "secondary" colors, as well as accented versions of those colors.

Unfortunately, I also got heavily distracted by some random things in the game. I noticed a while back that the first batch of stars that get created are not the same distance apart as the stars that get created during the game's runtime. Since I wanted to re-use the SpawnSystem to create stars for the title screen, I ended up spending a lot of time refactoring the system to make the spawning behavior more consistent, and resizing all of the relative sizes of all the spawnable objects, as well as the player's ship! In hindsight, I probably should have just adjusted the world camera's size instead.

The inconsistent star spawns were evidently caused by the Velocity component on the stars. I am very tired, so I haven't been able to reason about why the first batch of stars would behave any differently from any dynamically-spawned stars, so I decided to just remove the Velocity component from them, and adjust the velocities of everything else instead!

Title Scene

The speed of the ship on the title screen is based on the player's current "base speed" progress, which I think is really cool. The further they progress through the game, the faster the ship on the title screen will go!

If there is no save state, the "play" button is not created, and the "new game" button is at the top in yellow (the "primary" color) instead. I whipped up a DialogSystem that dynamically hides the Renderables for popup dialogs. In this case, I used it to pop up a confirmation dialog for the "new game" option. The "options" button isn't actually hooked up to anything yet, but I will eventually create a new scene for it so that players can disable screen shake, and maybe throw some audio settings in there as well.

Here is the somewhat chaotic commit that contains all of these changes. Obviously I got carried away.

Leveling System

I've been thinking a lot about the shop, how the player unlocks new stuff, and the fact that my wife just completely ignores the upgrades because something about the ShopScene just doesn't appeal to her. I mentioned that I wouldn't consider her a "gamer", but her opinions are likely still indicative of a large portion of the casual audience. She also mentioned that she didn't feel like there was any "reward" for playing; that there was nothing for her to work toward. I looked at her with a blank stare, like "are you serious?" - to me, it's obvious that you should be "working toward" unlocking all of the upgrades, but she didn't see it that way.

I've been playing the game a lot, hunting for bugs, thinking of improvements, and generally just enjoying the experience of progressing through the different upgrades. The "new game" button was actually more for me than anything - I don't expect people to get much use out of it, but it's a lot easier to press that button than to uninstall and reinstall the app every time I want to start over! I find myself following a very specific pattern of progression through the upgrades:

  • Always choose the cheapest upgrade
  • If there are multiple candidates with the same cost, then choose the one with the lowest current rank

This turns out to follow a very specific upgrade path:

  1. Shield generator (rank 2, 25 points)
  2. Generator capacity (rank 2, 50 points)
  3. Shield generator (rank 3, 50 points)
  4. Base speed (rank 2, 75 points)
  5. Ship acceleration (rank 2, 100 points)
  6. Generator capacity (rank 3, 100 points)
  7. Shield generator (rank 4, 100 points)
  8. Base speed (rank 3, 150 points)
  9. Ship acceleration (rank 3, 250 points)
  10. Generator capacity (rank 4, 250 points)
  11. Shield generator (rank 5, 250 points)
  12. Base speed (rank 4, 300 points)
  13. Ship acceleration (rank 4, 500 points)
  14. Generator capacity (rank 5, 500 points)
  15. Base speed (rank 5, 750 points)
  16. Ship acceleration (rank 5, 1000 points)

This progression path was not "designed". The relative costs of the upgrades were somewhat intentional, but I put so little thought into it that I didn't even mention it when I wrote about it.

I've been thinking about an alternative to purchasing upgrades in the form of a simple leveling system. The player would simply reach a new level each time they gathered a pre-determined number of points, and each level they reach would grant them an upgrade, possibly in the same order as the above list. A leveling system like this wouldn't be too hard to implement, but it would completely remove all elements of player agency and choice from the game. Some players (like my wife) would probably enjoy the lack of choice along with the explicit goal-oriented game play of reaching the next level. Others would hate it, but wouldn't necessarily know what they were missing unless I released the game with the current upgrade system and then subsequently took it away (unless they read this, in which case they would have knowledge of the "shop that could have been").

I spent a little bit of time thinking about what the "XP curve" might look like. I graphed out the existing curve, assuming the exact upgrade path I described above. I decided to expand the game to 20 levels (up from 16) just to make it end on a nice number. I fiddled around with making each level cost more than the previous level by fixed amounts, and decided each 5th level should cost slightly more than the rest.

XP Curve

The idea is that the game will start off progressing about as quickly as the shop-based progression, but slow down a bit around level 5. After it slows down, the rest of the levels consistently get more expensive, but avoid the massive cost jump of the final few levels that existed with the shop, but the additional levels will add to the total XP requirements to finish the game. Overall, it should take a bit longer to reach the maximum level than it would have taken to purchase everything in the shop.

From my own seasoned perspective, having played the game a lot, it's totally possible to score several hundreds of points in each run, though admittedly it gets harder as you unlock more speed upgrades. The progression path should be an ebb and flow of the game getting easier due to the increased number and strength of the shields, and the game getting harder due to the increased speed and acceleration.

I whipped up the new leveling curve pretty quickly, verifying the behavior through logs. It took me pretty much all day to whip up a new scene containing the end-of-run summary. It's mostly just a bunch of math, but it took a lot of trial and error to get things the way I like them. Let's see if I can summarize all of the changes I've made.

The new GameOverScene is pretty simple, containing only a handful of elements. A dark blue background, some text indicating the player's current level, a progress bar to indicate the player's progress toward the next level, and buttons that allow the player to "retry" or navigate to the "main menu" easily. The scene is driven by some of the standard systems, but I've also created a new LevelTrackingSystem that handles things like updating the player's current level and filling the progress bar. I spent quite a bit of time playing around with the math behind filling the progress bar in the event that the player gains more than one level in a given run. I decided that the progress bar will fill over the course of one second for each level. I also added some camera shake each time the player gains a level.

All of the XP requirements are driven by a new LevelCurve class that contains a lot of static constexpr methods, based on an array that simply contains the amount of XP that each level requires more than the previous level. The array starts with 0 (so that the player always starts at level 1 instead of 0).

I ripped out all of the upgrades from the SaveManager, and simply left behind the points to indicate the total amount of XP that the player has gained. Any system that needs to know what level the player is has to get that information using the LevelCurve and the amount of XP that the player has accumulated.

I changed up the UpgradeDatabase so that each Upgrade stores the levels in which each rank is unlocked, rather than the cost of each rank. I also changed the descriptions of each Upgrade to something more concise, like "+1 Shield" or "+1 Acceleration". There are new methods for accessing the ranks and descriptions by the player's current level.

Finally, I created a new ToastSystem, which pops up little text tidbits like a toaster pops up burnt toast. I didn't come up with the name of this concept - it has existed in the Android SDK and many web frameworks for many years. I created it so that I can display the new upgrade that the player unlocks each time they level up. I also added little toasts each time the player collides with an asteroid to show how many points they gained.

I almost forgot! I need to add some bounds checks so that I don't do any of this math if the player is already the max level. I want to add some high score tracking at max level, but I think I'll do that later.

This commit shows all of the changes, and you can check out the current state of the project here. If you feel so compelled, you can play the entire game here, but keep in mind that the browser version of the game does not support persistence, so you'll lose your state if you close your tab, or even refresh the page!

Game Over Scene