Art Direction and a New Vision
This is perhaps the most difficult subject that I've encountered on this journey so far.
I once considered myself to be a very creative person. I loved to draw as a kid, and had a folder that contained all of my Zelda drawings that I copied from strategy guides and issues of Nintendo Power. I would design skate parks for Tony Hawk's Pro Skater, years before the release of the actual park editor in the second installment of the series. My family was very musical, and I learned how to play guitar and drums, seemingly without even trying. I loved to write music, and even performed with a handful of bands over the years.
My progression as a programmer started when I was about 10 years old, customizing my Neopets shop and MySpace profile page with heavy CSS modifications. When Facebook started to gain traction, I was very disappointed in the new unfamiliar platform due to its lack of creative expression that I considered paramount to the success of a social network. I was evidently very wrong.
When I started really learning how to code in my early 20s (as opposed to designing web pages with markup languages), I began to view programming as its own form of creative expression. The code itself is a thing of beauty that the programmer can mold with every interface and variable declaration. I've written a lot of beautiful code throughout this series, but I've written a lot of ugly code as well.
Obviously I wouldn't be writing all of this if I didn't have at least some proficiency in writing. I didn't do particularly well in my writing classes in school, but I must admit, writing this has been quite therapeutic. I constantly crave some sort of creative release, and this has scratched the itch quite well.
Here I am, entering my mid-30s, and all of these memories feel like someone else's life that I merely read about in passing. It was all part of my own life, but so distant now. I can count on one hand the number of songs I've written over the last decade. I've gone through a couple of phases where I wanted to pick up drawing again, but I never stuck with anything. The most artistic thing I've done in recent memory is create app icons in Adobe Illustrator for some random business ideas that my friends have had.
16.1 Visual Style
Asset Distribution Platforms
These days, there are a lot of games being released every single day. A significant portion of them utilize art that was purchased from a distribution platform like Unity's asset store, or itch.io. These platforms are awesome. They allow developers (especially solo devs) focus on building their game's mechanics rather than spending a ton of time developing art assets. Some developers have no artistic capabilities whatsoever, so they can fill in their missing skills with assets from these platforms. There are some significant problems with these platforms, however.
The highest quality assets are used in many, many games, which makes all of those games sort of blend into the crowd. It might not matter so much for environmental art, but it's hard to stand out when your main character has the same face as that of another game.
A lot of assets also attempt to reproduce the art style of an already popular game. There are several assets available that are meant to recreate the unique art style of Monument Valley. I'm pretty sure a lot of the environmental asset packs are intended to reproduce the beauty of The Legend of Zelda: Breath of the Wild. The quality of these assets are incredible, but copying someone else's artistic vision won't necessarily help you sell your game.
Unique Style
Alright, so if I don't buy pre-made assets online, what are my options? The goal is to have an art style that is both unique and recognizable. I'm not opposed to trying to produce the art myself, though it might take me a very long time.
One of my biggest problems is that I don't think I actually have a "style". I never pursued visual arts for long enough to really develop my own recognizable style. Games like Super Meat Boy and The Binding of Isaac are immediately recognizable due to Edmund McMillen's unique artistic flavor. I would love to develop my own artistic skills to that point, but I'm not sure that's a realistic goal for myself. After all, I would like to actually release this game in some reasonable amount of time.
I could always hire someone to develop the art for me. I have no idea how to go about doing that, but that's exactly what Jonathan Blow did for Braid. He's more well-known as a competent programmer and designer than an artist, so that might make more sense for me - though I wouldn't even consider myself a "designer" yet. Wikipedia states that Blow spent around $200,000 USD out-of-pocket for the game's development - "most going towards hiring David Hellman for artwork and for living expenses". Yikes
I've actually spoken to a few of friends about trying their hand at developing artwork for my game. Only one of them entertained the idea for more than a second, but ultimately declined, feeling doubtful of his artistic ability.
So I've been trying my own hand at it. My first attempt was to create a halftone post-processing effect, to make the game look like it was printed into a comic book. The result was... not great.
I'm sure I make it look more presentable if I put a substantial amount of time and effort into it, but I spent nearly an entire day just getting that far. I don't really want to check the code in, but I'll commit it to a separate branch for transparency. Here is the commit containing the terrible shader code, which is just baked directly into Pesto's sRGB post-processor.
I took a step back and whipped out my iPad, which has a handful of composition apps on it. I used an app called Procreate to whip up a couple of bad sketches. One looks like the silhouette of a tall bird with butterfly wings - probably reminiscent of something you'd see in Monument Valley. Another started out as the Egyptian god Anubis, but turned out looking more like Scooby-Doo.
Okay so maybe I'm no good at freeform sketching. I started up Adobe Illustrator instead. Unfortunately, all I could think to draw in a vector format were progress bars - really minimal progress bars. "Minimalism" is at least a style of some sort, I guess that's progress.
I opened up Pixaki and tried to come up with the most minimal version of pixel art I could think of. I've been using this 16-color palette for a long time now, and I honestly don't remember where I got it from, but I really like how it forces color limitations on me so that I can really think about how the colors are used.
I started with the idea of a playable character, and went on to expand the idea to a level mock-up.
It's not completely terrible, but I don't love it.
I opened a new canvas and tried to whip up some pixel art that had a bit more detail. Obviously it would require a higher resolution than my 4x8 characters, but it doesn't have to be super detailed. I stayed up really late into the night scribbling, but ultimately came up with something somewhat presentable: a simple little warlock.
I'm sure an experienced pixel artist could point out every flaw in this result, but I'm pretty happy with the result. I can obviously produce results, even if it takes me a long time. In theory, I'll get faster as I produce more assets, and hopefully develop my own consistent "style". Worst case, I can use my own art in the short-term and replace it all with the work of a professional later.
I wish I had more to say about the topic. While I do consider programming to be a form of creative expression, there are still a lot of logical decisions that must be made, which leads to a lot to write about. I find that I don't really think very much when I'm drawing though - I just scribble until I like what I see. It's sort of liberating, but the pressure of creating all of the art for the game actually stresses me out.
Other Options
I recently spoke with an old friend who asked what I had been working on lately (he knows that I'm always working on something). When I told him about the game, he gave a rather direct response that has been stuck in my head:
You don't take enough shortcuts.
If I want to actually release a game, then why am I wasting so much time writing my own engine? Why am I writing this devlog about it? Why don't I just use Unity, buy some assets off the Asset Store, and focus on building out the actual game content?
He is absolutely right, and so I booted up Unity to see what I could whip up. Staring at the default scene, I felt completely and utterly uninspired. I felt the same feeling I would get in college when I would stare at a blinking cursor, knowing that my essay was due the next morning. I felt the same feeling that I still get when I'm assigned to a task at work that is menial or tedious. I just don't want to do things that way. I obviously forced myself to write those uninspired essays in college, and I still find a way to power through the boring parts of software development at work, but this is different - I don't have to do anything that I don't absolutely want to do with this project.
And so I closed Unity.
However, the conversation did make me ponder the possibility of a 3-dimensional tile-based art style. Something that utilizes a lot of textured cubes, though not pixelated like Minecraft. I imagine something very cartoony, where each individual cube is easily distinguishable.
Interestingly, our engine technically already supports 3D graphics, so it would just be a matter of writing shaders capable of rendering lighting effects. I could actually leave the physics of the game in 2D, as long as each playable level is "flat" - it really doesn't matter to the physics engine what the player sees.
I think I'll punt on further art style experimentation for now, but it was a good thought exercise. For now, I can focus on a quick-and-dirty 2-dimensional version of the game that is fully playable, and perhaps translate a single slice of the game into 3D later, just to see how it turns out.
I appreciate when someone challenges me directly, with no malicious intent - it forces me to look at my decisions through a wider perspective.
16.2 Narrative
I briefly touched on this topic at the end of the last chapter, but I mostly expressed very negative opinions of storytelling in video games. I still have great respect for those games as works of art in their own right, but there are other games that manage to convey interesting stories on top engaging mechanics. However, a game's story doesn't have to be told in a traditional way. Large game studios make heavy use of animated cutscenes, but that isn't necessary at all. I particularly enjoy Jonathan Blow's style of storytelling - though the mere act of explaining it would be a spoiler for his games.
I like the idea of a game that allows the player to discover the story over time, almost as an optional feature of the game. More specifically, I like the idea of someone interpreting a story based on limited information, rather than the story being spoon-fed to them in the form of cutscenes or blurbs of text. Because of that, I am somewhat conflicted about writing my ideas here. For that effect to be truly powerful, there can be no definitive source of what the story actually means - it should be left up to the player to take what they want out of the game, if they choose to take anything at all.
Alternatively, I can go for something entirely goofy that barely makes sense. In that case, I would have no qualms about spoiling the story in my posts here.
Emotional Manipulation
I am often intrinsically motivated to work on or write about my game. Over the last week, however, I have been mentally divorced from doing so. I was very sick for some of that time, but over much of it, I was just playing video games instead - time not necessarily wasted, but time that I could have spent working on my own game instead.
It seems that the idea of storytelling is not profoundly interesting enough to hold my attention for very long. I think it's important to recognize that though! I've certainly had projects in the past that completely fizzled out because of my lack of interest.
I've come to somewhat of a realization that storytelling and game mechanics share a common function: they exist in order to make the player feel a certain way. That is how I would describe the function of nearly all forms of entertainment.
I tend to get along pretty well with most people, but social interaction is very exhausting to me. I have to put a lot of my mental energy into it, and I often have to go directly to bed afterward. It's not that I dislike the people that I hang out with - I cherish their friendships wholeheartedly! I just have to really try to be somewhat "normal". Even with that much effort, others often perceive me to be rather childish and silly. In any case, the "effort" I'm describing here can be thought of as a form of manipulation - I am actively trying to make people feel a certain way about me. Perhaps that skill can be translated into game design.
So how do I want the player to feel while playing this game? I guess the answer to that changes depending on various factors, but on average, I think the game should fluctuate between invoking "anxiety" and "relief", while exploring the entire gradient between the two.
If I can rely on pure game mechanics to invoke the feelings that I am targeting, then the actual story would serve no purpose. I guess this is the reason a game should be designed before assigning a story to it, and thus we end up with fantastic games like Super Mario Bros. or Overcooked!
I think I need to punt on storytelling until I build out a bit more of the game.
16.3 Encounter Design, Revisited
I've been struggling with the idea of building an actual level for this game. After all of my experimentation with procedural generation and hybrid genres, I landed on a concept that basically boils down to "WoW, but if the healer was the only player".
I thought that focusing on the storytelling could inform the level design of my game. Storytelling doesn't seem to come naturally to me, however, and thus I found a way to justify my lack of progress - I'll just invoke emotion through game mechanics instead!
I've been playing around in my tutorial level, and I gotta be honest: it sucks. The health bars appearing and disappearing from the HUD is jarring and confusing. The idea of the wisps being a physical entity in the world doesn't really make sense - my wife didn't really understand why she couldn't walk through them.
What should a real level even be? Obviously the goal is for the player to "survive" some encounter by using their healing abilities, but what exactly causes the incoming damage? Maybe I should brainstorm a bit.
- Regular old projectiles
- Fire on the ground
- Explosions with a radius
- Explosions with projectiles that shoot out from a single point
- "Ticking" damage, which deals a large amount of damage in small chunks over time
- Knock-backs, which can potentially push the player into an unfavorable position
- Dynamically-changing level layouts, which change how the player must navigate around the level
- Slowing effects, which make it more difficult for the player to move out of dangerous positions
- Radial blasts, like a fire-breathing dragon
- Stuns which prevent the player from moving or healing
- Multiple "phases", which change the pattern of an encounter's mechanics over time
Fight Duration
Something else that comes to mind is the duration of an encounter. If we're killing a boss with a fixed health pool, then the duration is obviously based on the amount of damage that we are able to deal, but we should be able to come up with a ballpark estimate of how long any given encounter should last.
In WoW, a simple dungeon boss can generally be killed in under a minute. In a raid environment, bosses tend to live for 2 or 3 minutes. More difficult boss encounters, however, can last over 10 minutes - in the worst cases lasting an excruciating 20 minutes! These longer encounters are incredibly painful to endure, and often result in players quitting the game out of frustration.
Nintendo games like Zelda or Mario often orient their boss encounters around the player's successful execution of a particular maneuver - often requiring the player to perform the maneuver successfully exactly 3 times. Be it throwing bombs inside the mouth of a dinosaur, or jumping on top of a turtle's head, Nintendo leans into precise execution rather than raw damage. The encounters last as long as it takes the player to execute the mechanics successfully, and the player can often avoid taking any damage at all. In Ocarina of Time, many of the boss rooms contain ways to replenish health, as well as any items that are required to defeat the boss. If the player already knows what they are doing, they can often defeat the boss in well under a minute. A new player, on the other hand, could easily survive for 5 minutes while they learn the boss's movements and behavior.
It seems that Nintendo wants the player to defeat the boss in one attempt, even if that means keeping the player alive as they learn the patterns of the encounter. Blizzard's approach with WoW is much different - they expect the player to die, regroup, and try again from the beginning.
2-3 minutes would probably be my preference, but it kind of just depends on what the player is doing during that time. The player should always have something to do - there should never be a moment longer than a couple of seconds where they are allowed to just stand in one spot without healing.
Complete and Utter Frustration
The Oxford dictionary defines the word "frustrated" as "feeling or expressing distress and annoyance, especially because of inability to change or achieve something". I hate how accurate of a description it is for how I'm feeling about this.
I haven't actually written any code in weeks - not that I haven't tried. I just don't know what to build anymore, so I write about it instead, which just further emphasizes to myself that I have no idea what I'm doing; no good ideas; no actual "vision".
Even in my time away from the computer, I ponder about the possibilities, continually seeking out options that might be "fun", without much success. I've complained to my friends about my lack of progress and inspiration, but none of them are particularly inclined to share ideas or join me in this overambitious endeavor. It seems I'm on my own on this one.
I haven't even published a new chapter of the devlog in several weeks, after pretty steadily publishing chapters every two weeks or so. Honestly, if it weren't for my investment in the devlog, this would definitely be the point where I shelved the project entirely. The point of the series was to pinpoint the exact reason why I abandon my projects. It turns out that it is due to a complete lack of motivation and inspiration, resulting in a lack of progress, which, in turn, results in a loss of morale.
However, I am desperate for this project to be different.
Let's face it: I've barely even started to build the "game" yet. At least 90% of what I've made so far has just been the engine to support my various prototypes. Because of that reality, I've been asking myself what would spark interest in my game as early in the development cycle as possible?
I thought I could whip up a boss fight, polish the crap out of it, and leave it on my website to generate interest while I built out the rest of the game. My lack of experience as a game designer has left me in this awkward position without any direction.
I've done my fair share of research on boss fights from various games, including my obvious role models. Most of the boss fights in WoW or Zelda are fantastical beasts with abilities unlike anything the player is capable of. This type of open-ended design is evidently too daunting for me.
I did come to an interesting realization though: all of the bosses in the Pokémon series are just regular people - they are not capable of anything that the players themselves are not also capable of. What if the bosses in my game were just other people that happened to have their own party of wisps? That provokes another obvious train of thought: what if players could fight each other?
Before I commit to the insane amount of scope creep invoked with building an online game with realtime combat, let's first examine the core idea. The central gameplay of the game (as I have intended it to be thus far) involves the player using active abilities to recover the health of their party members, while those party members passively attack the opponent. If the opponent also had party members, then the opponent would also have to heal their own party members. Theoretically, a battle between two sufficiently skilled players could go on forever - especially since neither one of them have much control over the offensive capabilities of their party.
I think I'm onto something here, but I can't quite work it out in my head. Even if the offensive abilities are passive, perhaps the player can strategically position themselves to increase the effectiveness of the attacks. For example, maybe one wisp causes an explosion of damage within a short radius every 10 seconds or so - in order for that ability to deal any damage to the opponent, the player would have to close the distance, putting themselves at risk.
I mentioned this concept to a couple of friends, and to my surprise, I actually go some suggestions! Some ideas involved the two contenders "racing", battling waves of enemies rather than fighting each other directly. I don't think I'm a fan of that idea, but I still appreciate the input.
The other ideas they provided were more interesting to me - they all basically boil down to giving the player abilities that fall more into the "utility" or "support" categories. While still not giving the player the ability to attack their opponent directly, the player could have an ability that increases the attack power of a specific party member. Similarly, the player could have an ability that stuns their opponent, preventing them from healing for a short duration, allowing the player to unleash as much damage on their opponent as possible during that time.
My head is flooded with all sorts of possibilities, which is certainly a good thing. My first impulse is to build some real-time networking capabilities into the engine, but I know that would be a mistake. First, I still need to build an encounter that proves out the concept.
Here We Go Again
I've copied the TutorialScene
to a new BattleScene
, made the room bigger, added some visual tiles, and got rid of the dumb physical entities representing the wisps. I went ahead and added the wisps directly to the player's party rather than relying on the triggers to do so.
I guess the first thing I should do is create a party for an enemy entity, and allow the player to see the health of the party members. I'll copy the player entity and just set it to Hostile
instead of Friendly
, and remove the Player
component entirely. I'll rearrange the wisps in the enemy's party just to make sure I'm not accidentally displaying the wrong entities.
I copied the giant block in the HudSystem
that controls the HUD party layout, and pasted it directly under the original. I added some specifiers to the original so that it only works with the Player
party and Friendly
wisps, while the copy only works with the Hostile
party and wisps. I negated the Y-axis of the HUD elements for the Hostile
wisps so that they show up at the top of the screen instead.
While the layout worked as I intended, I immediately noticed that I can actually heal the enemy's wisps! Furthermore, it looks like my own abilities are triggering the cooldowns for the enemy's abilities. The first issue was just a matter of adding a Friendly
qualifier to the PlayerControllerSystem
's health bar query; the second issue was resolved with the same qualifier added to ability button queries in the PlayerControllerSystem
and CooldownProgressSystem
.
Now I just need to add some behavior to the enemy unit - probably easier said than done. In order to do so, I really need to decide what the enemy should be doing at any given moment.
WoW enemies are generally pretty stupid - they just run up to you and smack you without any regard for what you might be doing. They don't intentionally avoid attacks or try to get out of unfavorable positions, they just kind of use their own abilities as often as they can. Bosses are a bit more intelligent, due to the fact that they are scripted to use abilities with careful timing and order. However, they also do not care about avoiding attacks, though they are immune to a handful of abilities that would work on regular enemies, such as stuns or knock-backs.
Historically, Zelda enemies are pretty similar, unable to avoid attacks, charging and attacking the player without any regard for their own safety. In more modern installments of the series, however, the enemies have become a bit more intelligent. In Skyward Sword, many enemies attempt to block attacks by holding their weapons and shields at specific angles - this was due to the control scheme of the game originally requiring the player to swing the Wii Remote in a direction to specifically counter the enemy's defenses. In Breath of the Wild and Tears of the Kingdom, monsters are capable of picking items up off the ground and throwing them back at the player - including bombs that the player threw at them in the first place! This type of artificial intelligence isn't super difficult to whip up, but games do it so rarely that it certainly catches the player off-guard when it happens.
The Pokémon games have remarkably simple logic behind their computer-powered "trainers", due to a couple of convenient factors:
- The battle system contains no movement requirements.
- The battle system is turn-based, so the logic can analyze the situation in discrete steps rather than continuously.
Even still, the games contain different "levels" of artificial intelligence. For example, only "bosses" will use items to negate status effects or heal. Even the most powerful bosses are often simply overpowered by the player, either by out-leveling them or by utilizing type-advantages.
Movement
The role of movement in my game is still unclear. Why would a computer-controlled player bother to move at all? There are currently no advantages to moving - no range requirements, no evasive actions - the only thing movement does is prevent you from casting a heal.
The most obvious solution to the movement problem is to add randomly-spawning collectibles. Perhaps each player has a pool of energy, and casting a spell requires spending a portion of that energy. We could spawn items that replenish energy when collected, but perhaps more importantly, prevents your opponent from replenishing their energy.
Another strong reason to move around is to avoid projectiles. I still haven't decided on the offensive capabilities of the wisps, but different projectile patterns seems like a decent idea. I experimented with this type of thing a while back, only to determine that I didn't like that the player could potentially avoid all of the projectiles, thus removing the need for healing at all. As with most things, I'm sure the right answer is somewhere in the middle, with some projectiles homing toward their target, and others on a straight path. In that case, it would be important to somehow distinguish which projectiles are avoidable so that the players (be them human- or computer-controlled) can attempt to evade them.
Where Was I?
Sometimes work gets a little crazy. This has been one of those weeks, and I've been working really long days, despite having a holiday on Monday. I'm not trying to complain, but it's certainly prevented me from making any progress on the game.
Still, when I can spare a moment of brain power, I tend to think about the current state of the game and where I'd like to take it. One thought has been more prominent than the rest: perhaps I'm too stuck on the idea of it being a "healing" game.
Don't get me wrong, the healing mechanics are clearly fun. I tend to open up the game and just keep my health bars alive while playing cat-and-mouse with the dumb AI enemy. It's an oddly satisfying experience, but there's just no purpose.
The purpose of healing in MMORPGs is typically to help your group overcome difficult encounters, which reward you with more powerful gear, which in turn helps you defeat even more difficult encounters. This core gameplay loop transcends the "role" of any one player - healers, damage dealers, and tanks alike all participate in it. It doesn't matter which role you play, the goal is to defeat the encounter, which simply cannot be achieved without killing the boss.
In fact, the other games that I've been citing utilize that same basic requirement for progression - you cannot merely defeat the opponent by simply surviving, you must actively harm them. Killing enemies has been a fundamental aspect of countless video games for decades. Not all video games, mind you, but certainly the ones that require some form of healing!
And so, in my seemingly endless stream of thoughts, I decided to consider the idea of abilities that deal damage to an opposing entity. In the game's current state, not all that much would change - I guess it would come down to how the damaging abilities behaved, leading me right back to the thought of some attacks fixating on a target and others being completely avoidable.
This naturally led me back to reconsidering movement as a mechanic. Originally, I implemented movement into the game to fill in the gameplay "gaps" - sometimes the player wasn't required to heal at all, and other times they trivially topped off the health bars, so I decided that giving them obstacles to avoid would round out the play style (because that's how WoW does it). As I mentioned before, the Pokémon battle system doesn't rely on movement mechanics at all - in fact, many turn-based RPGs don't! Classical turn-based RPGs began to evolve more and more real-time mechanics (looking at you, Final Fantasy), so who is to say I can't build a movement-free battle system that is completely real-time in nature?
The thought of Final Fantasy provoked another thought. Many of the old Final Fantasy games have a neat nuance to them, in which you can cast any ability on any target - you can heal your opponents, or even kill your own party members! Even more interesting is that this enables other cool interactions: for example, an enemy inflicted with the "zombie" status effect can be damaged by healing them! I always thought that was a really cool idea, but it rarely made any difference in general gameplay.
This concept gave me an interesting idea: what if the health bars of both parties were displayed on the screen, and the player had to manually select which health bar they wanted to cast an ability on, friend or foe alike? Generally, they would want to cast offensive abilities on their opponents, and defensive abilities on their own party. However, if the game were fast-paced enough, they could conceivably screw up and help their opponent - or even kill themselves!
Rather than diving straight into the code, I decided to whip up a mockup of what this might look like.
I used a canvas size of 240x160 (the resolution of the Game Boy Advance), which resulted in the text below the ability buttons being a bit cramped at the bottom, since I had to make the words large enough to be legible. I also scribbled a few somewhat random silhouettes to represent the "monsters" that are battling in the arena. I kind of just imagine them animating according to what is going on with the health bars, but not really being particularly important to the actual gameplay.
The blue and orange colors are used to represent the separate teams in an attempt to be friendly to players with colorblindness. You can see similar contrasting colors in games like Rocket League. The spacial separation of the health bars might be enough to convey the idea of separate teams, but the background colors are a nice touch, and I may very well utilize the contrasting colors in the eventual marketing material for the game.
The ability buttons are rainbow-colored at the moment, though I envision them eventually displaying more identifying icons instead. In any case, the separate colors seem to convey uniqueness, as well as separation from the health bars.
I've shown the mockup to my family and a few friends, and the feedback is surprisingly positive, after these nine long months of borderline passive-aggressive disapproval. They're just trying not to discourage me, but I prefer to be told outright when an idea sucks rather than a mildly forced complement ("Oh that's neat" or "keep going!").
I think the real win here is that I have a somewhat cohesive vision of the gameplay, to such an extent that I can whip up a visual representation of the idea. It's not really that my previous prototypes completely sucked, rather, they were just incomplete, and so people didn't know how to respond to them.
There are still a few things I don't like about the mockup though:
- Obviously the sizing of the words is problematic. I can increase the size of the canvas to compensate, but at a certain point I will lose the pixel art aesthetic of the lettering.
- Without any movement mechanics in the game, I've also completely designed the scene to be usable from a touch screen. With 15 selectable objects on the screen, everything feels a bit cramped.
- I'm not entirely sold on the idea of the combat being between monsters. I do like the idea of showing the battle playing out in the area, but I didn't know what the combatants actually were, so I just scribbled some characters.
The old RPGs that this type of battle scene is inspired by tend to stack menu options vertically so that word length is less of an issue. Those games had the benefit of using a controller for selecting those options rather than a bulky finger, so they could convey the currently-selected options with a simple arrow icon instead of a large button. On the other hand, the turn-based combat allowed the player to take their time carefully selecting from the options.
In the current mockup, each letter is 4x5, with the exception of the "W" character, which is 5 pixels wide. The spacing between each letter is only one pixel, so the word "BARRIER", consisting of 7 characters, is 34 pixels wide. Each ability button, on the other hand, is only 24 pixels wide, causing this relatively average word to be much wider than the button itself.
If I increase the size of each button to 32 pixels, then the 7-character word still outsizes the button by just 2 pixels - one on each side. If I increase the size of each button to 48 pixels, then the word fits nicely, and I can actually support words up to 9 characters without any overflow. Similarly, a button of 64 pixels would support words up to 13 characters. Just as a point of reference, the original Pokémon games only supported names up to 12 characters long. As a middle ground, I could size the buttons to be 56 pixels, which would support words with 11 characters.
Even with the buttons and text sized appropriately in respect to one another, I still have to choose a target resolution for the game such that the buttons are usable on the physical screen and the text is readable when scaled with such a resolution. I know that I will need at least 5 health bars worth of vertical space - at 48 pixels per box, that would be 240 pixels with no padding. The Nintendo 3DS contains two screens that are each only 240 pixels in height - I couldn't even fit my health bars on a 3DS with this design! Luckily, I'm targeting modern phones with much higher resolutions - even the iPhone SE has 750 pixels to work with in a landscape orientation.
I ended up creating several iterations of the mockup, each using different canvas and button sizes, while keeping the size of the text the same. The first iteration used a 640x384 canvas, and each ability button was a 64 pixel square.
When I opened the image on my phone (in landscape mode, of course), the text was way too small to read! My options were pretty obvious: either make the text larger, or make the canvas smaller. In order to maintain the pixelated aesthetic, I opted for the latter, and made another iteration with a canvas size of 480x288, using 48x48 buttons.
While definitely an improvement, I decided to check how it would look on my daughter's iPhone SE, which has a much smaller screen than my iPhone 13 Pro. I could still read the text, but I had to strain my eyes a bit. I asked her to read the text and watched her move the phone closer to her face - not a good sign. I decided to drop the canvas size once more, down to 400x240 (the same effective resolution as the Nintendo 3DS), utilizing 40x40 buttons.
I think that's about as good as it's going to get. It looks like I'll be able to fit up to 9 characters under each ability, so I guess I'll just have to come up with short names. I actually have plenty of horizontal space to utilize on these phones; I've just chosen to abide by the 5:3 aspect ratio because my thumbs felt the most comfortable that way.
As a part of this exercise, I also managed to iterate a bit on the font - most notably, I created versions of the "M", "Q", and "W" characters that fit within the same 4-pixel width as the other characters, so that I can guarantee the 9-character spacing under each button.
Truth be told, the original mockup had the most readable text by a long shot, but I just can't justify committing to ability names with a maximum of 5 characters. I might iterate a bit more on this later, but honestly I'm just kind of tired of doing this for now.
What was this chapter even supposed to be about? Encounter design, huh... Well I can confidently say I've redesigned the encounters alright. None of this is actually implemented yet, so I guess let's do that.
The New Battle Scene
I had to absolutely gut the BattleScene
. I got rid of all of the systems that had anything to do with movement or physics, and removed the visualization of the player and the enemy - they are logically still separate entities that own their own parties, but they do not have a position on the screen and cannot move around.
I also went ahead and updated the font. I exported the new font image as a PNG instead of a BMP so that I can easily support partial transparency later, and updated the shaders to check the alpha channel instead of the red channel. I also had to update a few shader parameters to account for the change in the image's size. I additionally added a new block character that the feature renderers can default to, in the event that I try to use an unsupported character, rather than just crashing the application.
I've gotten mixed feedback on the font itself, but I really like it so I'm just going to roll with it for now.
A lot of elements have moved around, changed positions, and changed colors, but the actual code isn't all that interesting. I added the orange and blue backgrounds to each of the parties, and hid the opponent's abilities from the player. I also updated Alfredo and Pesto to create windows with a 5:3 aspect ratio.
I converted the keybind text into the ability name label, added a name
field to the Spell
struct, and came up with some names for the existing spells.
Perhaps the most interesting to the actual gameplay: I reverted the change to make the opponent's health bars untargetable - the player can now freely heal the enemy if they so choose.
Lastly, I added a backgroundColor
to the ProgressableFeature
so that it's not always just black (the mockup actually uses dark blue and dark brown for the health bars, depending on the team).
There are a handful of things that are unaccounted for in the mockup, or simply can't be conveyed with visuals alone:
EffectTracker
progress bars, such as our heal-over-time effect.- The cast bar. Should the player still be allowed to cancel casts? How would they do so without movement?
- Since I seem to be targeting mobile platforms again, I need to come up with a new control scheme without keybinds.
- Ability tooltips - do they even make sense on mobile?
In any case, it's definitely looking pretty good! It's kind of sketchy, but you can take a look at it here.
In regard to the EffectTracker
s, I realized that each health bar could theoretically have a maximum of 10 effects to track simultaneously (5 possible effects from each player). This is an incredibly unlikely situation, but I want to prepare for the worst case. I decided that I will stack the bars, overlaying the affected health bar such that each effect bar is 3 pixels tall. Positive effects (presumably triggered by the player) will stack up from the bottom of the health bar, while negative effects will stack down from the top. With 10 simultaneous effects, the effects would cover up a total of 30 pixels, leaving 10 pixels of space for the health bar to remain visible. I also decided to add a 1-pixel margin between the edge of the health bar and the effects, so that the effect color doesn't blend into the background. The end result is an 8-pixel space in the middle to see the health bar - hopefully that's enough.
I also decided on an input method that should be more mobile-friendly: the player must first select a target by tapping on the desired health bar, then they must hold down an ability button to cast it. If they release the button before the cast is finished, then the cast will be canceled. I imagine a big empty circle appearing underneath the player's finger when they select an ability. The circle will fill up from its center according to its cast time.
The concept actually highlights something peculiar about my game: it uses a very small resolution for its camera, but technically I can draw fractions of a "pixel" just fine - the camera isn't actually made up of pixels, just arbitrary units. The current state of the ability cooldowns actually highlights this inconsistency: as the ability's cooldown recovers, the bar fills up very continuously - not in a "choppy" way according to the "pixels". The resolution of the actual game is still very high! This also means I can draw circles that aren't completely pixelated. It will be interesting to see if anyone notices this inconsistency without having read this first. Unfortunately I can't actually draw this concept in the mockup because I'm using an app that is specifically for pixel art!
I came to the conclusion that detailed ability tooltips don't provide much value in a short real-time battle. Hopefully I can come up with a way to sufficiently describe the abilities outside of the battle scene. However, I decided that I would add a little icon to each ability to describe its "category", so to speak. I came up with 4 possible categories (though I'm sure I'll come up with more later): offensive, defensive, supportive, and weakening. Offensive abilities are those that actively deal damage, and as such are notated with a little sword. Defensive abilities recover health or prevent the loss of health, and are marked with a heart. Supportive abilities are intended to increase the capabilities of a unit, and have a plus sign. Obviously, weakening abilities are the opposite of supportive, and are denoted by a minus sign.
I also decided to make the background color of the abilities match the player's current party, so that they know which health bars they should be healing.
Effect Stacking
I don't think I'm too far off from achieving something like what I have in the mockup, but I've got to be honest: I don't love the mockup. It's still very cluttered and obstructs the actual health bar quite a bit.
In any case, I'll go ahead and whip this up. The first thing that I need to do is enable transparent background colors for the ProgressableFeature
. Obviously I'll need to change the backgroundColor
to a glm::vec4
, but there's a little bit of extra work required in the feature renderers. In the Metal implementation, I needed to add alpha blending to the pipeline descriptor. If I was properly ordering my draw calls by their Z-positions, then that's all I would need to do. Since I totally don't do that, the bars occasionally result in strange artifacts in which the health bar itself has "holes" in it, because the depth buffer was already written to by the draw call of the effect bar. I'm going to take a shortcut here and do it the "wrong" way by simply discarding any fragments that have an alpha value of zero.
renderers/metal/src/features/ProgressFeatureRenderer.cpp snippet
float4 fragment fragmentProgress(VertexOutput in [[stage_in]],
const constant MetalProgressFragmentFeature& feature [[buffer(0)]]) {
float4 result = in.x < feature.progress ? float4(feature.color, 1.0) : feature.backgroundColor;
if (result.a > 0.0) {
return result;
}
metal::discard_fragment();
}
renderers/opengl/src/features/ProgressFeatureRenderer.cpp snippet
void main() {
outColor = x < progress ? vec4(color, 1.0) : backgroundColor;
if (outColor.a <= 0.0) {
discard;
}
}
I refactored the EffectSystem
to first collect the effects by the health bar that they should attach to. I then iterate over the health bars and sort the effects by their current progress, such that the highest remaining percentage always appears on the outer part of the health bar.
I added a new Category
enum, containing Offense
, Defense
, Support
, and Control
values (I didn't really like the word "weaken"). I updated the Effect
class to require a category, and used that information in the EffectSystem
so that all Offense
and Control
effects (that is to say, the "negative" effects) appear at the top of the health bar, while Defense
and Support
effects ("positive" effects) remain at the bottom. I also added a color to the Effect
class so that I could more easily tell the effects apart, and updated the SpellDatabase
to provide the same colors as the abilities that applied them.
linguine/src/systems/EffectSystem.cpp snippet
void EffectSystem::update(float deltaTime) {
auto effectsByHealthBar = std::unordered_map<uint64_t, std::vector<uint64_t>>();
findEntities<EffectTracker, Progressable>()->each([this, &effectsByHealthBar](const Entity& entity) {
auto effectTracker = entity.get<EffectTracker>();
auto progressable = entity.get<Progressable>();
progressable->feature->progress = 1.0f - effectTracker->timeSinceApplication / effectTracker->effect.getDuration();
auto target = getEntityById(effectTracker->targetId);
auto hudDetails = target->get<HudDetails>();
auto it = effectsByHealthBar.find(hudDetails->healthBarId);
if (it == effectsByHealthBar.end()) {
effectsByHealthBar[hudDetails->healthBarId] = { entity.getId() };
} else {
it->second.push_back(entity.getId());
}
});
for (const auto& healthBarEffects : effectsByHealthBar) {
auto healthBarEntity = getEntityById(healthBarEffects.first);
auto effects = healthBarEffects.second;
std::sort(effects.begin(), effects.end(), [this](uint64_t a, uint64_t b) {
auto entityA = getEntityById(a);
auto entityB = getEntityById(b);
auto progressableA = entityA->get<Progressable>();
auto progressableB = entityB->get<Progressable>();
return progressableA->feature->progress > progressableB->feature->progress;
});
auto offenseCount = 0;
auto defenseCount = 0;
for (const auto& effectEntityId : effects) {
auto effectEntity = getEntityById(effectEntityId);
auto progressable = effectEntity->get<Progressable>();
progressable->renderable->setEnabled(healthBarEntity->has<HealthBar>());
auto healthBarTransform = healthBarEntity->get<Transform>();
auto transform = effectEntity->get<Transform>();
transform->position.x = healthBarTransform->position.x;
auto effectTracker = effectEntity->get<EffectTracker>();
switch (effectTracker->effect.getCategory()) {
case Offense:
case Control:
transform->position.y = healthBarTransform->position.y
+ healthBarTransform->scale.y / 2.0f
- transform->scale.y / 2.0f
- 2.0f * static_cast<float>(offenseCount++)
- 1.0f;
break;
case Defense:
case Support:
transform->position.y = healthBarTransform->position.y
- healthBarTransform->scale.y / 2.0f
+ transform->scale.y / 2.0f
+ 2.0f * static_cast<float>(defenseCount++)
+ 1.0f;
break;
default:
throw std::runtime_error("Unsupported Effect category");
}
}
}
}
In order to test this behavior, I created a new DamageOverTime
subclass (shamelessly copied from the HealOverTime
class) and created a new "scorch" ability that deals damage over 18 seconds. First I made sure the harmful scorch effect was at the top and the helpful siphon ability was at the bottom. Then I changed the DamageOverTime
class to be in the Support
category, and made sure both effects appeared at the bottom, with their bars switching positions such that the longer one was at the bottom. Finally, I switched both abilities to be in the Control
category and repeated the test to make sure the bars appeared at the top instead, with the longer one in the top-most position. After verifying the behavior, I switched scorch back to the Offense
category, and siphon back to Defense
.
You can check out the result here.
Spell-Casting Redux
Building the new spell-casting system is a little trickier than tinkering with progress bar positioning. I'll start by creating a new CircleFeatureRenderer
based on the ColoredFeatureRenderer
. The new CircleFeature
will just have a modelMatrix
and a color
- all circles will be drawn using the Quad
mesh internally. I'll have to go ahead and add a new Circle
component to keep track of the Renderable
, and update the TransformationSystem
to update the modelMatrix
as needed.
Other than that, it's really just a matter of discarding any fragments that are further away from the center than a pre-defined radius. Since the quad mesh's coordinates range from -0.5
to 0.5
, I'll need to check the squared length of the UV against 0.5 squared (which is 0.25). OpenGL doesn't seem to have a function to get the squared length of a vector, and I'm very lazy, so I'll just do it the bad way instead.
renderers/metal/src/features/CircleFeatureRenderer.cpp snippet
float4 fragment fragmentColored(VertexOutput in [[stage_in]]) {
float d2 = metal::length_squared(in.uv.xy);
if (d2 > 0.5 * 0.5) {
metal::discard_fragment();
}
return float4(in.color, 1.0);
}
renderers/opengl/src/features/CircleFeatureRenderer.cpp snippet
This technique works, but results in some unpleasant jagged edges. We can smooth the edges by using some more expensive techniques - check out this blog post for some explanations.
renderers/metal/src/features/CircleFeatureRenderer.cpp snippet
float4 fragment fragmentColored(VertexOutput in [[stage_in]]) {
float distance = metal::length(in.uv);
float delta = metal::fwidth(distance);
float alpha = 1.0 - metal::smoothstep(0.5 - delta, 0.5, distance);
return float4(in.color, alpha);
}
renderers/opengl/src/features/CircleFeatureRenderer.cpp snippet
void main() {
float distance = length(uv);
float delta = fwidth(distance);
float alpha = 1.0 - smoothstep(0.5 - delta, 0.5, distance);
outColor = vec4(color, alpha);
}
I spent quite a bit of time experimenting with other ways to draw circles - namely, drawing a triangle fan with so many triangles that the result looks round. That solution requires anti-aliasing of some sort, which doesn't work well with the otherwise pixel-centric aesthetic, so I trashed the idea and went with the above smoothstep
solution instead.
The WebGL version had a bit of dark artifacts around the circle due to the way the semi-transparent edges blend into the background. This was resolved by setting the blending function of the alpha channel to "max" instead of "add". I'll also go ahead and explicitly disable any automatic anti-aliasing that the browser might do by updating the EmscriptenWebGLContextAttributes
in Pesto.
renderers/metal/src/features/CircleFeatureRenderer.cpp snippet
renderers/opengl/src/features/CircleFeatureRenderer.cpp snippet
While testing this, I realized that Scampi's build has a few issues that don't seem to be my fault. I decided to update Xcode and ios-deploy
(via Homebrew) just to rule out any incompatibility issues. Whatever compiler settings that the new Xcode is using was complaining about some of my syntax choices in the GestureRecognitionSystem
involving optionals. I changed my calls to std::optional<>
's value()
method to using a dereference operator instead (specifically, *entityId
instead of entityId.value()
). That change seemed to make everything happy, and I can run the game on my phone once again.
With the ability to draw pretty circles, I can finally implement the feature that put me down that rabbit hole: the new spell-casting system!
I spent more time on it than I thought I would - probably because I'm rather exhausted today. I started off by adding a new Selected
component to keep track of which HealthBar
was most recently Tapped
. This is intended to replace the current "hover" functionality, which obviously won't work on mobile devices. I updated the PlayerControllerSystem
with the necessary changes and moved right along.
Next, I converted our Progressable
cast bar entity to a Circle
instead, and updated the CastSystem
to "inflate" the circle's Transform
based on the current progress of the cast. Currently, there's no visual indicator of how big the circle is supposed to get before the cast is finished, but it is oddly satisfying nonetheless!
Now for the hard part. I preemptively modified the GestureRecognitionSystem
to add a new Pressed
component to any entity that is currently pressed (contrary to Tapped
or LongPressed
, which only exist for a single frame). This will allow us to cast spells by holding down the desired ability button. Conversely, it will allow us to cancel our current cast by releasing the ability button before the cast has completed.
It took a bit of tinkering in the PlayerControllerSystem
, but I finally settled on a solution that I'm happy with. There's not much to say about the code itself - I refactored the GlobalCooldown
component to keep track of the amount of time remaining, rather than the time that has elapsed; and I added an isFirstFrame
flag to the Pressed
component, so that spells don't get repeatedly casted simply by holding down the button. I also ripped the logic out of the CastSystem
that was responsible for checking if the player was currently moving.
The most complicated part was making sure the global cooldown didn't reset for instant-cast spells. My logic to detect if a spell had been "canceled" was falsely triggering for instant-casts, but I managed to resolve it by checking that an ability is currently being casted, in addition to the cancellation conditional. Just take a look at the code if you're interested.
linguine/src/systems/CastSystem.cpp
#include "CastSystem.h"
#include <glm/gtx/norm.hpp>
#include "components/Ability.h"
#include "components/Cast.h"
#include "components/Circle.h"
#include "components/Transform.h"
namespace linguine {
void CastSystem::update(float deltaTime) {
findEntities<Cast, Transform, Circle>()->each([this, deltaTime](Entity& entity) {
auto cast = entity.get<Cast>();
auto circle = entity.get<Circle>();
if (cast->abilityEntityId) {
auto transform = entity.get<Transform>();
auto abilityEntity = getEntityById(*cast->abilityEntityId);
auto ability = abilityEntity->get<Ability>();
cast->elapsed += deltaTime;
if (cast->elapsed >= ability->spell.castTime) {
auto target = getEntityById(*cast->targetEntityId);
ability->spell.action->execute(*target);
ability->remainingCooldown = ability->spell.cooldown;
cast->abilityEntityId = {};
cast->targetEntityId = {};
cast->elapsed = 0.0f;
} else {
auto progress = cast->elapsed / ability->spell.castTime;
transform->scale = { 40.0f * progress, 40.0f * progress, 0.0f };
}
}
circle->renderable->setEnabled(cast->abilityEntityId.has_value());
});
}
} // namespace linguine
linguine/src/systems/PlayerControllerSystem.cpp
#include "PlayerControllerSystem.h"
#include "components/Ability.h"
#include "components/AbilityButton.h"
#include "components/Cast.h"
#include "components/Drawable.h"
#include "components/Friendly.h"
#include "components/GlobalCooldown.h"
#include "components/HealthBar.h"
#include "components/Pressed.h"
#include "components/Selected.h"
#include "components/Tapped.h"
#include "components/TargetIndicator.h"
#include "components/Transform.h"
namespace linguine {
void PlayerControllerSystem::update(float deltaTime) {
findEntities<TargetIndicator, Drawable, Transform>()->each([this](const Entity& entity) {
auto targetIndicatorDrawable = entity.get<Drawable>();
auto targetIndicatorTransform = entity.get<Transform>();
findEntities<HealthBar, Tapped, Transform>()->each([&targetIndicatorDrawable, &targetIndicatorTransform, this](Entity& healthBarEntity) {
findEntities<HealthBar, Selected>()->each([&healthBarEntity](Entity& selectedHealthBarEntity) {
if (healthBarEntity.getId() != selectedHealthBarEntity.getId()) {
selectedHealthBarEntity.remove<Selected>();
}
});
if (!healthBarEntity.has<Selected>()) {
healthBarEntity.add<Selected>();
}
auto healthBarTransform = healthBarEntity.get<Transform>();
targetIndicatorTransform->position = healthBarTransform->position;
targetIndicatorDrawable->renderable->setEnabled(true);
});
});
findEntities<HealthBar, Selected>()->each([this](const Entity& healthBarEntity) {
auto healthBar = healthBarEntity.get<HealthBar>();
findEntities<Cast>()->each([this, &healthBar](const Entity& castEntity) {
auto cast = castEntity.get<Cast>();
auto isCanceled = true;
findEntities<Friendly, AbilityButton, Pressed>()->each([this, &cast, &healthBar, &isCanceled](const Entity& abilityButtonEntity) {
auto abilityButton = abilityButtonEntity.get<AbilityButton>();
auto abilityEntity = getEntityById(abilityButton->abilityEntityId);
auto ability = abilityEntity->get<Ability>();
auto pressed = abilityButtonEntity.get<Pressed>();
if (!cast->abilityEntityId && ability->remainingCooldown <= 0.0f && pressed->isFirstFrame) {
findEntities<GlobalCooldown>()->each([&cast, &abilityButton, &healthBar, &isCanceled](const Entity& entity) {
auto globalCooldown = entity.get<GlobalCooldown>();
if (globalCooldown->remaining <= 0.0f) {
cast->abilityEntityId = abilityButton->abilityEntityId;
cast->targetEntityId = healthBar->entityId;
globalCooldown->remaining = globalCooldown->total;
isCanceled = false;
}
});
} else if (cast->abilityEntityId == abilityEntity->getId()) {
isCanceled = false;
}
});
if (cast->abilityEntityId && isCanceled) {
cast->abilityEntityId = {};
cast->targetEntityId = {};
cast->elapsed = 0.0f;
findEntities<GlobalCooldown>()->each([](const Entity& entity) {
auto globalCooldown = entity.get<GlobalCooldown>();
globalCooldown->remaining = 0.0f;
});
}
});
});
}
} // namespace linguine
After playing around with the app on my phone, I decided to make a couple of adjustments to Scampi's ViewController
. I'd like the game to always be in landscape mode, and I'd like to hide the home "bar" at the bottom of the screen. After a bit of Googling, I found the right combination of things to achieve my goals.
scampi/src/uikit/ScampiViewController.mm snippet
-(BOOL)prefersHomeIndicatorAutoHidden{
return YES;
}
- (void)viewDidLoad
{
[super viewDidLoad];
[[UIDevice currentDevice] setValue:@(UIInterfaceOrientationLandscapeLeft)
forKey:@"orientation"];
[UINavigationController attemptRotationToDeviceOrientation];
[self setNeedsUpdateOfHomeIndicatorAutoHidden];
...
}
Check it out here. It still needs more work, but it's definitely coming along!
16.4 Enemy AI
With the basic mechanics of the game in place, I need to focus a bit on redesigning the enemy's AI. There is no more movement, so the EnemyTargetingSystem
is completely useless. There are also no more automatic projectile attacks, so the EnemyAttackSystem
also serves no purpose.
Just like the player, the enemy will have a set of abilities available to it based on its current party makeup. The enemy AI must not only choose which ability to cast, but also which unit to cast the ability on.
I mentioned before that this is a little more complex than a traditional turn-based RPG due to its realtime nature. We do, however, have concepts that throttle the realtime aspects of the game, including spell cast times, spell cooldowns, and the "global" cooldown. This leads to some convenient constraints for our enemy AI:
- Only one ability may be cast at a time
- Any given ability may not be cast while it is on cooldown
- No abilities may be cast during the global cooldown
These constraints lead to a logical flow in which, each frame, the enemy AI determines which abilities, if any, may be cast at that time. With the set of available abilities, the AI can analyze the current situation and rank its options. I'll make a couple of concessions for now to make the logic easier to implement:
- The enemy AI must choose an ability if one is available
- The enemy AI may not cancel spell casts
These rules may end up making the AI easy to manipulate, but that's not really that big of a deal.
Splitting the Contenders
Up to this point, we've only had a single GlobalCooldown
entity, and more recently, a single Cast
entity. I'll start off by creating separate entities for the player and enemy, which I will add the Friendly
and Hostile
components to, respectively.
This change required some updates to the CastSystem
so that all Casts
in the scene were updated appropriately, but only the Friendly
cast updated the visible circle indicator.
linguine/src/systems/CastSystem.cpp
#include "CastSystem.h"
#include <glm/gtx/norm.hpp>
#include "components/Ability.h"
#include "components/Cast.h"
#include "components/Circle.h"
#include "components/Friendly.h"
#include "components/Transform.h"
namespace linguine {
void CastSystem::update(float deltaTime) {
findEntities<Cast>()->each([this, deltaTime](Entity& entity) {
auto cast = entity.get<Cast>();
if (cast->abilityEntityId) {
auto abilityEntity = getEntityById(*cast->abilityEntityId);
auto ability = abilityEntity->get<Ability>();
cast->elapsed += deltaTime;
if (cast->elapsed >= ability->spell.castTime) {
auto target = getEntityById(*cast->targetEntityId);
ability->spell.action->execute(*target);
ability->remainingCooldown = ability->spell.cooldown;
cast->abilityEntityId = {};
cast->targetEntityId = {};
cast->elapsed = 0.0f;
}
}
});
findEntities<Cast, Friendly, Transform, Circle>()->each([this](Entity& entity) {
auto cast = entity.get<Cast>();
auto circle = entity.get<Circle>();
if (cast->abilityEntityId) {
auto transform = entity.get<Transform>();
auto abilityEntity = getEntityById(*cast->abilityEntityId);
auto ability = abilityEntity->get<Ability>();
auto progress = cast->elapsed / ability->spell.castTime;
transform->scale = { 40.0f * progress, 40.0f * progress, 0.0f };
}
circle->renderable->setEnabled(cast->abilityEntityId.has_value());
});
}
} // namespace linguine
As far as I can tell, the CooldownProgressSystem
is already correct, since it updates all Ability
and GlobalCooldown
entities, but only updates Friendly
AbilityButton
s.
Whipping up the AI
Obviously I'll start by creating a new AIControllerSystem
. This system should be the counterpart of the PlayerControllerSystem
, performing the same responsibilities, just without any player input.
linguine/src/systems/AIControllerSystem.cpp
#include "AIControllerSystem.h"
#include "components/Ability.h"
#include "components/Cast.h"
#include "components/Friendly.h"
#include "components/GlobalCooldown.h"
#include "components/Health.h"
#include "components/Hostile.h"
namespace linguine {
void AIControllerSystem::update(float deltaTime) {
findEntities<Cast, Hostile>()->each([this](const Entity& castEntity) {
auto cast = castEntity.get<Cast>();
if (!cast->abilityEntityId) {
findEntities<GlobalCooldown, Hostile>()->each([this, &cast](const Entity& gcdEntity) {
auto globalCooldown = gcdEntity.get<GlobalCooldown>();
if (globalCooldown->remaining <= 0.0f) {
auto availableAbilities = std::vector<Entity>();
findEntities<Ability, Hostile>()->each([&availableAbilities](const Entity& abilityEntity) {
auto ability = abilityEntity.get<Ability>();
if (ability->remainingCooldown <= 0.0f) {
availableAbilities.push_back(abilityEntity);
}
});
if (!availableAbilities.empty()) {
auto shouldBeDefensive = false;
findEntities<Health, Hostile>()->each([&shouldBeDefensive](const Entity& healthEntity) {
auto health = healthEntity.get<Health>();
if (static_cast<float>(health->current) / static_cast<float>(health->max) < 0.5f) {
shouldBeDefensive = true;
}
});
auto defensiveAbilities = std::vector<Entity>();
auto otherAbilities = std::vector<Entity>();
for (const auto& abilityEntity : availableAbilities) {
auto ability = abilityEntity.get<Ability>();
if (ability->spell.category == Category::Defense) {
defensiveAbilities.push_back(abilityEntity);
} else {
otherAbilities.push_back(abilityEntity);
}
}
std::optional<uint64_t> chosenAbilityId = {};
if (shouldBeDefensive && !defensiveAbilities.empty()) {
auto randomAbility = std::uniform_int_distribution<>(0, static_cast<int>(defensiveAbilities.size() - 1));
const auto index = randomAbility(_random);
chosenAbilityId = defensiveAbilities[index].getId();
} else if (!otherAbilities.empty()) {
auto randomAbility = std::uniform_int_distribution<>(0, static_cast<int>(otherAbilities.size() - 1));
const auto index = randomAbility(_random);
chosenAbilityId = otherAbilities[index].getId();
}
if (chosenAbilityId) {
auto abilityEntity = getEntityById(*chosenAbilityId);
auto ability = abilityEntity->get<Ability>();
std::optional<uint64_t> chosenTargetId = {};
switch (ability->spell.category) {
case Category::Offense: {
auto lowestHp = 1.0f;
findEntities<Health, Friendly>()->each([&chosenTargetId, &lowestHp](const Entity& targetEntity) {
auto health = targetEntity.get<Health>();
auto hp = static_cast<float>(health->current) / static_cast<float>(health->max);
if (hp > 0.0f && hp <= lowestHp) {
lowestHp = hp;
chosenTargetId = targetEntity.getId();
}
});
break;
}
case Category::Control: {
auto highestHp = 0.0f;
findEntities<Health, Friendly>()->each([&chosenTargetId, &highestHp](const Entity& targetEntity) {
auto health = targetEntity.get<Health>();
auto hp = static_cast<float>(health->current) / static_cast<float>(health->max);
if (hp > 0.0f && hp > highestHp) {
highestHp = hp;
chosenTargetId = targetEntity.getId();
}
});
break;
}
case Category::Defense: {
auto lowestHp = 1.0f;
findEntities<Health, Hostile>()->each([&chosenTargetId, &lowestHp](const Entity& targetEntity) {
auto health = targetEntity.get<Health>();
auto hp = static_cast<float>(health->current) / static_cast<float>(health->max);
if (hp > 0.0f && hp <= lowestHp) {
lowestHp = hp;
chosenTargetId = targetEntity.getId();
}
});
break;
}
case Category::Support: {
auto highestHp = 0.0f;
findEntities<Health, Hostile>()->each([&chosenTargetId, &highestHp](const Entity& targetEntity) {
auto health = targetEntity.get<Health>();
auto hp = static_cast<float>(health->current) / static_cast<float>(health->max);
if (hp > 0.0f && hp > highestHp) {
highestHp = hp;
chosenTargetId = targetEntity.getId();
}
});
break;
}
}
if (chosenTargetId) {
cast->abilityEntityId = *chosenAbilityId;
cast->targetEntityId = *chosenTargetId;
globalCooldown->remaining = globalCooldown->total;
}
}
}
}
});
}
});
}
} // namespace linguine
While testing this, I immediately realized that the CooldownProgressSystem
was not working as I thought it was. The logic to update the Progressable
s for ability buttons was applying for all GlobalCooldown
s in the scene. I had to split out the progression of the GlobalCooldown
s into its own iterator, and then change the visual update iterator to only query the Friendly
GlobalCooldown
.
linguine/src/systems/CooldownProgressSystem.cpp snippet
findEntities<GlobalCooldown>()->each([deltaTime](const Entity& entity) {
auto globalCooldown = entity.get<GlobalCooldown>();
globalCooldown->remaining = glm::max(0.0f, globalCooldown->remaining - deltaTime);
});
findEntities<GlobalCooldown, Friendly>()->each([this](const Entity& entity) {
auto globalCooldown = entity.get<GlobalCooldown>();
findEntities<Friendly, AbilityButton, Progressable>()->each([this, &globalCooldown](const Entity& entity) {
...
});
});
The general idea behind the initial AI logic is for it to prefer defensive abilities if any of its party members are below 50% HP. This iteration has a few rather noticeable issues though:
- Party members never actually "die". While the AI won't actively start a cast on a party member that is at 0 HP, any pending casts will still finish if the party member died mid-cast. Heal-over-time effects also tend to bring the entities back from the dead, allowing the AI to cast heals again.
- If any of the AI's party members are already dead, they spam defensive abilities on the remaining party members because technically the dead entity is below 50% HP.
- The AI focuses its attacks on the player's party member with the lowest HP. Since the only attack that the AI has is a damage-over-time effect, it just keeps refreshing the effect on the same entity over and over.
- In the event that the player's entities all have the same percentage of HP (such as the start of a battle), it always chooses the last entity. This basically results in the AI always attacking the player's last party member.
I've updated the old LivenessSystem
to remove any remaining effects from an entity when it dies. I went back and added Alive
components to all the entities so that we can make sure the AI only selects usable abilities. In addition to the changes in the AIControllerSystem
, I similarly modified the PlayerControllerSystem
to prevent casting abilities that don't have the Alive
component. Finally, I updated the CastSystem
to prevent action executions on entities that are not Alive
.
To resolve the other two issues, I changed the AIControllerSystem
to randomly choose its targets, except for defensive abilities, in which it always chooses the lowest health target in its own party.
linguine/src/systems/AIControllerSystem.cpp snippet
std::optional<uint64_t> chosenTargetId = {};
switch (ability->spell.category) {
case Category::Offense:
case Category::Control: {
auto availableTargets = findEntities<Health, Friendly, Alive>()->get();
if (!availableTargets.empty()) {
auto randomTarget = std::uniform_int_distribution<>(0, static_cast<int>(availableTargets.size() - 1));
const auto index = randomTarget(_random);
chosenTargetId = availableTargets[index]->getId();
}
break;
}
case Category::Defense: {
auto lowestHp = 1.0f;
findEntities<Health, Hostile, Alive>()->each([&chosenTargetId, &lowestHp](const Entity& targetEntity) {
auto health = targetEntity.get<Health>();
auto hp = static_cast<float>(health->current) / static_cast<float>(health->max);
if (hp <= lowestHp) {
lowestHp = hp;
chosenTargetId = targetEntity.getId();
}
});
break;
}
case Category::Support: {
auto availableTargets = findEntities<Health, Hostile, Alive>()->get();
if (!availableTargets.empty()) {
auto randomTarget = std::uniform_int_distribution<>(0, static_cast<int>(availableTargets.size() - 1));
const auto index = randomTarget(_random);
chosenTargetId = availableTargets[index]->getId();
}
break;
}
}
I'm not gonna lie, this is AI is freaking hard to beat.
I decided to add a cast "bubble" for the opponent at the top of the screen (in orange, to match its team color), just so I can try to anticipate its actions. I'm not sure if I like it or not, but it at least conveys some idea that the damage/healing has a source.
This is the most complete gameplay we've achieved so far. A little bit of AI goes a long way!
Unfortunately, it's fairly common to find yourself in some weird scenarios. Due to the way abilities are disabled once the owning entity dies, it's pretty common to end up in an unwinnable situation in which you cannot attack your opponent. Nonetheless, you can still heal your remaining party members indefinitely. This is particularly frustrating when the AI obviously can't win, but dutifully continues to do whatever it takes not to lose.
I removed the Alive
limitation on the abilities, but that doesn't feel quite right either. The game becomes easier if you simply let all but one of your party members die and focus your efforts on damaging the opponent while only keeping your single unit healthy. I actually prefer the Chess-like structure, where you want to protect all of your units so that you can utilize their unique capabilities. I'll undo the change, and try something else entirely.
I decreased the effect of all the abilities by roughly half of their original value. I could have achieved the same effect by doubling the HP of all of the units. This definitely made the gameplay feel smoother, but the AI still got into a situation where it could easily keep itself alive through my single wimpy damage-over-time attack. Back to the drawing board.
Just A Matter of Time
I've been thinking about how other games handle this problem.
Some games add resources, effectively limiting the amount of times you can cast your abilities over time. Different classes in WoW use different types of resources, but healers commonly use mana. The Pokémon games use "PP" (I think that means power points) to limit how many times a move can be used before they must visit a Pokémon Center or use an item to replenish the points.
The modern version of WoW has really lightened up on mana restrictions, when compared to its former self. Most mana-based damage dealers don't have to think about their mana at all! Healers still feel the most amount of strain on their mana, but some healers have secondary resources that allow them to bypass mana restrictions altogether, while others have abilities that let them replenish their mana during combat. This problem has become so prevalent in PvP (player versus player) matches that Blizzard decided to add a mechanic called "Dampening" - over time, healers simply heal for less, so that eventually even the smallest bit of damage will kill the opposing player. The amount of dampening that applies goes up by 1% every 10 seconds - the match ends in a draw if neither party has won after 20 minutes.
Pokémon's more direct approach sounds good in theory, but what do you think happens when a monster runs out of PP of all of their moves? It's such an uncommon occurrence that a lot of players don't even know what to expect. If a monster tries to attack on a turn after they have exhausted their resources, they will default to a move named "Struggle", which deals damage to the opponent, while also dealing backlash damage to the user. If a player reaches this point, their only hope is to use items to keep their monster alive - but items may not be used in PvP!
PlayerUnknown's Battlegrounds (PUBG) and Fortnite have a remarkably similar problem: given a bunch of players dropped into a free-for-all battle royale, a very effective strategy is to simply hide until most of the other players have killed each other. These games use a shrinking bubble to force players into a smaller area of the map - anyone caught outside of the bubble will die, so the player's only option is to keep moving.
The last example I'll give is the Super Smash Bros. series. The goal is to knock opposing players off of the level, but many characters and attacks simply don't have the power to put other players in any real danger. The solution used in this game involves increasing each player's vulnerability to knockback effects based on the power of the attacks that have hit them. Each incoming attack increases your vulnerability by a percentage. As your total vulnerability percentage increases, the distance you will fly upon getting hit increases by the same percentage. Somewhat interestingly, players at a high level of vulnerability will also deal more damage to other players, introducing a form of risk/reward!
These are all very clever solutions for preventing stalemates, but they all boil down to the same idea: the match becomes more and more likely to end as time goes on. What types of solutions might work best for our game?
- As each unit takes damage, the amount of damage they take increases (the opposite of WoW's "dampening", similar to Super Smash Bros.'s percentage-based modifiers)
- Repeatedly attacking the same target increases the amount of damage dealt to that target (rewards ability combos, discourages the "shotgun" method)
- Careful tuning, such that heals are always weaker than damage over longer periods of time
In an attempt to avoid having to build any additional systems, I went ahead and cut the power of all the heals in half and I doubled the health of all of the entities. Doing so absolutely got rid of the stalemates. It's actually kind of amazing how much of an effect simply tweaking the numbers has on the gameplay.
My wife played several rounds of the game. Each round, she got better and better at remembering what each of the buttons did (I admit this is a big issue), but never actually managed to defeat the AI. I don't think she was playing poorly though - she healed her party when they needed it and damaged the opposing party when she felt like she had some breathing room (though she did vocalize that the heals felt very weak). The AI is just really good, mostly because it has zero downtime between its casts.
She watched me play and was floored by how slowly I was moving, compared to her stress-induced actions. I explained that it's a bit of an unfair comparison, since I know how to take advantage of the AI - after all, I built it! I can consistently defeat it, but she has not managed to do so yet. I consider that to be a failure of some sort on my part, but I don't really know what that is yet.
In any case, even after losing several party members, the AI can manage to hang on for a frustratingly long amount of time. On the off-chance that its last party member has the "restore" ability, then it can actually out-heal my damage indefinitely. I definitely haven't solved that problem.
I think I'll wrap up the chapter here. It somehow turned out much longer than I expected when I started. It's been over a month since I last published a new chapter, and about nine months since I started building the engine. I'm definitely proud of the progress I've made recently. It's coming along, but it needs a lot of work. The latest code can be found at this commit, and you can try your hand at defeating the new AI here.