Skip to content

Prototyping Part 2

I immediately broke down and showed the game off to some friends. Some of them are healers in my World of Warcraft guild, and were immediately excited by the idea. Others are familiar with the healing mechanics, but don't often play WoW at all - their reactions were less excited, but still positive. My goal is to make the game appealing to a broader audience, not just players who are already dedicated healers in another game.

Most of the feedback just addressed tuning: how much you should be able to heal at a time, or how frequently damage should be dealt. It's funny because that's not the type of feedback I'm looking for at all! I'm still very much experimenting with mechanics, and I'm more than willing to shake up just about any part of the game as it is. My friends are not game designers, however, so I should not expect them to give any major mechanical suggestions. I don't really consider myself a game designer, but I am the one designing this game, I suppose.

Instead of relying on any external recommendations, I spent the last couple of days thinking about some things that would make the game more interesting. I think I've come up with some ideas that could make the game more interesting to healers and other gamers alike.

The first thing I considered was how "static" the game feels. In MMORPGs, the responsibility of a healer is not just to keep the group alive, but also to keep themselves alive. Most often, this means controlling your character's movement such that you don't take any unnecessary environmental damage. In my game, however, there is no "player character" to move. Adding one certainly isn't outside of the realm of possibilities, but moving that character while healing would provide for a very clunky user experience, in my opinion.

That got me thinking about possible alternatives. What if the health bars themselves were movable? The player could move them around to avoid damage whenever possible, but have the ability to heal to rectify their mistakes? I like the idea of the bars moving across the screen, but requiring the player to control them feels like it takes away from the primary "healing" mechanic.

Riffing off of that idea, I considered the possibility of the bars moving automatically, and the implications of such. In MMORPGs, the player must weigh the pros and cons of healing others or moving themselves to a safer position. The health bars of the group are statically positioned on the screen in order to reduce the cognitive load. Removing the movement aspect from the player's responsibilities allows us to play around with additional complexity that the player must take on - namely, the health bars no longer have to remain static. Instead, the player must carefully tap on moving targets. Furthermore, the player has no control over the motion of those targets - they must simply keep the targets alive through whatever damage they happen to take.

I love this idea, but I think I can take it a step further.

I envision these mechanics in the context of a bullet hell game - a genre appropriately named for its copious use of projectiles, flying in every direction, which the player must avoid while shooting their own projectiles at the enemy. The culmination of this genre, in my view, is a game called Enter the Gungeon (which is exactly what it sounds like).

I want my friendly entities to fly around the screen, like the enemy ships from Galaga swooping in. I want them to attack a central target, which shoots dozens of projectiles outward in beautiful patterns with no particular target. The player must remediate the damage done to the friendly entities by these projectiles long enough to defeat the boss.

Let's give it a shot.

9.1 Unit Movement

The idea of Galaga-like movement screams Bézier curves. I'm not going to explain every detail of them before jumping into the code, but if you're interested, you should totally check out "The Beauty of Bézier Curves" by Freya Holmér. For the purposes of this prototype, I'll just be approximating a circular movement. Bézier curves can't actually represent a perfect circle, so I'll be using an approximation based on this Stack Overflow answer.

Cubic Bézier curves can be described by 4 points. Since I plan to integrate this movement into my physics simulation, I'll just be using 2-dimensional vectors to describe those points. I'll start by creating a CubicBezierCurve struct. Since this struct isn't actually a component, I'll create a new data folder for it, even though I think it's a bad name.

linguine/src/data/CubicBezierCurve.h

#pragma once

#include <glm/vec2.hpp>

namespace linguine {

struct CubicBezierCurve {
  glm::vec2 points[4];
};

}  // namespace linguine

Multiple curves joined together is called a spline. Multiple Bézier curves joined together is naturally called a Bézier spline, but Wikipedia calls it a composite Bézier curve. Since a circle is made up of 4 cubic Bézier curves, I'll need to represent it as a spline, so I'll create a Spline struct for that purpose.

linguine/src/data/Spline.h

#pragma once

#include <vector>

#include "CubicBezierCurve.h"

namespace linguine {

struct Spline {
  std::vector<CubicBezierCurve> curves;
};

}  // namespace linguine

