Skip to content

Play-Testing and Iteration

The momentum I had from porting the game to the browser has come to a screeching halt.

I did, however, manage to let a few friends finally play the game. None of them were overly excited about it or anything, but they all exhibited one behavior that stuck out to me: they played all the way to the end of the level, without me having to force them to. That informs me that the core game play loop is solid enough to hold someone's attention.

No one seemed to question the fact that there are more health bars than there are "players". They all recognized the goal of keeping the health bars from depleting while controlling the movements of the single player character. Many other games that attempt to utilize the healer point-of-view attempt to maintain the idea of multiple party members that you don't have direct control over. When those party members are not controlled by an actual person, the experience feels clunky and awkward. Even within World of Warcraft, some of the most notoriously annoying quests involve protecting a computer-controlled character from harm.

Here are some other notes I took:

  • Reaching the end of the level is anticlimactic. One friend stated that he thought there should be a boss at the end.
  • My 8-year-old nephew was the only one who asked what happened when all the health bars were depleted. I told him that nothing actually happens right now (because I never implemented any behavior for it), but I would consider it a "game over" if he let it happen. Everyone else seemed to assume that to be the case.
  • Everyone noticed some inconsistent behavior around handling click events. Sometimes it just doesn't work, but it didn't seem to be too problematic.
  • My wife says it looks too easy when I play it. I would certainly hope so, considering I'm an expert on every aspect of the game!
  • I don't like the click-to-move input system when playing the game on a computer, but I don't perceive that discontentment from anyone else.

Fixing the Inputs

