A corrupted king. Horded elemental power. High up in his castle, but not for long... if you can help it. Tasked with defeating the king to restore your village, you must deftly jump, climb, and fight your way to the top of his castle, using your quick thinking, tenacity, and even the king's own elemental power to succeed.
Team:
Gameplay programmer, Systems and Level Designer
Remade systems from a teammate's original game in Flintlock, our Custom C++ Engine.
Built new systems, such as room encounters and enemy elemental transformations, to enhance original gameplay.
Implemented game audio such as SFXs and transitioning BGMs, using Wwise.
Designed gameplay mechanics, and levels, such as redesigning the intro level.
4 months               5
2D Combat Platformer
PCÂ Â Â Â Â Â Â Â Â Â Â Â Â Â Â Â Â Â Â Â Flintlock (Custom C++)
This class was one I created to let us apply a weighted random to our enemy AI, giving us an easy way for it to make decisions in an intentional but semi-unpredictable way.
A container of items of any one data type, where each item is assigned a weight.
template< typename T>
class FL_API RandomBin
{
public:
RandomBin(){};
~RandomBin() {};
// Sets the item's weight
// If item does not exists, creates a new item with the given weight
void SetItem(const T& itemName, int weight);
virtual void AddToItem(const T& itemName, int weight);
float GetItemWeight(const T& itemName);
  // Picks a random item, bases on set weights
T DrawItem() const;Â
protected:
std::map<T, int>& GetItems() { return mItems; }
private:
std::map<T, int> mItems;
};
#include "RandomBin.cpp"
Then, using the DrawItem function, one of the items can be randomly picked, with items of a higher weight more likely to be chosen.
template<typename T>
T RandomBin<T>::DrawItem() const
{
if (mItems.empty())
{
return T();
}
// Calculates the total weight
int weightTotal = 0;
for (auto it = mItems.begin(); it != mItems.end(); it++)
{
weightTotal += it._Ptr->_Myval.second;
}
// Gets a random value for deciding which item to choose
int chosenValue = Random::RangeInt(0, weightTotal - 1);
// Finds which item the chosen value falls under, and returns chosen item
int lastRangeEnd = 0;
for (auto it = mItems.begin(); it != mItems.end(); it++) // For each item
{
              // If in this item's random range
if (chosenValue >= lastRangeEnd && chosenValue < lastRangeEnd + it._Ptr->_Myval.second)Â
{
return it._Ptr->_Myval.first; // Returns chosen item
}
lastRangeEnd += it._Ptr->_Myval.second; // Moves past this range
}
return T(); // if this reaches here, one of the weights is likely negative
}
A weighted random is often preferable to regular random when using it for behavior, because the weights let you mimic enemy preferences. Set the weights right, and it seems like the enemy is making tactical decisions, while sometimes choosing an unlikely option, either out of stupidity, or to catch the player off guard.
I used this random bin class to help decide when an enemy would transform their element.
Getters and setters for the transform random bin, from the generic enemy class:
RandomBin<Elements> mTransformChance; // Transform chances for each element
const Elements GetTransformOption() const { return mTransformChance.DrawItem(); }
void BehaviourEnemy::ClearTransformChances(int noTransformChance)
{
mTransformChance.SetItem(POWERLESS, noTransformChance);
mTransformChance.SetItem(FIRE, 0);
mTransformChance.SetItem(EARTH, 0);
mTransformChance.SetItem(LIGHTNING, 0);
}
Here, when an enemy receives an attack, it increases its chance to transform into that attack's element, based on a table of modifiers for each element.
void BehaviourEnemy::TakeAttack(AttackData attack)
{
        //… other attack functionality …
        // Add transform chance from incoming attack type
mTransformChance.AddToItem(attack.mElement,Â
           static_cast<int>(mElementStats.GetModifier(AffinityTypes::Transform, mCurrentElement, attack.mElement)));
}
Here in the enemy behavior tree, every so often, the enemy uses the random bin to see if it should transform.
TreeState Check_ShouldTransform::Update()
{
    auto* blackboard = dynamic_cast<Blackboard*>(mBlackboard);
    if (mCheckTransformTimer >= 0) // If timer is still running
    {
        mCheckTransformTimer -= Time::DeltaTime(); // Updates timer
        if (mCheckTransformTimer <= 0) // If time to check
        {
            BehaviourEnemy* enemy = dynamic_cast<BehaviourEnemy*>(blackboard->mUnit);
            if (blackboard)
            {
                BehaviourUnit::Elements transformElement = enemy->GetTransformOption(); // Pulls a random to see if the enemy should transform
                if (transformElement == BehaviourUnit::POWERLESS)
                {
                    mCheckTransformTimer = mCheckTransformInterval; // Resets check interval
                    mState = TreeState::FAILURE; // No transformation, returns failure
                    return mState;
                }
                else
                {  Â
                    enemy->SetNextElement(transformElement);
                    // updates next node
                    TreeState childResult = TreeState::READY;
                    childResult = Child()->Update();
                    mCheckTransformTimer = mCheckTransformInterval; // Resets transform check timer
                    enemy->ClearTransformChances(mNoTransformChanceReset); // Resets transform chances
                    mState = childResult;
                    return mState;
                }
                   Â
            }
        }
       Â
    }
   Â
    mState = TreeState::FAILURE; // No transform check occurred, or bad blackboard, returns failure
    return mState;
}
Using the random bin this way allows the enemy AI to do a few easy, but useful things:
To usually transform into the element it is hit with the most, while sometimes surprising the player by choosing another.
To increase the chance of transforming over time.
To adjust transform chances at any point, such as resetting chances after each transformation.
Before being hit
After deciding to transform
One feature we found was important to add was locking the player in a space so they couldn't escape important fights. And, with many of our levels already mostly built from the previous version of the game, I made this barrier manager as our room system, keeping in mind the need to be able to flexibly place barriers, and enemies within them.
This class houses each barrier group, and within each group, data like each barrier that's a part of it, and the enemies that must be defeated.
class FL_API FLBarrierManager : public Module
{
public:
void Init();
void Update();
void Shutdown();
void ActivateBarrierGroup(int barrierID);
void DeactivateBarrierGroup(int barrierID);
void AddBarrier(Entity barrierObj);
void AddEnemySection(glm::vec2 position, glm::vec2 scale, int barrierID);
void RemoveMeFromBarrierGroup(int entityID);
void ResetActiveBarrierGroup(int placeholder);
void ClearBarriers();
static std::string Name() { return "FLBarrierManager"; }
private:
enum class BarrierNums
{
NoActiveGroup = -1
};
struct EnemySection
{
glm::vec2 mPos;
glm::vec2 mScale;
};
struct BarrierGroup
{
std::vector<EntityID> mBarriers;
std::vector<EnemySection> mEnemySections;
std::vector<EntityID> mEnemies;
};
bool EnemyInBarrierGroup(BehaviourEnemy& enemy, int barrierID);
std::map<int, BarrierGroup> mBarrierGroups; // The barrier groups, categorized by ID
int mActiveBarrierGroupID = static_cast<int>(BarrierNums::NoActiveGroup);
};
Barrier groups are added to the barrier manager through drawing the above boxes on our tilemap (using the Tiled map editor).
The white boxes spawn a barrier object with a collider that will trigger the group.
Purple boxes determine which enemies must be defeated to unlock the barriers.
Just mark each piece with the same BarrierGroupID, and you're good to go!
When a barrier is triggered, it calls this function, activating all barriers in the group, and setting up which enemies must be defeated to progress.
void FLBarrierManager::ActivateBarrierGroup(int barrierID)
{
if (mBarrierGroups.contains(barrierID) == false) // Ensures barrier group exists
{
Trace::log("Tried to activate barrier group of ID: " + std::to_string(barrierID) + ", but no barrier group exists");
return;
}
Scene* mainScene = FLEngine::Instance().Get<FLSceneManager>()->GetScene("Main Game"); // Gets the main scene
if (mainScene)
{
ECS* mainGameEcs = mainScene->GetECS(); // Gets the ecs of the main scene
if (mainGameEcs)
{
// Triggers each barrier
for (EntityID barrierID : mBarrierGroups[barrierID].mBarriers)
{
if (mainGameEcs->HasComponent<BehaviourTrapBarrier>(barrierID))
{
BehaviourTrapBarrier& barrier = mainGameEcs->GetComponentFrommanager<BehaviourTrapBarrier>(barrierID);
barrier.TriggerBarrier();
}
}
mActiveBarrierGroupID = barrierID; // Saves active barrier group ID
// Subscribes to each enemy that will need to be killed
std::unordered_map<EntityID, BehaviourEnemy>& enemies = mainGameEcs->GetComponentManager<BehaviourEnemy>().GetComponents();
for (auto& enemy : enemies) // Checks each enemy
{
if (EnemyInBarrierGroup(enemy.second, barrierID))
{
// Subscribes removal function. No unsubscription needed, because enemy is deleted after calling
enemy.second.DeathEvent().SubscribeMember(FLBarrierManager, RemoveMeFromBarrierGroup, "RemoveEnemy" + std::to_string(enemy.first) + Â Â Â Â Â Â Â Â Â Â Â Â Â Â Â Â Â Â Â Â Â Â Â Â Â Â Â Â Â Â Â Â Â Â Â Â Â Â Â Â Â Â Â Â Â Â Â Â Â Â Â Â Â Â Â Â Â Â Â Â Â Â Â Â Â "FromBarrierGroup");
mBarrierGroups[barrierID].mEnemies.push_back(enemy.first); // Adds enemy to the group
}
}
}
}
// Audio: Switches to the battle state
FLSound::PostEvent("TO_BATTLE_BGM");
}
In the update loop, checks for if a barrier group is active, and deactivates it if all enemies have died.
void FLBarrierManager::Update()
{
// For testing
if (Input::KeyReleased(GLFW_KEY_PERIOD))
{
DeactivateBarrierGroup(1);
}
if (mActiveBarrierGroupID != static_cast<int>(BarrierNums::NoActiveGroup))
{
if (mBarrierGroups[mActiveBarrierGroupID].mEnemies.empty() == true) // If all enemies have been killed
{
DeactivateBarrierGroup(mActiveBarrierGroupID); // Deactivates the current barrier group
}
}
}
After some error checking, deactivates each barrier, and sets the music to the explore state; ending the battle.
void FLBarrierManager::DeactivateBarrierGroup(int barrierID)
{
if (mBarrierGroups.contains(barrierID) == false) // Ensures barrier group exists
{
Trace::log("Tried to activate barrier group of ID: " + std::to_string(barrierID) + ", but no barrier group exists");
return;
}
Scene* mainScene = FLEngine::Instance().Get<FLSceneManager>()->GetScene("Main Game"); // Gets the main scene
if (mainScene)
{
ECS* mainGameEcs = mainScene->GetECS(); // Gets the ecs of the main scene
if (mainGameEcs)
{
// Deactivates each barrier in group
for (EntityID barrierID : mBarrierGroups[barrierID].mBarriers)
{
if (mainGameEcs->HasComponent<BehaviourTrapBarrier>(barrierID))
{
BehaviourTrapBarrier& barrier = mainGameEcs->GetComponentFrommanager<BehaviourTrapBarrier>(barrierID);
barrier.DeactivateBarrier();
}
}
mActiveBarrierGroupID = static_cast<int>(BarrierNums::NoActiveGroup); // Clears saved active barrier ID
}
}
// Audio: Switches to the explore BGM
FLSound::PostEvent("TO_EXPLORE_BGM");
}
With enemies left, the barrier prevents forward progress
With the enemies defeated, the barrier is now gone
While the first version of this level had some strong moments like a startling first enemy encounter, and a good start to an underground passageway, it had weaknesses like inconsistent platforming and rather quick difficulty curve, with the player facing death before being properly tested.
So, when given the task to redesign the first level, I sought to improve these weaknesses, while building upon its bones for an intriguing castle infiltration sequence. Below is how I tackled the inconsistent platforming.
First, while the jumps in this level were simple, they didn't follow a specific base speed, meaning that platforms weren't lined up for successive jumps, and required a bit of running for the player to platform properly.
If the player continously jumps...
they end up falling. Can this feel better?
While not all platforming fits base speed, allowing the player to get in a rhythm of quick jumps is a recipe for flow while platforming, and in sections where this works, it generally feels better give sequences this property.
With this, I gathered the data for our base speed, and used it to create new groups of obstacles.
While automation is something to consider for the future, for this project, I created a test level where I could try incremental increases in distance to get specific, usable values for base speed (maximum horizontal and vertical jump distance).
With this data in mind, I aligned the jumps with the level grid at base speed, to better support player flow, and overall smoother platforming.
While tough to know when to start jumping, tightly timed jumps here flow together well
This section includes rolling, and base speed allows for quick alternations between rolling and jumping
For this project, being partially about new designs, but also about remaking an older project in a faithful way, it was a unique opportunity to explore both what adds to the experience of the game, and how best to collaborate to achieve specific requirements. It was also a great chance to practice and see the benefits of collaborating on the design process overall, with a teammate who was the other main designer. The other main lessons I found were the importance of defining problems clearly, and perhaps most resonant of all, it was also a reminder on the importance of testing as many aspects of the game as possible, as often as possible.
For one of the practices I believe I did well, I collaborated consistently with the other main designer/ programmer, who was also on the old project, to find our balance of remaking old systems exactly, and tweaking them to find improvements. Through utilizing concepts similar to design pillars, we were able to discern what mattered most to keep from the prior game, in areas like the level structure, and movement mechanics, so that the game kept it's core engagement. For instance, through talking with this teammate about what to redesign in levels, we determined that one of the most important aspects of the old game was giving the feeling of climbing the castle as you platformed. With this, the layout of the puzzles/ enemy encounters mattered a little less, so this helped us direct our work, knowing that we were freer to change around the puzzles to ensure a good platforming feel, as long as the sequences were framed in a castle structure. For me, in redesigning the intro level, this meant I could lengthen the underground section to allow for more time to practice mechanics and could adjust the obstacles as outlined in my design sample. After doing this, I then made sure that when adding details, and readjusting the rest of the level, I kept the level looking like the outside of a castle leading into a secret underground tunnel, to preserve the intended environmental feel. If not for defining our pillars, I may not have felt like we could make as many changes, and at the very least, it would have taken longer to do so. So, I plan to make note of how defining the specifics of our goals this way helped, for future projects.
Additionally, through collaboration and having enough development time to look at mechanic feel more closely, I was able to find a variety of areas to add additionally clarity, and game feel improvements in, through adding more feedback. In previous projects, I've only had so much time to add particle effects, and extra SFX, let alone to ensure all the basic SFX are accounted for, so getting more time to add these features made it clear just how much they augment the feel of a game. When finding what SFX to add to even just the main elemental abilities, I found tons of room to make each action more exciting, with fiery, spark-filled, and earthen flourishes for switching elements, spatialized fireball flight sounds, and the like. I know this kind of polish may lead to a rabbit hole where infinite time can be spent, but especially for the sound effects that help with clarity, like one we added whenever your elemental attack is nullified by an enemy, they seem quite beneficial to teaching the player naturally, and improving the experience. While more testing would have helped in confirming just how impactful these additions were, there were plenty of times when players reacted positively to the effects. With this, I will see if time permits, but I plan to approach future mechanics with at least these options for polish in mind, so that I can act on them quicker, and potentially get more polish in by the end of development.
This collaboration and defining of goals also helped in areas like systems design, and the creation of the enemy transformation system. In the old game, enemies spawned already transformed, and their elemental nature provided a smaller impact, where you would deal increased or decreased damage if you picked the right elemental attack against them. It was a system that was somewhat shoehorned in the first time, and we weren't sure how to utilize it better, but after defining one main goal to be making each element useful in combat, and one of our main combat issues to be the strength of the fireball when spamming, we were able to link this elemental transformation system to a solution. We considered multiple options, but by leveraging the old elemental enemy system, and using it to make enemies transform on the fly, we could make each element usable, while limiting the effects of spammable strengths. In this new system, if the player used one element for too long against an enemy, they would transform to make that element ineffective, and require a change of strategy. Of course, there were still concerns of this hiding balance issues, but when coupling this with testing, we largely noticed the desired effect of a more dynamic combat experience. Overall, while we used multiple strategies to solve this issue, this experience highlighted the importance of defining our problems well, and collaboratively brainstorming solutions, so that we could best improve the experience of the player, while delivering the desired style of gameplay.
This also leads into one of the areas I feel like I could improve in, which is deciding on simpler solutions to problems/ finding a workable implementation for systems quicker. For example, take when certain systems decisions were made, such as that we needed barriers to lock the player in for certain fights, and that we needed to be able to force the player to pause for tutorials, and potentially other situations like cutscenes. Knowing that I was going to implement these, it took me a little while to consider how modular I would need to make their systems, and how I might integrate them into existing functionality. For example, for our cutscene needs, I wasn't sure if I should make an entire cutscene system, controlling the entire game and telling each piece what to do, or if I should just have the individual agents like the player handle their behaviour during the cutscenes. The first would likely take a while to implement, while eventually allowing more complex cutscenes, while the second could be done quickly utilizing our behavior trees but would be tougher to use when linking multiple agent's behaviour together in any complex way. The better option wasn't clear upfront, and in general I usually favor modularity, but I also knew that speed was a priority here, as other systems relied on its completion. To solve this, I tried defining the problem to solve, in which I realized that our main goal here was to have the player pause during cutscenes, and maybe play some dialogue. So, with this definition in mind, I likely didn't need that complex of a system. Sometimes I will take too long to pick an option, and I've been trying to have more of a bias towards action after doing the initial planning, so I began testing what this system would look like by creating a cutscene that paused the player. I found I could accomplish this using either method, but while the larger system worked, using the simpler system based on the behaviour tree also allowed for the player animations, and the rest of the game to work as normal, making the implementation much simpler, while still fitting our needs. While looking back, I do still think that the decision was complex enough to require time for consideration, but by coming up with a few workable ideas, and prioritizing testing them before I planned more, I believe I saved time, and arrived at a simpler solution. While complexity may be required sometimes for implementation of systems, I can tend to lean toward more complex approaches, and for future problems, I plan to focus on breaking the problem down into specific goals, and testing solutions sooner, so that I can minimize the time it takes to find a good balance of useful complexity and keeping development moving in a timely manner.
Kind of like how the location is for real estate, I've been told many times how important testing is for design, I have seen many examples of why this is true. But when, how often, and what to test, are still things that I know I can improve my knowledge of. Thankfully, through many instances in this project, I was both reminded of how useful feedback from testing is and shown how even the simplest of playtests can give you a pathway to improvement. For example, in the beginning, when many mechanics decisions were left to be made, we had multiple players play the old version of the game. We got quite a few ideas for quality-of-life improvements, and we also happened to run into testers who were quite familiar with ability-based platformers. They brought up an interesting idea of not locking movement mechanics behind abilities, but instead having your unlockable abilities be more combat focused, so that the player wasn't restricted (and for difficulty reasons). This conflicted with our intentions and encouraged us to reconsider the intent behind why our mechanics worked the way they did. It also pushed us towards a variety of solutions, like improved control layouts, and a new final form with access to all abilities, all of which would have been tougher to come up with, and integrate into the game cohesively, if we hadn't been testing from the beginning.
Another aspect of testing that was highlighted was the importance of testing as many aspects of a game and its systems as possible. Throughout the project, I thought I did a good job of testing each feature I implemented in various ways, and I caught a lot of bugs this way. But even with all my testing efforts, I still missed certain crucial spots. One example of this was when I found a few bugs with our menu system, towards the end of development. Up until then, I had tested mostly in our editor, and jumped around scenes as necessary, but as it turned out, when I started testing in Fullscreen mode, I found that certain things weren't reset correctly. Even with all my considerations, I still didn't foresee this being a problem, and I think I can improve my testing to account for issues like this in two main ways. First, kind of like in my current Remanence project, where we keep a QA checklist, I think it would be useful to maintain a sheet of systems that exist; all of which need to be verified as working before each new build. The idea behind this is that regardless of the issues you think may come up, you ensure that everything is working correctly by seeing what happens when the build is run. The other method that I think would help is prioritizing getting as many features as possible in front of outside testers. When we tested, the core systems certainly got scrutinized, but systems like the menus got used less often, and with how many gameplay issues are discovered once someone with no knowledge of how something should work steps in, it seems important for quality assurance that the smaller systems are tested this way too. And, even if things are working as intended, based on the tester's responses, this testing could still reveal more paths for improvement.