Technically, the points of each curve within a composite Bézier curve are required to match up. Rather than enforcing that constraint programmatically, I'm going to just trust myself to construct the curves correctly. If I don't, well then I'll see an entity suddenly jump from one place to another!

I'll create a new Path component, containing a spline, the index for which curve is currently active, and an interpolation factor t. To test things out, I'll create a new MovementPrototypeScene, and only register the minimum necessary systems to visualize the movement behavior. There's a lot of numbers required to actually construct the desired spline in this new scene.

linguine/src/scenes/MovementPrototypeScene.h snippet

auto c0 = CubicBezierCurve{
    {
        { 0.0f, 1.0f },
        { 0.552284749831f, 1.0f },
        { 1.0f, 0.552284749831f },
        { 1.0f, 0.0f }
    }
};

auto c1 = CubicBezierCurve{
    {
        { 1.0f, 0.0f },
        { 1.0f, -0.552284749831f },
        { 0.552284749831f, -1.0f },
        { 0.0f, -1.0f }
    }
};

auto c2 = CubicBezierCurve{
    {
        { 0.0f, -1.0f },
        { -0.552284749831f, -1.0f },
        { -1.0f, -0.552284749831f },
        { -1.0f, 0.0f }
    }
};

auto c3 = CubicBezierCurve{
    {
        { -1.0f, 0.0f },
        { -1.0f, 0.552284749831f },
        { -0.552284749831f, 1.0f },
        { 0.0f, 1.0f }
    }
};

auto spline = Spline{
    { c0, c1, c2, c3 }
};

auto entity = createEntity();
entity->add<Transform>();

auto path = entity->add<Path>();
path->spline = spline;

auto drawable = entity->add<Drawable>();
drawable->feature = new ColoredFeature();
drawable->feature->meshType = Quad;
drawable->renderable = renderer.create(std::unique_ptr<ColoredFeature>(drawable->feature));
drawable.setRemovalListener([drawable](const Entity e) {
  drawable->renderable->destroy();
});

The current result is a static quad in the middle of the screen, so let's write the system to make it move. I was going to name it the FriendlyMovementSystem, but there's no strict requirement that the logic only applies to friendly units. Instead, I'll call it the PathTraversalSystem.

linguine/src/systems/PathTraversalSystem.cpp

#include "PathTraversalSystem.h"

#include <glm/gtx/compatibility.hpp>

#include "components/Path.h"
#include "components/Transform.h"

namespace linguine {

void PathTraversalSystem::update(float deltaTime) {
  findEntities<Path, Transform>()->each([deltaTime](const Entity& entity) {
    auto path = entity.get<Path>();
    path->t += deltaTime;

    while (path->t >= 1.0f) {
      path->t -= 1.0f;

      if (++path->index >= path->spline.curves.size()) {
        path->index = 0;
      }
    }

    auto curve = path->spline.curves[path->index];

    auto a = glm::lerp(curve.points[0], curve.points[1], path->t);
    auto b = glm::lerp(curve.points[1], curve.points[2], path->t);
    auto c = glm::lerp(curve.points[2], curve.points[3], path->t);

    auto d = glm::lerp(a, b, path->t);
    auto e = glm::lerp(b, c, path->t);

    auto position = glm::lerp(d, e, path->t);

    auto transform = entity.get<Transform>();
    transform->position.x = position.x;
    transform->position.y = position.y;
  });
}

}  // namespace linguine

To my surprise, it worked! I thought for sure I wrote a bug somewhere in there. A couple of minor tweaks: I'll add speed and scale values to the Path component, and adjust the traversal system appropriately.

path->t += path->speed * deltaTime;
...
auto position = glm::lerp(d, e, path->t) * path->scale;

Path Traversal System

9.2 Boss Mechanics

Bézier curves are just math, so I find them rather easy to understand and implement. Coming up with interesting boss mechanics is a bit more of a challenge to me. I've never built a game so heavily based around projectiles, so I don't have any previous experience to build off of. I have a vague image in my head of the types of patterns I want to create with the projectiles, but nothing substantial enough to work with.

I decided to Google "bullet hell patterns", and this interesting tutorial was the first result, entitled "Sparen's Danmaku Design Studio - Guide A2 - Danmaku and the Player - Aesthetics and Gameplay" by Andrew Fan. It describes and demonstrates different types of projectile patterns utilized in the genre, and goes onto explain their value in terms of the game's aesthetic, not just the mechanics. This entire site has some incredibly valuable information, not just the specific page that I referenced.