The cause of the inconsistent clicking behavior is actually pretty obvious to me: the WebGL commands for the previous frame haven't completed yet, so the expected contents of the SelectionFeatureRenderer's framebuffer might not have finished drawing yet. I can resolve that with a simple glFinish() call at the beginning of the getEntityIdAt() method, which ensures that all previously issued WebGL commands have fully executed. Doing this in the getEntityIdAt() method (as opposed to the draw() method, or the higher-level renderer's draw() method) is a way to defer this requirement until the last possible moment. Relying on glFinish() to block until rendering has completed is generally bad for rendering performance, but it's unfortunately a necessity with an input system that is coupled to the renderer (though it would be possible to achieve a bit more decoupling, with some additional effort).

Moving Forward

When things get tough - be it at work, on a project, or any aspect of life - I like to remind myself that things are alright as long as I'm making forward progress. Having people actually play the game was a huge milestone, but the actual development of the game hasn't moved an inch in months. The remedy to that seems obvious - just work on the game, idiot!

Unfortunately it's not that simple. I don't really have a knack for game design, and my skills with digital art are limited. I tell myself that I'd be willing to pay an artist to slap a coat of paint onto the game once it's "ready", but what does that even mean? At what point is a game "ready enough" to start focusing on the theme and overall visual style? How early do I start that process? It's obviously a bad idea to commit financial resources toward a game that inevitably fails, but it's also a bad idea to wait until the last minute, potentially delaying the release of the final product. Asset distribution platforms have become increasingly popular for this very reason, but many of the higher quality assets have already been used in many games, which makes it difficult for a single game utilizing them to stand out.

Theoretically, it doesn't matter how your game looks, as long as it's fun. Unfortunately it's increasingly difficult for new games to get noticed in such a highly saturated market, and visual appeal goes a long way when trying to stand out from the crowd.

I consider myself a pretty creative person (at least I did at one point in my life), but I feel stuck. I obviously need to get over this hump in order to continue to make forward progress. I think the biggest problem is that I have no long-term vision for what the game is supposed to be. Without a vision, I have no obvious short-term steps to work on. So let's figure out where we want to go, and that will inform what our next move should be.

Genre

We experimented with a few different genres throughout the prototyping phase, and ultimately landed on the idea of building a roguelike (or roguelite, perhaps). However, way back in the introduction, I made the following assertion:

We need to avoid genres that require a lot of content. That rules out RPGs, platformers, shooters, roguelikes, and lots more.

Building a randomized level for the sake of prototyping the healing mechanic doesn't necessarily make our game a roguelike though. Furthermore, it's certainly not too late to pivot away from level randomization altogether. If we instead decided to design our levels by hand, then what genre would the game be? Probably something with the word "action" in it, since the movement, combat, and healing are all happening in real-time.

Labeling the game as a specific genre isn't really all that important. What is most important is keeping the scope of the game small so that releasing it is achievable.

Focusing on a narrow scope can be beneficial, but keeping the scope small is a skill in and of itself. It's natural to want to build a giant game with a vast world, deep story, and endless side quests. Unfortunately, it's completely unrealistic to expect a single developer with a full-time job and a family to accomplish such a feat within a reasonable about of time. Learning how to identify scope creep comes from experience.

Progression

Still, a game should contain some form of progression, or the player will lose interest very quickly. Roguelikes are appealing because each playthrough is different from the last, even though the player's progression occurs relatively quickly. RPGs, on the other hand, utilize very long progression paths, but are less likely to be replayed from the beginning.

Even casual games like Plants vs. Zombies and Angry Birds have their own form of progression. Each level presents progressively more difficult challenges that the player must overcome, but they also unlock more useful tools along the way. I think small levels with specific challenges - rather than long corridors willed with small enemies - might be a more achievable structure for our game, while still affording us the ability to expand upon it later.

Encounter Design

So what might these small levels entail? Boss fights, of course!

Some of my favorite games involve a lot of boss fights that are separated by story, puzzles, collectables, and other content. Ultimately though, completing a boss encounter represents the player's mastery of the entire level.

The Legend of Zelda series often designs its levels around the use of a key item (which itself is usually discovered within that level), culminating in a boss fight that requires the use of that key item to overcome. Mark Brown, known for his Game Maker's Toolkit YouTube channel, has a wonderful video series entitled Boss Keys in which he breaks down the dungeon design of all of the Zelda games. The trick is that the player must be taught how to use their newly acquired tools effectively before being thrust into a seemingly impossible situation.

What does that mean for us? Well, not every level can be a "boss" - at least not in the traditional sense. Because our game draws so much inspiration from World of Warcraft, it becomes difficult for me to separate the terminology. In WoW, a "boss" is distinct from any other enemy unit in that it contains scripted behavior rather than blindly attacking the player. Bosses might trigger events based on a timer or their current health level, and those events are generally much more difficult for the player to deal with than the attacks of a typical enemy.

I don't want my non-boss levels to contain a single unscripted enemy. I could demonstrate how boring that would be by changing our scene to contain a single room with a single enemy inside of it. There would be no threat, and therefore no engagement from the player. Instead, I should organize my levels into sequences that contain increasingly difficult challenges of a common theme - likely a specific ability.

Controls

We made the decision to target iOS very early - probably prematurely. We didn't even know what kind of game we were making yet. Because we were constraining ourselves to a mobile platform, we forced a touch-based input scheme onto the game. Constraints can breed creativity, but I'm not entirely convinced that it was a good idea to be so decisive from the start.

Since porting the game to web browsers, the click-to-move control scheme has felt unnecessarily clunky, and somewhat unintuitive. Most gamers would expect the WASD keys to control the movement, and even casual players might expect the arrow keys to accomplish something.

I'm not saying I won't be releasing an iOS app at all, just that our current focus should be the browser user experience, since that's the platform that people will be playing on while we iterate.

Let's take all of these points and build another prototype that addresses them.

14.1 Movement, Again

Supporting keyboard inputs will help the game feel more natural from both Pesto and Alfredo, but Scampi will be left behind for now. While it is possible to attach a keyboard to an iPad to play the game, it's not something that I'm interested in supporting right now.

I'll start off by creating a new BossFightPrototypeScene with a handful of standard systems and a dark blue background. I'll also modify Alfredo's window to use the same 1440x768 dimensions as Pesto.

I'll create a new DirectionalMovementSystem for this scene, rather than using the old PointAndClickMovementSystem. It's nice to keep both around in case I want to switch things around later. The new system will depend on the InputManager, but before I can implement any actual behavior, I'll have to modify the InputManager interface to expose the state of specific keys.

Within the InputManager, I've added a new Key enum. For now, the only values are W, A, S, and D - these will be the movement keys I care about, and I can always add more values later. Doing it this way is actually setting myself up for failure when/if I need to support different keyboard layouts in the future. We're still in rapid prototyping mode though, so I'm just going to roll with it. I'll also add a new virtual method named isKeyPressed(), which each application will have to implement.

Platform Support

Starting with Alfredo, we'll need to modify our event processing loop within pollEvents() to store the state of the keys that we care about. I'm going to use a std::bitset for this purpose, since it provides a clean way to store a lot of boolean values in an efficient manner. Since the size of a std::bitset has to be defined at compile-time as a template parameter, I'll add a MAX value at the end of the Key enum, which we can use to define the bitset's size, without having to change it each time we add more values to the Key enum.

The logic to store values in the bitset is very simple, though it took me a little Googling to figure out what header the enum values for the keyCode resided in (<Carbon/Carbon.h>, apparently).

alfredo/src/platform/MacInputManager.mm pollEvents() snippet

case NSEventTypeKeyDown: {
  switch (event.keyCode) {
  case kVK_ANSI_W:
    _keyStates[Key::W] = true;
    break;
  case kVK_ANSI_A:
    _keyStates[Key::A] = true;
    break;
  case kVK_ANSI_S:
    _keyStates[Key::S] = true;
    break;
  case kVK_ANSI_D:
    _keyStates[Key::D] = true;
    break;
  default:
    break;
  }
  break;
}
case NSEventTypeKeyUp: {
  switch (event.keyCode) {
  case kVK_ANSI_W:
    _keyStates[Key::W] = false;
    break;
  case kVK_ANSI_A:
    _keyStates[Key::A] = false;
    break;
  case kVK_ANSI_S:
    _keyStates[Key::S] = false;
    break;
  case kVK_ANSI_D:
    _keyStates[Key::D] = false;
    break;
  default:
    break;
  }
  break;
}

alfredo/src/platform/MacInputManager.mm snippet

bool MacInputManager::isKeyPressed(Key key) const {
  return _keyStates[key];
}

Within the DirectionalMovementSystem, I whipped up a quick test inside the update() method that prints a log if the W key is pressed. While it does work, the operating system keeps playing a "bloop" noise, as if the application isn't handling the key event properly. I think the culprit is that I always forward the event to the NSApplication at the end of the event loop (via [app sendEvent:event]), so from the application's perspective, it's receiving an unhandled event. I'll change the loop so that I only forward the event in the default case. After that change, the "bloop" noise only happens if I press a key that our application specifically doesn't handle, which could be annoying, but I think it's fine for now.

Moving onto Scampi, I'm just going to implement the isKeyPressed() method to always return false. I can revisit in the future if I ever want to support keyboards on iOS.

Now for Pesto. Emscripten has emscripten_set_keydown_callback() and emscripten_set_keyup_callback() methods for this very purpose, so let's utilize those. I followed the same pattern we used with the mouse events, and utilized the same bitset storage that we used in Alfredo's InputManager implementation. The tricky part was deciding which of the many provided key identifier options I wanted to use. According to Emscripten's documentation, the keyCode, which, and charCode values are deprecated, while charValue is no longer available in modern browsers. That basically just leaves us with key, which is a string representing the key that was pressed. The case of this string actually changes based on the current state of the shift and caps lock keys, so there's a bit of logic involved in parsing the value. Furthermore, non-alphanumeric keys (such as the arrow keys) are represented as specific strings that don't need to be parsed at all - but string equality checks are still rather expensive compared to simple numeric key codes.

Once I thought I had everything hooked up correctly, none of my key presses seemed to be recognized. I added a bit of debug logging to no avail, and started searching Emscripten's GitHub page for issues that others may have been experiencing. I came across this issue comment that suggests targeting the document or window for key events rather than the canvas itself. I tried setting the target of the callback functions to the strings "document" and "window" with no more success than before.

I stumbled across someone else's project in Emscripten's Discord channel, in which they use the constant EMSCRIPTEN_EVENT_TARGET_WINDOW for this purpose. I changed my string to that constant (which appears to evaluate to a magic constant), and suddenly everything worked as expected! If I had actually read the documentation for these registration functions, I would have known better.

pesto/src/platform/WebInputManager.cpp constructor snippet

emscripten_set_keydown_callback(EMSCRIPTEN_EVENT_TARGET_DOCUMENT, this, false, [](int eventType, const EmscriptenKeyboardEvent* keyEvent, void* userData) -> EM_BOOL {
  auto key = std::string(keyEvent->key);
  static_cast<WebInputManager*>(userData)->onKeyDown(key);
  return true;
});

emscripten_set_keyup_callback(EMSCRIPTEN_EVENT_TARGET_DOCUMENT, this, false, [](int eventType, const EmscriptenKeyboardEvent* keyEvent, void* userData) -> EM_BOOL {
  auto key = std::string(keyEvent->key);
  static_cast<WebInputManager*>(userData)->onKeyUp(key);
  return true;
});

presto/src/platform/WebInputManager.cpp snippet

void WebInputManager::onKeyDown(const std::string& key) {
  if (key == "w" || key == "W") {
    _keyStates[W] = true;
  } else if (key == "a" || key == "A") {
    _keyStates[A] = true;
  } else if (key == "s" || key == "S") {
    _keyStates[S] = true;
  } else if (key == "d" || key == "D") {
    _keyStates[D] = true;
  }
}

void WebInputManager::onKeyUp(const std::string& key) {
  if (key == "w" || key == "W") {
    _keyStates[W] = false;
  } else if (key == "a" || key == "A") {
    _keyStates[A] = false;
  } else if (key == "s" || key == "S") {
    _keyStates[S] = false;
  } else if (key == "d" || key == "D") {
    _keyStates[D] = false;
  }
}

Actual Game Logic

Utilizing the newly implemented method on the InputManager is very simple. We'll create a direction vector, increment its components based on the state of the keys, and move our player's PhysicalState appropriately. I'll use glm::length2() to avoid normalizing the direction vector unless keys are actually being pressed, and I'll also update the player's rotation in the same way the GridPositionSystem did. The rotational updates are actually just for a visual indicator, and won't be necessary whenever we finally add our own graphics to the game.

linguine/src/systems/DirectionalMovementSystem.cpp

#include "DirectionalMovementSystem.h"

#include <glm/gtx/norm.hpp>

#include "components/PhysicalState.h"
#include "components/Player.h"

namespace linguine {

void DirectionalMovementSystem::fixedUpdate(float fixedDeltaTime) {
  auto direction = glm::vec2(0.0f);

  if (_inputManager.isKeyPressed(InputManager::W)) {
    direction += glm::vec2(0.0f, 1.0f);
  }

  if (_inputManager.isKeyPressed(InputManager::A)) {
    direction += glm::vec2(-1.0f, 0.0f);
  }

  if (_inputManager.isKeyPressed(InputManager::S)) {
    direction += glm::vec2(0.0f, -1.0f);
  }

  if (_inputManager.isKeyPressed(InputManager::D)) {
    direction += glm::vec2(1.0f, 0.0f);
  }

  if (glm::length2(direction) > 0.0f) {
    direction = glm::normalize(direction);

    findEntities<Player, PhysicalState>()->each([&direction, fixedDeltaTime](Entity &entity) {
      auto physicalState = entity.get<PhysicalState>();
      physicalState->currentPosition += direction * 5.0f * fixedDeltaTime;
      physicalState->currentRotation = glm::atan(direction.y, direction.x) - glm::half_pi<float>();
    });
  }
}

}  // namespace linguine

Note that we would never want to check for "trigger" inputs in the fixedUpdate() method (such as "down" or "up" key events), since they would only return true for a single update() frame, which could lead to both dropped events (in the event a fixedUpdate() was not triggered during the same loop iteration as the pollEvents()), as well as duplicate events (in the event that multiple fixedUpdate()s were invoked during the same loop iteration as the pollEvents()). Generally speaking, it's not good practice to process inputs within fixedUpdate() at all, but we can get away with it for "continuous" inputs.

I hard-coded a speed modifier of 5.0f units per second because it felt too slow without any modifier. I could make this an actual value of the Player component, but I'm just going to leave it as-is for the time-being.

This implementation is incredibly more simplistic than our renderer-based point-to-click A* pathfinding solution. There's not much to show, but you can try out the new movement here.

14.2 Starting Small

Rather than jumping into designing elaborate boss encounters with all sorts of puzzling challenges for the player to overcome, I think I'll start from square one. What should the very first level be?

I mentioned that the Zelda series tends to design its levels around the use of a specific key item, but the earliest levels in each game are often more simple than that: they teach the player the basics of the game itself - how to move, interact, and fight.

In our case, the first level should give the player space to experiment with the movement controls, while requiring a light amount of healing, to emphasize the game's primary mechanic.

How should the player actually defeat the enemy? I'm not a fan of the player character automatically shooting projectiles on a fixed timer. That effectively just turns the game into "survive for x amount of time". We could, however, utilize the game's core movement mechanic. For example, we could place bombs on the ground, which hurt the player if they pass through them - but they could also deal damage to the boss if the player can manage to kite the boss through them.

Rebuilding the Scene

Before we can start iterating on this type of gameplay, we first need to rebuild a lot of the entities that we had built up in the ProceduralPrototypeScene, but without the movement constraints of the Grid. I don't think it's valuable to go into too much detail, but I did have to make some heavy modifications to the EnemyTargetingSystem and the EnemyAttackSystem.

The targeting behavior previously used the Grid to determine distance and pathing to a target based on the configured targeting mode. I ripped the Grid out of the system completely, and instead I'm judging distance based on the PhysicalState components. I also removed support for the Adjacent targeting mode.

The attacking behavior for the ProjectileAttack remains the same, but I moved the MeleeAttack handling into the fixedUpdate() method, and only triggered it when a collision is detected. I'm ignoring the attack frequency of the MeleeAttack completely, so if you allow the boss to touch you, it's basically a "game over". In order to allow the collisions to be detected properly, I had to update the CollisionSystem to check for collisions between Friendly and Hostile Units (previously, it only checked Units against Projectiles).

While composing the enemy entity, I gave it a long range of 50.0f so that the player would be very unlikely to be out of range. Eventually I'll need to build levels in which the range is a non-factor, but this will do just fine for now.

One thing to note is that the player has no way of attacking the enemy, and therefore cannot win! If you feel like trying it out anyway, it's available here.

Collision Resolution

This probably isn't what I should be focusing on right now, but I can't stop thinking about it, so I'm just going to knock it out.

I've mentioned in previous chapters that, while we do have collision detection working for our circular colliders, we don't actually have any way to resolve collisions. Resolving a collision is the process of preventing two objects from overlapping by detecting the overlap, and nudging the objects away from each other.

In our point-and-click prototype, we didn't actually need any separate physics-oriented code to prevent overlapping with the walls because the Grid (and its internal A* pathfinding around "obstacles") prevented the player from ever reaching an unintended position.

Now that our player character doesn't use the Grid or its pathfinding logic, we have to actively prevent the player from passing through obstacles. Furthermore, our engine currently only supports collisions between circles, but I'd like to add support for box collisions, as well as collisions between circles and boxes. I guess that's as good a place as any to start. I'll add a new BoxCollider component with a 2-dimensional vector representing its size, and whip up some support in the CollisionSystem.

linguine/src/systems/CollisionSystem.cpp snippet

bool CollisionSystem::checkCollision(const Entity& a, const Entity& b) {
  if (a.getId() == b.getId()) {
    return false;
  }

  if (a.has<BoxCollider>() && b.has<BoxCollider>()
      && checkBoxBoxCollision(a, b)) {
    return true;
  }

  if (a.has<BoxCollider>() && b.has<CircleCollider>()
      && checkBoxCircleCollision(a, b)) {
    return true;
  }

  if (a.has<CircleCollider>() && b.has<BoxCollider>()
      && checkBoxCircleCollision(b, a)) {
    return true;
  }

  if (a.has<CircleCollider>() && b.has<CircleCollider>()
      && checkCircleCircleCollision(a, b)) {
    return true;
  }

  return false;
}

bool CollisionSystem::checkBoxBoxCollision(const Entity& a, const Entity& b) {
  auto positionA = a.get<PhysicalState>()->currentPosition;
  auto positionB = b.get<PhysicalState>()->currentPosition;

  auto colliderA = a.get<BoxCollider>();
  auto colliderB = b.get<BoxCollider>();

  return positionA.x + colliderA->size.x / 2.0f >= positionB.x - colliderB->size.x / 2.0f
         && positionA.x - colliderA->size.x / 2.0f <= positionB.x + colliderB->size.x / 2.0f
         && positionA.y + colliderA->size.y / 2.0f >= positionB.y - colliderB->size.y / 2.0f
         && positionA.y - colliderA->size.y / 2.0f <= positionB.y + colliderB->size.y / 2.0f;
}

bool CollisionSystem::checkBoxCircleCollision(const Entity& a, const Entity& b) {
  auto positionA = a.get<PhysicalState>()->currentPosition;
  auto positionB = b.get<PhysicalState>()->currentPosition;

  auto colliderA = a.get<BoxCollider>();
  auto colliderB = b.get<CircleCollider>();

  return positionA.x + colliderA->size.x / 2.0f >= positionB.x - colliderB->radius
         && positionA.x - colliderA->size.x / 2.0f <= positionB.x + colliderB->radius
         && positionA.y + colliderA->size.y / 2.0f >= positionB.y - colliderB->radius
         && positionA.y - colliderA->size.y / 2.0f <= positionB.y + colliderB->radius;
}

bool CollisionSystem::checkCircleCircleCollision(const Entity& a, const Entity& b) {
  auto positionA = a.get<PhysicalState>()->currentPosition;
  auto positionB = b.get<PhysicalState>()->currentPosition;

  auto colliderA = a.get<CircleCollider>();
  auto colliderB = b.get<CircleCollider>();

  auto distance = glm::distance(positionA, positionB);
  return distance <= colliderA->radius + colliderB->radius;
}

I won't be explaining the math behind the collision checks (use your head!), but I will point out that the box collision detection only works for axis-aligned boxes. I'm currently leaning toward a tile-based level design, so that won't be an issue. If I end up needing support for rotating boxes, then I can either keep it simple by using circle colliders, or add the more complicated math later.

Actually resolving these collisions is a bit more complicated. Our physics engine isn't really a physics engine at all - our objects aren't moved by forces or impulses, and we only use velocity as a suggestion rather than a fundamental characteristic of a body. Because we aren't building a fully-featured physics engine, we have to decide what concessions we're willing to make - what physical interactions are we willing to ignore?

For example, in real life, a wall is not impenetrable. A car, described by its mass and its velocity, can collide with that wall with such a force that the wall dislodges from the ground and flies until the force of gravity once again brings it to the ground. In a game, however, walls are often (though not always) static. No amount of force should be capable of moving them. In this case, is it even necessary to describe our physical objects in terms of the forces that act upon them?

Our existing CollisionSystem only detects collisions for very specific combinations of entities. In this way, it is already completely unrealistic. We should embrace the fact that this is a game and only implement the bare minimum to achieve the desired result. Ultimately, I just don't want the player to walk through walls.

The problem with simple solutions is that they do not scale. What happens if our player collides with two walls at the same time? Will the physics solver push our player out of one wall, just to be ping-ponged back and forth in an infinite loop? There are many robust options available, such as Box2D, but I enjoy the minimalism of building everything myself, for some reason.

I think I'm rambling too much, so I'm just going to start coding and see what I can come up with.


After a bit of iteration, I decided to "simplify" the CollisionSystem by removing the component-specific iterators, and simply have all PhysicalState entities interact with all other PhysicalState entities. This obviously has the downside of increasing the number of calculations our engine makes as the number of physically simulated entities grows, but we can optimize for that later if it becomes a problem.

I added a couple of new components to augment the physical simulation of specific entities. Trigger allows an entity to receive a Hit component when another entity overlaps with it, but skips the resolution step, so that the location of both entities remains untouched. Static allows an entity to collide with another entity, but will not be moved - instead, the full resolution will be attributed to the other entity. If the two colliding entities have neither the Trigger nor the Static components, then the solver will allow the entities to move each other around the world according to their velocities - and it's weirdly fun to push blocks around!

Implementing the component-specific flow was just a matter of nested conditionals, but actually resolving the collisions required a bit of math for each collider combination (circle/circle, box/circle, box/box). I'll be the first to admit that the code is likely not robust enough to handle sufficiently complex simulations, but I'm still happy with the result!

linguine/src/systems/CollisionSystem.cpp

void CollisionSystem::resolveCollision(const Entity& a, const Entity& b) {
  if (a.has<BoxCollider>() && b.has<BoxCollider>()) {
    resolveBoxBoxCollision(a, b);
  } else if (a.has<BoxCollider>() && b.has<CircleCollider>()) {
    resolveBoxCircleCollision(a, b);
  } else if (a.has<CircleCollider>() && b.has<BoxCollider>()) {
    resolveBoxCircleCollision(b, a);
  } else if (a.has<CircleCollider>() && b.has<CircleCollider>()) {
    resolveCircleCircleCollision(a, b);
  }
}

void CollisionSystem::resolveBoxBoxCollision(const Entity& a, const Entity& b) {
  auto stateA = a.get<PhysicalState>();
  auto stateB = b.get<PhysicalState>();

  auto colliderA = a.get<BoxCollider>();
  auto colliderB = b.get<BoxCollider>();

  auto offset = stateA->currentPosition - stateB->currentPosition;
  auto penetration = colliderA->size / 2.0f + colliderB->size / 2.0f - glm::abs(offset);

  if (glm::abs(penetration.x) < glm::abs(penetration.y)) {
    auto resolutionX = glm::sign(offset.x) * penetration.x;

    if (!a.has<Static>() && !b.has<Static>()) {
      stateA->currentPosition.x += resolutionX / 2.0f;
      stateB->currentPosition.x -= resolutionX / 2.0f;
    } else if (a.has<Static>() && !b.has<Static>()) {
      stateB->currentPosition.x -= resolutionX;
    } else {
      stateA->currentPosition.x += resolutionX;
    }
  } else {
    auto resolutionY = glm::sign(offset.y) * penetration.y;

    if (!a.has<Static>() && !b.has<Static>()) {
      stateA->currentPosition.y += resolutionY / 2.0f;
      stateB->currentPosition.y -= resolutionY / 2.0f;
    } else if (a.has<Static>() && !b.has<Static>()) {
      stateB->currentPosition.y -= resolutionY;
    } else {
      stateA->currentPosition.y += resolutionY;
    }
  }
}

void CollisionSystem::resolveBoxCircleCollision(const Entity& a, const Entity& b) {
  auto stateA = a.get<PhysicalState>();
  auto stateB = b.get<PhysicalState>();

  auto colliderA = a.get<BoxCollider>();
  auto colliderB = b.get<CircleCollider>();

  auto offset = stateA->currentPosition - stateB->currentPosition;
  auto penetration = colliderA->size / 2.0f + colliderB->radius - glm::abs(offset);

  if (glm::abs(penetration.x) < glm::abs(penetration.y)) {
    auto resolutionX = glm::sign(offset.x) * penetration.x;

    if (!a.has<Static>() && !b.has<Static>()) {
      stateA->currentPosition.x += resolutionX / 2.0f;
      stateB->currentPosition.x -= resolutionX / 2.0f;
    } else if (a.has<Static>() && !b.has<Static>()) {
      stateB->currentPosition.x -= resolutionX;
    } else {
      stateA->currentPosition.x += resolutionX;
    }
  } else {
    auto resolutionY = glm::sign(offset.y) * penetration.y;

    if (!a.has<Static>() && !b.has<Static>()) {
      stateA->currentPosition.y += resolutionY / 2.0f;
      stateB->currentPosition.y -= resolutionY / 2.0f;
    } else if (a.has<Static>() && !b.has<Static>()) {
      stateB->currentPosition.y -= resolutionY;
    } else {
      stateA->currentPosition.y += resolutionY;
    }
  }
}

void CollisionSystem::resolveCircleCircleCollision(const Entity& a, const Entity& b) {
  auto stateA = a.get<PhysicalState>();
  auto stateB = b.get<PhysicalState>();

  auto colliderA = a.get<CircleCollider>();
  auto colliderB = b.get<CircleCollider>();

  auto offset = stateA->currentPosition - stateB->currentPosition;
  auto direction = glm::normalize(offset);
  auto penetration = colliderA->radius + colliderB->radius - glm::abs(offset);

  auto resolution = direction * penetration;

  if (!a.has<Static>() && !b.has<Static>()) {
    stateA->currentPosition += resolution / 2.0f;
    stateB->currentPosition -= resolution / 2.0f;
  } else if (a.has<Static>() && !b.has<Static>()) {
    stateB->currentPosition -= resolution;
  } else {
    stateA->currentPosition += resolution;
  }
}

void CollisionSystem::detectHit(Entity& a, Entity& b) {
  if (checkCollision(a, b)) {
    if (a.has<Trigger>()) {
      if (a.has<Hit>()) {
        a.get<Hit>()->entityIds.push_back(b.getId());
      } else {
        a.add<Hit>()->entityIds = { b.getId() };
      }
    }

    if (b.has<Trigger>()) {
      if (b.has<Hit>()) {
        b.get<Hit>()->entityIds.push_back(a.getId());
      } else {
        b.add<Hit>()->entityIds = { a.getId() };
      }
    }

    if (!a.has<Trigger>() && !b.has<Trigger>()) {
      resolveCollision(a, b);
    }
  }
}

linguine/src/scenes/BossFightPrototypeScene.h snippet

auto playerEntity = createEntity();
auto circleCollider = playerEntity->add<CircleCollider>();
circleCollider->radius = 0.25;

...

auto enemyEntity = createEntity();
auto circleCollider = enemyEntity->add<CircleCollider>();
circleCollider->radius = 0.5f;

...

auto movableEntity = createEntity();
auto boxCollider = movableEntity->add<BoxCollider>();
boxCollider->size = { 1.0f, 5.0f };

...

auto immovableEntity = createEntity();
auto boxCollider = immovableEntity->add<BoxCollider>();
boxCollider->size = { 4.0f, 2.0f };
immovableEntity->add<Static>();

Collision Resolution

Truth be told, I have no idea where I learned how to do this sort of simple collision resolution. I know I spent quite some time getting frustrated with Unity's physics components, which sparked my interest in the Kinematic Character Controller by Philippe St-Amand. Reading about his character controller helped me understand the different phases of a game's physics engine, but I think I just figured out the resolution formulas on my own, based on the formulas used to check if two objects are colliding. In any case, you can try it out here - see if you can manage to break it.

Building a Level

Level design is a really deep topic on its own, but I'm not going to go too far down the rabbit hole. I'd really just like to be contained to a reasonable space, rather than being allowed to travel off into infinity.

I whipped out my iPad, which has a nifty pixel art app installed named Pixaki. I created a 32x32 canvas and drew a little mockup of a level - it's basically an octagon with a few interior walls that will serve as obstacles. I don't have any deep level design theory or reasoning for this layout, but it's better than leaving the player completely unconstrained.

The layout is symmetrical both vertically and horizontally, so to simplify the code to construct the level, I'm just going to define a 2-dimensional array of booleans representing the walls and obstacles of one corner, and draw it 4 times with different positional modifiers.

linguine/src/scenes/BossFightPrototypeScene.h snippet

const auto Y = true;
const auto N = false;

std::array<std::array<bool, 12>, 12> tiles = {
    std::array<bool, 12> { N, N, N, N, N, N, N, N, N, N, N, Y },
    std::array<bool, 12> { N, N, Y, N, N, N, N, N, N, N, N, Y },
    std::array<bool, 12> { N, N, Y, N, N, N, Y, N, N, N, N, Y },
    std::array<bool, 12> { N, N, N, N, N, N, Y, N, N, N, N, Y },
    std::array<bool, 12> { N, N, N, N, N, N, N, N, N, N, Y, N },
    std::array<bool, 12> { N, N, N, N, N, N, N, N, N, Y, N, N },
    std::array<bool, 12> { N, N, N, N, N, N, N, N, N, Y, N, N },
    std::array<bool, 12> { N, N, N, N, N, N, N, N, Y, N, N, N },
    std::array<bool, 12> { N, N, N, N, N, N, N, Y, N, N, N, N },
    std::array<bool, 12> { N, N, N, N, N, N, Y, N, N, N, N, N },
    std::array<bool, 12> { Y, Y, Y, Y, Y, Y, N, N, N, N, N, N },
    std::array<bool, 12> { N, N, N, N, N, N, N, N, N, N, N, N }
};

std::array<glm::vec2, 4> mods = {
    glm::vec2 { -1.0f,  1.0f },
    glm::vec2 {  1.0f,  1.0f },
    glm::vec2 { -1.0f, -1.0f },
    glm::vec2 {  1.0f, -1.0f }
};

for (auto i = 0; i < tiles.size(); ++i) {
  for (auto j = 0; j < tiles[i].size(); ++j) {
    if (tiles[i][j]) {
      for (auto& mod : mods) {
        auto immovableEntity = createEntity();

        auto transform = immovableEntity->add<Transform>();
        transform->position = {
            mod.x * (static_cast<float>(j) + 0.5f),
            mod.y * (static_cast<float>(i) + 0.5f),
            5.0f
        };

        auto physicalState = immovableEntity->add<PhysicalState>();
        physicalState->previousPosition = glm::vec2(transform->position);
        physicalState->currentPosition = physicalState->previousPosition;

        immovableEntity->add<BoxCollider>();
        immovableEntity->add<Static>();

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

Eventually I'll want to design actual levels with graphics. When I get to that point, I might end up coding up some tooling that allows me to parse files that I drew in some other application. For the purposes of prototyping, however, it's perfectly reasonable to just create the level in code like this.

With so many static objects in the scene, I realized that they are always checking for collisions between one another. I'll just add a minor optimization to prevent that from happening, since none of them will ever receive Hit components or be subject to collision resolution from one another.

linguine/src/systems/CollisionSystem.cpp checkCollision() snippet

if (a.has<Static>() && b.has<Static>()) {
  return false;
}

Collision in a Level

An unfortunate side effect of having box colliders side-by-side in a floating-point-based physics system is that the player can get "stuck" between two boxes, rather than sliding seamlessly along the apparently wall.

Stuck Collisions

The "solution" to this is just to avoid placing box colliders adjacent to one another altogether - and if you must, then prevent the player from colliding with them. Rather than placing boxes adjacent to one another, you could intelligently combine them into a single collider. This can be done at a variety of stages in the level creation pipeline, but I'm just going to ignore this problem completely for now. You can try it out for yourself here.

Pathfinding, Again

The boss currently just moves in the direction of the player, regardless of any obstacles. This results in the player easily manipulating the boss to get stuck behind a wall.

We actually already have perfectly usable A* pathfinding implemented in the engine, except that it requires a Grid to utilize. The tile-based movement of the Grid is okay, but it's not as nice as the free-form movement that we've implemented in this scene.

So I think I'll use a hybrid solution that I first heard about from one of the original creators of The Binding of Isaac, Florian Himsl, in his video Pathfinding Explained in the Binding of Isaac!.

Whenever the enemy actually sees the player, it just doesn't even do the pathfinding. If it sees the player, just go in a straight line. If you don't see the player, then you do the pathfinding.

Florian describes his own pathfinding algorithm in the video (for better or worse), but I'm just going to stick to our A* solution.

In order to achieve this, we'll need to implement raycasting into our physics engine, so that we know when the enemy cannot see the player. Raycasting equations are a bit harder for me to remember, and I'm sure I'll end up using the internet for guidance. I always stumble across this resource which contains links to a lot of physics and rendering equations.

What I ended up writing isn't actually considered raycasting - I'm specifically checking for line segment intersections. The difference is subtle, but a line segment has a finite length, while a ray extends to infinity. To configure my raycasting entities, I created a Raycaster component. This component contains a distance, as well as an optional RaycastHit, which is a struct containing the entity ID of the intersecting object, as well as the distance to the edge of that object.

linguine/src/systems/CollisionSystem.cpp snippet

std::optional<RaycastHit> CollisionSystem::checkRayIntersection(const Entity& a, const Entity& b) {
  if (a.getId() == b.getId()) {
    return {};
  }

  std::optional<RaycastHit> result = {};
  std::optional<RaycastHit> current = {};

  if (b.has<BoxCollider>() && (current = checkRayBoxIntersection(a, b))) {
    result = current;
  }

  if (b.has<CircleCollider>() && (current = checkRayCircleIntersection(a, b))) {
    if (!result || result->distance > current->distance) {
      result = current;
    }
  }

  return result;
}

std::optional<RaycastHit> CollisionSystem::checkRayBoxIntersection(const Entity& a, const Entity& b) {
  auto stateA = a.get<PhysicalState>();
  auto stateB = b.get<PhysicalState>();

  auto raycasterA = a.get<Raycaster>();
  auto colliderB = b.get<BoxCollider>();

  auto direction = glm::normalize(glm::vec2(
      glm::cos(stateA->currentRotation + glm::half_pi<float>()),
      glm::sin(stateA->currentRotation + glm::half_pi<float>())
  ));

  auto line = direction * raycasterA->distance;
  auto sign = glm::sign(line);

  auto nearTime = (stateB->currentPosition - sign * colliderB->size / 2.0f - stateA->currentPosition) / line;
  auto farTime = (stateB->currentPosition + sign * colliderB->size / 2.0f - stateA->currentPosition) / line;

  if (nearTime.x > farTime.y || nearTime.y > farTime.x) {
    return {};
  }

  const auto greatestNearTime = glm::max(nearTime.x, nearTime.y);
  const auto leastFarTime = glm::min(farTime.x, farTime.y);

  if (greatestNearTime >= 1.0f || leastFarTime <= 0.0f) {
    return {};
  }

  auto time = glm::clamp(greatestNearTime, 0.0f, 1.0f);

  return RaycastHit {
      .entityId = b.getId(),
      .distance = time * raycasterA->distance
  };
}

std::optional<RaycastHit> CollisionSystem::checkRayCircleIntersection(const Entity& a, const Entity& b) {
  auto stateA = a.get<PhysicalState>();
  auto stateB = b.get<PhysicalState>();

  auto raycasterA = a.get<Raycaster>();
  auto colliderB = b.get<CircleCollider>();

  auto direction = glm::normalize(glm::vec2(
      glm::cos(stateA->currentRotation + glm::half_pi<float>()),
      glm::sin(stateA->currentRotation + glm::half_pi<float>())
  ));

  auto offset = stateB->currentPosition - stateA->currentPosition;
  auto projectionDistance = glm::proj(offset, direction);

  if (glm::length2(projectionDistance) > raycasterA->distance * raycasterA->distance) {
    return {};
  }

  auto projection = stateA->currentPosition + projectionDistance;
  auto distance = glm::distance(stateB->currentPosition, projection);

  if (distance < colliderB->radius) {
    auto mod = glm::sqrt(colliderB->radius * colliderB->radius - distance * distance);
    auto distance1 = glm::distance(stateA->currentPosition, projection + mod);
    auto distance2 = glm::distance(stateA->currentPosition, projection - mod);

    return RaycastHit {
        .entityId = b.getId(),
        .distance = distance1 < distance2 ? distance1 : distance2
    };
  } else if (distance == colliderB->radius) {
    return RaycastHit {
        .entityId = b.getId(),
        .distance = glm::distance(stateA->currentPosition, projection)
    };
  }

  return {};
}

linguine/src/systems/CollisionSystem.cpp detectHit() snippet

if (a.has<Raycaster>() && !b.has<Trigger>()) {
  auto raycaster = a.get<Raycaster>();
  auto intersection = checkRayIntersection(a, b);

  if (intersection && (!raycaster->nearest || raycaster->nearest->distance > intersection->distance)) {
    raycaster->nearest = intersection;
  }
}

To test it out, I added the Raycaster component to the enemy entity, and updated the EnemyTargetingSystem to highlight the nearest object in front of the enemy in green.

Raycasting Test

Now to integrate the Grid into our scene. This part feels a little clunky because the state of the Grid is loosely coupled to the state of our physical world. Since the Grid is tile-based by nature, representing our walls in terms of Grid obstacles means the dimensions and positions of the walls need to be in terms of integers. We could technically use a Grid with tiles that are smaller than the actual objects in the world, which would afford us the ability to have fractional block sizes/positions, but that's kind of why I describe it as "clunky" - there is a bit of translation between what we consider "world space" and "grid space".

I made some pretty substantial chances to the Grid, including removing the concept of width and height. Instead, the Grid is centered at the origin, and obstacles can be added to any arbitrary coordinate, be it positive or negative. While it would be really inefficient to generate paths that are too far away with too many obstacles, it does allow me to ignore the constraint when trying to make sure the grid's coordinate system aligns with the world's coordinate system.

I also added path smoothing to the Grid's search() method. The resulting path allows the enemy to move in a more direct manner, while still avoiding obstacles.

linguine/src/data/Grid.cpp snippet

void Grid::smooth(std::list<glm::ivec2>& path) const {
  auto previous = path.begin();
  auto current = std::next(previous);
  std::list<glm::ivec2>::iterator next;

  while ((next = std::next(current)) != path.end()) {
    if (isWalkable(*previous, *next)) {
      auto toRemove = current;
      current = std::next(current);
      path.erase(toRemove);
    } else {
      previous = current;
      current = std::next(current);
    }
  }
}

The isWalkable() method (not shown) simply steps through the grid in steps of 0.2f, and determines if there are any nearby obstacles that should prevent the enemy from cutting corners. If the method returns true, then the intermediate path nodes are removed from the resulting path.

Additionally, I had to update the EnemyTargetingSystem to account for the grid-based movement when the player is not in line-of-sight. If the player can't be seen, then we dynamically add a GridPosition component to the enemy and allow the GridPositionSystem to take over the movement. When the player is in sight again, we remove the GridPosition component and control the movement from the EnemyTargetingSystem again.

This strategy almost worked, except that the enemy would get "caught" on corners. Once the player was in sight of the enemy, it would immediately ignore the Grid, and start moving toward the player, even though a nearby wall would prevent it from doing so. I toyed around with the idea of making sure the enemy had completed its last grid-based maneuver before returning to the "follow the player" behavior, but it didn't feel right.

Instead, I removed the Raycaster component from the enemy entity entirely. I created two new entities, each with a Raycaster component, and "bolted" them to either side of the enemy unit. If both Raycasters can see the player, then we return to the following behavior. If either one of them cannot see the player (presumably because it's still behind the corner of a wall), then we continue using the grid-based movement.

To achieve this, I created a new Attachment component, containing a parentId and an offset. I then created a new AttachmentSystem, which is responsible for keeping the entity attached to its parent according to its configured offset.

linguine/src/systems/AttachmentSystem.cpp

#include "AttachmentSystem.h"

#include <glm/geometric.hpp>
#include <glm/gtx/quaternion.hpp>

#include "components/Attachment.h"
#include "components/PhysicalState.h"

namespace linguine {

void AttachmentSystem::fixedUpdate(float fixedDeltaTime) {
  findEntities<Attachment, PhysicalState>()->each([this](const Entity& entity) {
    auto attachment = entity.get<Attachment>();
    auto physicalState = entity.get<PhysicalState>();

    auto parent = getEntityById(attachment->parentId);
    auto parentPhysicalState = parent->get<PhysicalState>();

    auto offset = glm::angleAxis(parentPhysicalState->currentRotation, glm::vec3(0.0f, 0.0f, 1.0f))
                  * glm::vec3(attachment->offset, 0.0f);

    physicalState->currentPosition = parentPhysicalState->currentPosition + glm::vec2(offset);
    physicalState->currentRotation = parentPhysicalState->currentRotation;
  });
}

}  // namespace linguine

linguine/src/systems/EnemyTargetingSystem.cpp snippet

void EnemyTargetingSystem::moveTowardTarget(Entity& entity,
                                            Component<Targeting>& targeting,
                                            Component<PhysicalState>& physicalState,
                                            float fixedDeltaTime) {
  auto targetId = *targeting->current;
  auto target = getEntityById(targetId);
  auto targetPosition = target->get<PhysicalState>()->currentPosition;

  auto raycasters = findEntities<Attachment, Raycaster, PhysicalState>()->get();

  for (const auto& raycasterEntity : raycasters) {
    auto raycaster = raycasterEntity->get<Raycaster>();
    auto raycasterPhysicalState = raycasterEntity->get<PhysicalState>();
    raycaster->direction = glm::normalize(targetPosition - raycasterPhysicalState->currentPosition);
  }

  auto isPlayerInSight = std::all_of(raycasters.begin(), raycasters.end(), [this](const std::shared_ptr<Entity>& raycasterEntity) {
    auto raycaster = raycasterEntity->get<Raycaster>();

    if (raycaster->nearest) {
      auto nearest = getEntityById(raycaster->nearest->entityId);
      return nearest->has<Friendly>();
    }

    return false;
  });

  const auto speed = 2.0f;

  if (isPlayerInSight) {
    if (entity.has<GridPosition>()) {
      entity.remove<GridPosition>();
    }

    auto direction = glm::normalize(targetPosition - physicalState->currentPosition);
    physicalState->currentPosition += direction * speed * fixedDeltaTime;
    physicalState->currentRotation = glm::atan(direction.y, direction.x) - glm::half_pi<float>();
  } else {
    if (!entity.has<GridPosition>()) {
      entity.add<GridPosition>();
    }

    auto gridPosition = entity.get<GridPosition>();
    gridPosition->position = _grid.getGridPosition(physicalState->currentPosition);
    gridPosition->speed = speed;
    gridPosition->finalDestination = glm::round(_grid.getGridPosition(targetPosition));
  }
}

To make sure all of this worked as intended, I temporarily added code to create Drawables for the Attachments, as well as to visualize the path smoothing logic. Other than that, I also removed the logic to dynamically add and remove obstacles to the Grid from the GridPositionSystem.

Honestly, just getting the enemy to stop walking into walls was a lot more work than I had anticipated, but I'm glad we have a solution for it moving forward.

Raycast-oriented Pathfinding

Defining Goals

I've built a lot of cool functionality this week, but unfortunately I am no closer to having a "game" than I was before I started. In fact, I probably have even less of a game than I had before! In the procedural level prototype, the player could defeat enemies and traverse through a series of rooms, while recovering the health lost by the enemies' attacks. That prototype had the moment-to-moment goal of healing, the short-term goal of defeating enemies in the current room, the mid-term goal of reaching the next room, and the long-term goal of reaching the final room. It was surprisingly well-rounded, considering I didn't put much effort into thinking about any of that.

Our current level has no well-defined goals at all. I personally tend to avoid being hit by the enemy by hiding behind walls, defeats the purpose of the healing-oriented gameplay that I want to achieve. Furthermore, since the enemy is always following you, it's natural to retreat - especially since you can't even fight back.

So let's try to define some more established goals.

Moment-to-Moment Goals

The moment-to-moment goal should still be healing to keep your health bars from depleting. So far, the healing requirement of this level is low because the single enemy's attacks aren't particularly threatening to begin with and they are completely avoidable. We can address the former by beefing up the enemy's capabilities, but the walls present an interesting problem.

The 2016 release of DOOM and its 2020 sequel DOOM Eternal address this very problem in the context of a shooter, in which the player is likely to take cover behind objects and fire at the enemies in a safe and opportunistic manner. The developers wanted to incentivize the player to take risks with close-quarters combat, so they introduced the "glory kill" - a maneuver that can only be performed at close range, but - when executed successfully - rewards the player with health recovery and weapon ammunition.

Our game doesn't currently have any resources to replenish other than the health bars, which can be filled by the player without any problem. Naturally, the "best" strategy is to avoid getting hit altogether to avoid even having to do that!

Short-Term Goals

I imagine the player's short-term goal to be defeating the current boss by "solving" the fight mechanics. This is rather difficult for me to articulate because it feels so... arbitrary. The mechanics of any given fight could be anything, and I just kind of have to decide what it is.

I've been using the Zelda series as my example, where each boss encounter is a test of proficiency with the key item that was discovered in the current dungeon. The encounters are rarely difficult for the player to overcome, once they figure out the primary mechanic. They often boil down to "use the key item at a certain time to make the boss vulnerable, then wail on them with your sword".

I don't think I want to rely too heavily on a key item that the player must have in order to overcome any encounter. I prefer the idea that each encounter contains environmental components that will allow the player to defeat the enemy. In that case, it's not much different than a puzzle game, in that each level contains a unique obstacle that must be overcome. Going back to the Zelda series, the dungeons themselves are often thought of as puzzles. In our case, the boss room itself could be a puzzle that must be solved while the player keeps themselves alive.

Mid-Term Goals

I'd like the mid-term goals to be more impactful for the player. I think it would be cool to group the levels into encounters that utilize shared mechanics, in order of increasing difficulty. Similar to older games that organize their levels into "worlds", I think the mid-term goal for our game should be defeating all of the bosses within a world.

Rather than just unlocking the next world, perhaps we could reward the player with additional abilities. This is pretty far off, so I'm not putting too much thought into it yet, but some direction is better than nothing.

Long-Term Goals

The long-term goal for any game is the completion of the game itself - in our case, completing all of the worlds. Additionally, a game might have various collectables or secrets scattered throughout the levels, providing even more long-term goals for the player to work toward.

The First Encounter

When building a level, I need to consider how it will fit thematically into a larger grouping of levels within a single world. Furthermore, I need to make sure the design of each level does not sacrifice or impede the intended moment-to-moment gameplay.

The first thing I'll do is remove the interior walls. I know I just spend a lot of time on pathfinding, but their mere existence is antithetical to the intended gameplay. It is entirely possible that we can reintroduce them into other levels with more interesting mechanics (perhaps the boss can destroy the walls if the player hides behind them, causing them to explode for a ton of damage), but for the first level, I don't think they should exist.

14.3 The Blows Keep Coming

On Saturday - July 29, 2023 - my father passed away. I was actively typing the words in the previous section when my mother called to inform me. Naturally, I didn't continue working after that. Only two days have passed, and it's felt like an eternity filled with emotions, traveling, and decision-making.

I'm grateful for the time I've gotten to spend with him over the last few months, and it was a good idea for me to take some time off from writing to do so. While our hobbies and interests didn't intersect often, we still enjoyed each other's company. Ultimately, I just know he was proud of me and the legacy he left behind, and there wasn't anything more that needed to be said.

I love and miss my dad. His support and approval - along with that of the rest of my family - have pushed me to be the best version of myself. I hope that I can continue to make him proud.

Latest commit.

Latest playable build.