I'll go ahead and create a new BulletPrototypeScene for the purposes of implementing and iterating on these patterns. My IDE just informed me that the ServiceLocator is being included multiple times - apparently I had a typo! Rather than #pragma once at the top of ServiceLocator.h, I accidentally typed #pragma one. Easy fix.

I'll create a SpawnPoint component, as well as a ProjectileSpawnSystem. The SpawnPoint contains a handful of parameters, including a count (the number of projectiles which will spawn, defaults to 1), an angle (how wide of an arc the count projectiles will spawn, defaults to 360.0f), and an interval (how frequently, in seconds, count projectiles will spawn, defaults to 1.0f). There is also an elapsed value used to keep track of how long it has been since the last spawn.

As I began to work on the ProjectileSpawnSystem, I quickly realized why the tutorial above decided to separately describe the concepts of rings, spreads, and stacks. It may seem obvious, but I only just realized that the math and looping logic behind them is rather different. Not only is my SpawnPoint component insufficient to describe any single one of these structures, but the structures can also be nested (for instance, a "ring" which emits "stacks" of bullets on an interval).

Honestly, I'm a little intimidated by the potential depth of a fully dynamic projectile spawning system. Rather than go down that rabbit hole so early, I'll focus on just implementing basic rings for now. I'll just remove the angle value from the SpawnPoint and write the ProjectileSpawnSystem to create count projectiles evenly distributed around a ring.

I found myself repeating the code to create projectiles rather often, so I made a ProjectileFactory class within a new factories/ directory to perform that job on behalf of any system that needs to create them. It might turn out to be too rigid of an implementation to support the different types of requirements that the various systems need to implement, but I can always just delete it later if I don't think it's valuable.

linguine/src/systems/ProjectileSpawnSystem.cpp

#include "ProjectileSpawnSystem.h"

#include "components/SpawnPoint.h"
#include "components/Transform.h"

namespace linguine {

void ProjectileSpawnSystem::update(float deltaTime) {
  findEntities<SpawnPoint, Transform>()->each([this, deltaTime](const Entity& entity) {
    auto spawnPoint = entity.get<SpawnPoint>();
    auto transform = entity.get<Transform>();

    spawnPoint->elapsed += deltaTime;

    while (spawnPoint->elapsed >= spawnPoint->interval) {
      spawnPoint->elapsed -= spawnPoint->interval;

      auto direction = transform->rotation * glm::vec3(0.0f, 1.0f, 0.0f);

      for (auto i = 0; i < spawnPoint->count; ++i) {
        auto angle = glm::two_pi<float>() / static_cast<float>(spawnPoint->count) * static_cast<float>(i);
        auto newDirection = glm::angleAxis(angle, glm::vec3(0.0f, 0.0f, 1.0f)) * direction;
        _projectileFactory.create(transform->position, glm::vec2(newDirection) * 1.0f, 0);
      }
    }
  });
}

}  // namespace linguine

To test the new system, I'll create a couple of entities in the BulletPrototypeScene. The first one will spawn 36 projectiles every 1/3 of a second. I will also add a Rotating component to it, with a speed of 0.07f, which should give the projectiles a swirling effect. The second entity will simply spawn 72 projectiles every 5 seconds, which will provide a dense ring that pulses outward in the midst of the swirling fan blades. Let's take a look.

Projectile Spawn System

That's pretty cool! There are a couple of issues that are glaringly obvious to me, that aren't visible from the image. The first issue is that the projectiles are never destroyed, so the longer the game runs, the more projectiles it has to simulate, which causes the frame rate to drop over time. The second issue is only obvious once the frame rate drops significantly: the spawn rate of the projectiles is no longer consistent, and the aesthetically pleasing pattern you see turns into a jumbled mess. Both of these issues are related to the physical simulation of the game.

Utilizing the Fixed Time Step

The ProjectileSpawnSystem performs its work in the update() method, rather than fixedUpdate(), so the spawn interval is dependent on the frame rate of the actual game. Not only that, but the projectiles use the position and rotation of the Transform component of the entity rather than the PhysicalState component. The PhysicalState component does not currently support rotations, so we'll have to add it and update any relevant systems if we want to keep using the rotational effect seen here (which I really do). Let's see what all I need to do here.

  • Add a previousRotation and currentRotation float to the PhysicalState component. We only need a single float to represent rotation about the Z axis for our physics system.
  • Update the PhysicsInterpolationSystem to copy the currentRotation into the previousRotation at the beginning of each fixed time step frame.
  • Update the PhysicsInterpolationSystem to linearly interpolate between the previousRotation and the currentRotation on each render frame.
  • Update the ProjectileSpawnSystem to use fixedUpdate() instead of update(), and utilize the currentPosition and currentRotation of the PhysicalState rather than the position and rotation of the Transform.
  • Update the RotatorSystem in the same way, but leave its Tapped detection logic inside of update() (inputs should be handled based on what the user can see, not the underlying physical simulation).

After all of that, I can run the game for a long period of time (until it reaches less than 1 FPS) and all of the projectiles continue to follow their intended path! Now we just need to clean up all of these projectiles once they are no longer visible, so that these frame rate issues never occur to begin with.

Cleaning Up the Garbage

Ultimately, there's no point in performing any work for entities that aren't visible and will never have any impact on the game's mechanics, so it is obviously desirable to simply clean them up. There are many ways we can detect which projectiles need to be destroyed. Some engines just destroy the entities after they have been alive for a defined duration (called a "time to live", or "TTL"). Others piggyback off of their camera's frustum culling logic to destroy entities that are no longer visible.

A TTL could work, but we would run the risk of destroying a projectile which can still affect the game - either because it was moving slowly, or it was moving in a circular path that kept it on the screen for too long.

We also don't actually have a camera frustum culling system in this engine. So far, the game doesn't actually need one, because the camera is static, and we would rather destroy entities that leave the screen than temporarily stop drawing them.

Instead, I'll just add a CircleCollider to our camera entity with a radius that is the same as the camera's height, and update the CollisionSystem to destroy any Projectile entities that are not colliding with it. The camera's height is currently hard-coded into the CameraSystem, so I'll extract that out into the CameraFixture component. Before I add the logic to destroy the projectiles, I'm going to add some temporary code to the FpsSystem to log the total number of projectiles in the game once per second.

linguine/src/systems/FpsSystem.cpp snippet

auto count = findEntities<Projectile>()->get().size();
_logger.log(std::to_string(count) + " projectiles");

It looks like my laptop manages to reach just over 11,000 projectiles before its frame rate drops below 60 FPS - which is pretty freaking good, especially considering we haven't implemented any sort of instanced rendering! Now I'll add the logic to destroy the entities that go outside of the camera's CircleCollider.

linguine/src/scenes/BulletPrototypeScene.cpp snippet

auto cameraEntity = createEntity();
cameraEntity->add<Transform>();
cameraEntity->add<PhysicalState>();

auto fixture = cameraEntity->add<CameraFixture>();

auto cameraCollider = cameraEntity->add<CircleCollider>();
cameraCollider->radius = fixture->height;

linguine/src/systems/CollisionSystem.cpp snippet

findEntities<Projectile, PhysicalState, CircleCollider>()->each([this](Entity& a) {
  findEntities<CameraFixture, PhysicalState, CircleCollider>()->each([&a](const Entity& b) {
    if (!checkCollision(a, b)) {
      a.destroy();
    }
  });
});

Now the game holds steady at about 850 FPS with just shy of 1,900 total projectiles. There's a lot I could do to optimize the frame rate here, but I'm totally happy with this for now. I'll revert the changes to the FpsSystem and move on.

9.3 Combining the Prototypes

This should just be a matter of updating the BulletPrototypeScene with all of the entities and systems from the MovementPrototypeScene and the ProgressPrototypeScene.

Here are the systems I brought over:

  • GestureRecognitionSystem
  • PlayerControllerSystem
  • PathTraversalSystem
  • FriendlyAttackSystem
  • LivenessSystem
  • HealthProgressSystem
  • CooldownProgressSystem

The PathTraversalSystem was just updating the Transform's position in the update() method, I converted it to update the PhysicalState's currentPosition in the fixedUpdate() method instead.

I had to merge together the logic for the friendly entity creation so that there would be multiple friendlies following the same spline path. For this prototype, I have 4 entities that each start at the beginning of one of the 4 curves within the spline. With the ability to construct arbitrary splines, I'll have to specifically define where on the spline I want each friendly entity to start for each scene. It can get a bit tedious, but for now, that's just what needs to happen.

The cooldown indicators for the global cooldown and the big heal are just direct copies from the ProgressPrototypeScene, but shifted down a bit so that they don't interfere with the orbiting friendly entities.

I tweaked the rotating entity in the center to be an actual enemy unit with the following components:

  • Hostile
  • Alive
  • Unit
  • Transform*
  • PhysicalState*
  • CircleCollider
  • SpawnPoint*
  • Rotating*
  • Progressable**
  • Health

The components with a single-asterisk were pre-existing. The Progressable component, notated with a double-asterisk, replaced the Drawable component from before, so that the current health of the entity can be visualized.

At first, the friendly entities were correctly causing the projectiles to be destroyed upon collision, but no damage was being dealt. I remembered that I had set the projectile's power to 0 in the ProjectileSpawnSystem, so I updated it to 20. The massive number of projectiles being spawned absolutely melted the friendly entities, so I reduced the number of projectiles in the rotating spawn point to 6, down from 36.

The only weird behavior I noticed was that the green projectiles originating from the friendly entities were "Z-fighting" with the red projectiles from the hostile entity. Z-fighting is just a term used to describe multiple drawable objects at the same screen position with the same depth value (the Z axis of the normalized device coordinate system). There's no way for the GPU to determine which object should be on top, and the result can change frame-to-frame, causing a flickering effect between the objects. To fix this, I just updated the FriendlyAttackSystem's createProjectile() method to use 1.0f as the Z value of the friendly projectiles, whereas the ProjectileSpawnSystem uses 2.0f for the hostile projectiles. This will result in the friendly projectiles always appearing on top, since they are closer to the camera, which is located at a Z value of 0.0f.

Bullet Prototype

Evaluating the Result

It works, and it's pretty, but somewhat unexpectedly, it's not particularly fun. I had hyped up this idea quite a bit in my head, so it's pretty disappointing that it's not an immediate hit - pretty demoralizing, actually. I built the exact systems I was imagining, so what is wrong with it?

First of all, it makes me dizzy. I showed it to a couple of friends, and that was the reaction of one of my healer friends as well. In other games in the genre, the player focuses their eyes on the player's character, and has direct control over its movement. This game has 4 entities that the player is supposed to "focus" on, but they are constantly moving, making them hard to track. The rotational movement of both the friendly entities and the projectiles makes for a rather nauseating experience (and I don't often get nauseous from video games).

One of the things that makes the health bars work in the context of an MMORPG is the proximity of all of the health bars to one another. MMORPGs often allow for heavy customization of the user interface, but it's nearly universal for players to position all of the health bars for the player's group into one location on the screen, making it easy to gauge the overall health of the group at a glance. It makes sense that splitting those bars apart spatially and moving them around the screen would induce some frustration. I guess I was hoping that the predictability of the movement would provide for enough consistency that it wouldn't be a big deal - boy, was I wrong.

If you actually play the prototype, then you might notice that your eyes track a single entity at a time, and you have to force them to switch to a different entity, which is usually the "previous" entity in the spline path that has moved into the other entity's location. In the WEBP video above (which I realize won't be animated in a book), you might notice that my cursor tends to move toward the top-right-most entity. When I feel overwhelmed, then I move down to the big "heal everyone" button, and immediately go back to my previous comfortable position at the top-right. This fixation is bad for the player's situational awareness, and that's the fault of the game's design, not the player.

At the beginning of the chapter, I hypothesized that the lack of player movement mechanics would afford us the cognitive bandwidth to move the health bars around the screen instead. I did not anticipate the issues that we actually encountered. While it is still a failure on my part, that is precisely the point of prototyping. I didn't spend weeks or months building this game based on some grand vision - I just did the absolute minimum amount of work necessary to test out an idea. The cool idea that was in my head turned out to have issues that I did not consider, and that's perfectly fine.

I'm going to scrap this prototype, but the components and systems that we built to support it might still come in handy later! I encourage you to play the prototype by checking out this commit. There might be a path forward with that prototype, like making the movement speed of the entities slower, or just keeping them closer together. I can always revisit the idea later, but for now I'm going to keep iterating on ideas.