Dandelion Digest #1

Implementation Details of Player Movement

Hello, and thanks for stopping by for the this entry in the Dandelion Digest series! The goal of this series is to provide visibility into Project Dandelion, the side project that I’m working on, and share some code samples so people can get a better idea of my skills and general approach.

Here’s what you can expect:

  1. Overview of Development Approach and Rationale [Go To]
  2. Details of Player State Class [Go To]
  3. Details of Player Controller Class [Go To]
  4. Details of Player Character Classes [Go To]
  5. Details of Classes for Mapping Input Actions to Gameplay Abilities [Go To]
  6. Details of Movement Ability Task Classes [Go To]
  7. Details of the Player Movement GA [Go To]
  8. Roadmap of Future Development Goals [Go To]

Want to see some of my more design oriented work from last year? Check out the prior blog entry. If you’d like to see more technical content like this, check out my most recent entry!

Also, all of the source code for headers in this post includes 2 versions. One with the multi-line docstrings included, and another with all of these docstring stripped out. I strongly believe in the value in documenting the intended use of each member/parameter and consider it a personal requirement I hold myself to before submitting for a code review. Typically, I rely on the code folding features in my IDE to keep the code readable with the docstrings in place, but the embedded snippet viewer I used does not include code folding features. If you’d like to pull/look at the source code from the GitHub repo, please contact me directly using the email button below.

Overview of Development Approach and Rationale

I started Project Dandelion using the Top Down C++ template from Unreal. My first development goal in this iteration was to move the gameplay logic for player movement out of the PlayerController and into a Gameplay Ability (GA).

Generally speaking, it is not recommended to implement player movement in a GA because MovementComponents already handle the networking and replication needed to maintain and correct the positions of players. A naive implementation of player movement using GAs could potentially require redundant networking traffic since for most games, player movement input tends to be continuous, not discrete.

A major reason why I elected to ignore this recommendation is because I do not currently plan for the game to be networked. I feel a major challenge of side projects is getting your game to playable state and adding multiplayer to a game greatly increases the complexity and therefore the friction to getting your game playable. Additionally, I do not have access to a team that can assist in testing networking edge cases, which would again increase the friction to getting the game into the hands of real play testers.

If I were to later decide to make my game multiplayer, the movement input for top down gameplay can be implemented as discrete inputs, thereby avoiding the concerns introduced by continuous input.

The major motivations for moving player movement into a GA are that I want to take advantage of the GA blocking/canceling based on Gameplay Tags features of the Gameplay Ability System (GAS). I also believe the stateful nature of GAs and robust callbacks will make it easier to implement more complex movement behaviors in the future.

The other development goal in this iteration was to create a generic, data-driven way to map Input Actions (IAs) to Gameplay Abilities so that I could better maintain a layer of abstraction between the two systems.

Gameplay Footage: Player Movement

Above, you can see the player movement behavior. While it imitates the behavior of the Top Down template project, all of the behavior is implemented using Gameplay Abilities and Gameplay Cues.

Details of Player State Class

The custom ADandelionPlayerState class is relatively simple. It is the owner of the player’s AbilitySystemComponent (ASC) and implements the IAbilitySystemInterface. The Player State is the owner in this project since I would like for the ASC to persist beyond the destruction of the player’s Pawn so that I can better control the behavior of the ASC if the player’s Pawn gets destroyed on death/respawn or gets switched out as a result of transforming or switching characters. It also defines DefaultGameplayAbilitySets and a method for granting these sets of GAs when the ASC is initialized.

One particular style choice I’d like to call out is that I generally opt to keep properties and helper methods private until otherwise needed. I believe this helps to simply the interface for other developers if they need to access the class or inherit from it in the future and if the access level does not prove to be sufficient, it can always be changed. The other style choice I’d like to point out is in the implementation of InitAbilitySystem(). In general, I try to avoid nesting in if and for loops if possible by checking for invalid states and early-outing before proceeding with the actual intended behavior of the function. I also make extensive use of logging and ensures when invalid states are encountered to assist with debugging.

Details of Player Controller Class

The custom ADandelionPlayerController class is also relatively simple. It defines some DefaultInputMappingContexts to be applied when the InputComponent gets setup. It also creates a required UPathFollowingComponent that will be needed to work with the Nav System and initializes it once the ADandelionPlayerController possesses a Pawn. Finally, it overrides StopMovement(). This is a method that is typically used to tell the Nav System to stop whatever pathing request is ongoing for a given controller, but this particular controller also uses the MovementInputVector to handle the case where the player follows the cursor while the movement button is held down. The overridden StopMovement() consumes the current MovementInputVector to ensure that all player movement stops when StopMovement() is called.

I don’t think there are any particularly interesting style choices to highlight here that haven’t already been called out. I did take advantage of the custom log category that was already defined by the template. I often use logging when debugging, and having custom log categories makes it easier to filter the logging output.

Details of Player Character Classes

The implementation of the Character Player class involves a base class to represent both the Player and the AI Characters, and an inheriting class for the player’s Character.

ADandelionCharacter Implementation

As mentioned above, the ADandelionCharacter is an abstract base class for both Player and AI Characters. It implements the IAbilitySystemInterface, but the implementation is marked as PURE_VIRTUAL, requiring inheritors of the class to implement the method. This is because for players, the ASC will live on the Player State, while for AI, it will likely live on the Character itself. relatively simple. Otherwise, it just sets some default values for the Character’s capsule, camera rotation settings, and movement settings.

I’ve called out for myself a few TODOs for some of the capsule and movement values being set directly in code instead of in a method that is more data driven. I’ve left those details in for now, but will likely return to them in the future.

ADandelionPlayerCharacter Implementation

As mentioned above, the ADandelionPlayerCharacter is class that specifically represents Player Characters. It implements the IAbilitySystemInterface by getting the PlayerState and finding the ASC on the PlayerState. It also instantiates and configures the USpringArmComponent and the UCameraComponent with default values. It also defines a reference to the InputActionGameplayAbilityMappings which define the mapping from InputActions to GameplayAbilities as well as a list of DefaultGameplayAbilitySets that should be applied when the ASC get set up after this Character gets possessed. This is so that the PlayerState can define AbilitySets that should be applied regardless of which Character is possessed, and the Character can define AbiltySets that should be applied specifically for that character.

Finally, it overrides SetupPlayerInputComponent() which is the method that binds OnAbilityInputTriggered() and OnAbilityInputCompleted() to the appropriate TriggerEvents on the InputAction that is defined in the InputActionGameplayAbilityMappingsOnAbilityInputTriggered() and OnAbilityInputCompleted() will forward the received inputs to the ASC using the InputID that was defined in the InputActionGameplayAbilityMappings. When an AbilitySet is applied, the AbilitySet has the logic to ensure that the correct InputID is set when a GameplayAbility with a mapping in the InputActionGameplayAbilityMappings is granted.

I’ve called out for myself in a TODO since I am not capturing the outputs of BindAction(), and so may be leaving dangling bindings on the EnhancedInputComponent when an ADandelionPlayerCharacter gets destroyed. I figure it’ll be simple enough to address if it actually becomes an issue.

Details of Classes for Mapping Input Actions to Gameplay Abilities

From Initial Player Input to TryActivateAbility() on a GameplayAbility

Now that we’ve covered the PlayerState, PlayerController, and Character classes, we can start to go into greater detail of the other classes involved in the mapping of InputActions to GameplayAbility. At a high-level, the flow of a particular player input to the activation of a given GameplayAbility so far looks like:

  1. Player presses an input.
  2. InputAction gets a Trigger event.
  3. OnAbilityInputTriggered() in DandelionPlayerCharacter is bound to a specific ETriggerEvent on an InputAction and calls AbilityLocalInputPressed() with the corresponding InputID on the ASC located on ADandelionPlayerState.
  4. AbilityLocalInputPressed() eventually calls TryActivateAbility() on the GameplayAbility that was bound to the provided InputID.
In order to accomplish this, there are actually two mapping tasks that need to be accomplished on setup. First is the mapping from the a particular ETriggerEvent on a target InputAction to an InputID. As mentioned above, this mapping takes place in SetupPlayerInputComponent() on the ADandelionPlayerCharacter. The second is the mapping from the InputID to the GameplayAbility that should be activated by AbilityLocalInputPressed(). This mapping is made in the UDandelionAbilitySet class in the GiveAbility() method which we will be covering below. The InputID is an integer and is necessary because the ASC only allows binding via an integer InputID instead of directly to an InputAction’s ETriggerEvent. The UInputActionGameplayAbilityMappings class is responsible for maintaining these two mappings and ensuring that both mappings are using the same InputID, and we will also break down this class below.

UInputActionGameplayAbilityMappings Implementation

The UInputActionGameplayAbilityMappings class contains a TSet of FInputActionGameplayAbilityMappingDataUInputActionGameplayAbilityMappings also defines some convenience getter functions and an override for PostEditChangeProperty().

The FInputActionGameplayAbilityMappingData struct represents an individual mapping from a GameplayAbility to an ETriggerEvent on a target InputAction. This struct also contains the InputID which gets automatically generated in the Editor by logic in the PostEditChangeProperty() override in UInputActionGameplayAbilityMappings. This is because the actual value of the InputID is not important, just that the InputID bound to the InputAction’s ETriggerEvent and the InputID bound the GA on the ASC are matching. FInputActionGameplayAbilityMappingData defines an IsValid() check to ensure that no default values are set that would prevent a well-formed mapping. It also provides some equality operators and a GetTypeHash() implementation to ensure accurate behavior in TSets and other hashable container types.

One of the limitations with this current implementation is that the InputIDs only get automatically generated within the scope of an individual UInputActionGameplayAbilityMappings. This means that there can effectively only be one instance UInputActionGameplayAbilityMappings. Otherwise, automatically generated IDs in the second instance would overwrite/collide with the InputIDs and corresponding bindings defined by the first instance. At least right now, I do not see a pressing need to be able to support this behavior, though I may need to return to this if there is an actual gameplay need. Also, I typically implement Data Validation methods for classes that are supposed to populated in Editor by other team members (like designers), but I feel right now that it’s unlikely that I’ll forget or misunderstand the requirements and intended use case of the class, so I’ve been a little lazy and opted not to implement those methods. Also I just want to point out that in InputActionGameplayAbilityMappings.cpp, I used some #pragma region directives to enable code folding. Unfortunately, folding isn’t available on the online viewer, but outside of this context and possibly within a code review tool, I don’t really think there’s any reason to be viewing the code outside an IDE. 

UDandelionAbilitySet Implementation

The UDandelionAbilitySet class contains a TSet of TSubclassOf<UGameplayAbility> and implements methods for giving these abilities to a particular ASC. In particular, the GiveAbility() helper method on UDandelionAbilitySet is the method that binds the InputID to a particular GameplayAbility if one is defined within the provided UInputActionGameplayAbilityMappings.

Debatably, it might be better to move the GiveAbility() method in this class and possibly even the GiveAbilities() method to a custom ASC that overloads the GiveAbility() method with the implementation here. Looking over the code now, it actually feels like it makes more sense to keep the UDandelionAbilitySet “dumb” since it’s just a DataAsset and move the implementations, but I also think I’m splitting hairs at this point and this particular location might not be ideal, but also isn’t totally invalid or blocking to further development. Also, I totally forgot to make the Abilities UPROP private. I’ll probably update this file again in my own time.

From ActivateAbility() to EndAbility()

At this point, we have now called TryActivateAbility(). Assuming that this actually succeeds, we will have now entered ActivateAbility() on the GameplayAbility. At this point, the behavior needed to eventually reach EndAbility() is up to the implementation  of that particular GameplayAbility. If a regular GameplayAbility is the one that was mapped by the UInputActionGameplayAbilityMappings, then the GameplayAbility will not receive any further callbacks unless it directly implements support for it.

I also implemented a UInputActionGameplayAbility (IAGA) class that defines a GameplayAbility that should continue to receive callbacks and even potentially perform basic behaviors like call EndAbility() or CancelAbility() in response to these callbacks. Specifically, in ActivateAbility(), the UInputActionGameplayAbilityMappings are searched for the mappings matching this GameplayAbility and InvokeTriggeredInputAction() is bound to the InputAction’s Triggered ETriggerEvent if the original activation trigger defined in the UInputActionGameplayAbilityMappings was the Started ETriggerEvent. This is to support behaviors that want InputActions that will activate and perform behaviors once the InputAction in question starts to potentially be triggered, and to alter their behavior in response to the InputAction actually meeting all of the Triggered ETriggerEvent requirements. InvokeTriggeredInputAction() will broadcast to both a native and a dynamic delegate that additional behaviors can subscribe to.

Additionally, in response to the InputPressed() callback, an IAGA can be configured to do nothing, restart the GameplayAbility by calling EndAbility() or CancelAbility() before calling TryActivateAbility(), or to call the HandleInputPressed() method that can be overridden by a native child class or in BP. Similarly, in response to InputReleased(), an IAGA can be configured to do nothing, call EndAbility() or CancelAbility(), or to call an overridable HandleInputReleased() method. As one can see, there’s several different flows by which EndAbility() or CancelAbility() can be reached. I should probably make a flow chart or something for this.

I’ll breakdown the custom base UDandelionGameplayAbility class first before going into further depth on the UInputActionGameplayAbility class.

UDandelionGameplayAbility Implementation

The UDandelionGameplayAbility class provides a few convenience functions like a templated TryCastAvatarActorFromActorInfo() method that does pretty much exactly what it describes and a GetASCOwnerActor() that retrieves a reference to the Actor that owns the ASC that this GA was activated on. Additionally, it overrides most of the major GameplayAbility callbacks with Verbose logging statements. Although the game is not currently networked, it still uses the ActivationPredicitonKey in the FGameplayAbilityActivationInfo parameter as these are unique to each activation of the GA and are (and already have been) extremely helpful in debugging the behavior of a given GameplayAbility. Finally, it implements methods for restarting a GameplayAbility by calling EndAbility() or CancelAbility() based on the bCancelAbility parameter and an overridable CanRestartAbility() that checks that the GameplayAbility is active, can be activated again, and meets the current cost/tag requirements.

One particular thing to note is that one of the RestartAbility() log statements is set to VeryVerbose as it has the potential to spam the log every frame for IAGAs when the corresponding input is held down and triggered every frame.

UInputActionGameplayAbility Implementation

The UInputActionGameplayAbility class has been mostly described above as part of explaining the typical flow from ActivateAbility() to EndAbility(), but there are a few more details worth mentioning.

First, the ResponseOnInputPressed and ResponseOnInputReleased properties use a custom enum, EInputActionGameplayAbilityResponse, to define the behavior of the IAGA when InputPressed() and InputReleased() are called.

Secondly, I’m not in love with the fact that the UInputActionGameplayAbilityMappings are being searched for every call to ActivateAbility() to correctly bind InvokeTriggeredInputAction(). Potentially, I could cache the InputAction that corresponds to the IAGA, but I don’t love the idea of breaking the layer of abstraction that currently exists between InputActions and the actual GameplayAbility logic.

Finally, I also don’t love that the InvokeTriggeredInputAction() method and HandleInputPressed() method are both effectively handlers for the same event and will be raised together pretty much every time, but are bound to callbacks from two different sources: the EnhancedInputComponent and the ASC, respectively. I feel like the issue is that HandleInputPressed() makes it difficult to retrieve the FInputActionValue since its purpose is to help abstract away the input system. I don’t think that it will be an issue right now, since I don’t plan to make use of any 1 or 2-axis driven input, and therefore, really only need to know if the InputAction was triggered or not, but I do feel like this implementation doesn’t handle those particular use cases very cleanly. Possibly something to iterate on in the future.

Details of Movement Ability Task Classes

Ultimately, the objective of all of this boilerplate is to eventually implement a GA that will cause the player to follow the cursor location while the input is held down and will play a VFX and use the Nav System to path the player to the cursor location when the button is released. The movement of the character is accomplished through a few custom AbilityTasks. The UAbilityTask_MoveCharacterToLocation is the base class that UAbilityTask_MoveCharacterToCursor and UAbilityTask_MoveCharacterToPoint derive from to implement pathing using the Nav System with appropriate callbacks. The UAbilityTask_FollowCursor is a separate class since the cursor following movement behavior differs from the other AbilityTasks.

UAbilityTask_MoveCharacterToLocation Implementation

The custom UAbilityTask_MoveCharacterToLocation class contains the bulk of the important implementation details for moving the character. It provides a BP accessible callback, OnMoveComplete that is used to indicate that the movement request to the NavSystem has finished and what the actual result of the movement request was. It disables ticking since it depends entirely on callbacks from the Nav System to maintain its state. The InitializeBaseMoveTask() helper method caches some transient references when the AbilityTask is constructed. When canceled externally, the AbilityTask immediately calls StopMovement(). When Activate() is called, it stops any current player movement, starts a new path request using SimpleMoveToLocation(), and binds HandleOnRequestFinished() using a lambda on to the OnRequestFinished callback on the UPathFollowingComponent. HandleOnRequestFinished() just checks that the PathRequestID matches and that the FPathFollowingResult was a success before broadcasting OnMoveComplete. When OnDestroy() is called, this class removes the binding of HandleOnRequestFinished() from the OnRequestFinished callback, if valid.

Some potential points of improvement I can see are that the current implementation of the task assumes that the GA that uses the AbilityTask will call EndTask() or ExternalCancel(), even if the move request was a success. This gives the GA more flexibility in terms of how it decides to respond to the move request completing, but I think that it also creates a stronger coupling between the GA and AbilityTask classes as the GA now needs to be aware of this particular implementation detail. The other point of improvement I can see is that OnMoveComplete is only broadcast on success, but it could broadcast regardless of the FPathFollowingResult so that the GA can have greater control over how it handles the callback. I don’t think either is a critically blocking issue right now, but a few TODOs.

UAbilityTask_MoveCharacterToCursor Implementation

The UAbilityTask_MoveCharacterToCursor class is comparatively simple. It just has the standard static constructor and when Activate() is called, it sets the target position to be the current position under the cursor which it finds using the GetPositionUnderCursor() helper function. It’s important to do it at this point, as initialization of the AbilityTask is not the same as activation of the AbilityTask. It otherwise uses all the same callback/logic as the base class UAbilityTask_MoveCharacterToLocation.

One point of potential improvement would be to make the trace channel used in determining the point under the cursor a parameter you can pass into the AbilityTask.

UAbilityTask_MoveCharacterToPoint Implementation

The UAbilityTask_MoveCharacterToPoint class is even simpler. It’s pretty much identical to the base class UAbilityTask_MoveCharacterToLocation, but also includes a parameter in its static constructor to set the target location.

UAbilityTask_FollowCursor Implementation

The UAbilityTask_FollowCursor class is a separate implementation from the prior three AbilityTasks as it uses AddMovementInput() instead of the Nav System to move the player character. It also has an OnMoveComplete callback that is raised when the player character reaches a configurable stopping distance set in the static constructor. When ExternalCancel() and Activate() are called, it calls StopMovement() on the character. Finally, the task uses the TickTask() function to update the target location based on the cursor location and then checks if the XY distance of the character from the target point is below the StopDistance threshold. If so, it calls OnMoveComplete and ends the task. Otherwise, it uses AddMovementInput() to move the character towards the cursor hit point.

Two points of improvement that I can see here are that first, this task calls EndTask() on itself, unlike the other three AbilityTasks, so the lifespan management of the tasks is inconsistent. The other issue is that OnMoveComplete won’t get called on these tasks right now because the camera is currently a follow camera instead of a free camera. Whenever the player moves, then the hit point of the cursor will also move, so the character will continue moving until it hits a wall or something. It’s just a little odd to have the callback, but I think it’s necessary in case I do elect to implement a free camera or bounding boxes on the follow camera.

Details of the Player Movement GA

Finally, with all of these building blocks in place, we can implement the UIAGA_PlayerMove class. This IAGA has two phases. The first phase is while the mouse button is held down, follow the cursor using a UAbilityTask_FollowCursor. The second phase is when the mouse button is released, use the nav system to move to the point that was under the cursor when released using a UAbilityTask_MoveCharacterToCursor and use a GameplayCue to play a VFX indicating the targeted move location.

It sets some default values in the custom constructor. Specifically, that it should restart the ability using CancelAbility() when the input is pressed, and should use the custom HandleInputReleased() callback when the input is released. The CanRestartAbility() check enforces that the GA can only be restarted if it is currently in Phase 2. This makes it so an ongoing move request using a UAbilityTask_MoveCharacterToCursor can be canceled and so that the GA can go back to phase 1. When the ability is activated, it stops any current player movement and then starts a UAbilityTask_FollowCursor as per phase 1 behavior. If CancelAbility() gets called, it will cancel the FollowCursorAbilityTask and MoveCharacterToCursorAbilityTask if they are valid and active using ExternalCancel(). When EndAbility() is called, it cleans up the FollowCursorAbilityTask and MoveCharacterToCursorAbilityTask using EndTask(), but raises a warning as both of these tasks should already have been cleaned up at this point. The overridden HandleInputReleased_Implementation() method cancels the FollowCursorAbilityTask and starts the MoveCharacterToCursorAbilityTask, binding HandleOnMoveComplete() to the OnMoveComplete callback. If the MoveVFXCueTag is valid, it also executes the GameplayCue with the TargetLocation from the MoveCharacterToCursorAbilityTask set as the Location in the Cue Params. Finally, when HandleOnMoveComplete() is called, it checks to ensure that the GA is in phase 2, throwing an error and canceling the GA if it is not. If the path following result was not a success, cancel the MoveCharacterToCursorAbilityTask and this GA, and if it is a success, then end the MoveCharacterToCursorAbilityTask and this GA.

One of the things I don’t love about this current implementation is that the CanActivateAbility() method gets called every frame during phase 1 of this GA, which is not ideal from a perf perspective, though probably not critical since there’s only one player character to trigger it. Secondly, when the FollowCursorAbilityTask calls OnMoveComplete, it just directly calls EndAbility(). It might be preferable to issue a logging statement at this point since under the current implementation, this case should never be reached. Finally, I don’t like using AddDynamic in native code to subscribe to events since it becomes more difficult to manage the lifecycle of the bindings to those events. Although it should be impossible for the lifespan of the AbilityTasks to outlast the lifespan of the GameplayAbility and thereby create a null reference, I still think it’d be preferable to use the native/BP callback pattern in the AbilityTasks that I used in the UInputActionGameplayAbility class to represent the OnTriggeredInputAction event.

Roadmap of Future Development Goals

If you’ve made it this far, then thanks for reading! While by no means perfect, I hope it gives a bit of a picture into what I’ve been working on. Moving forward, the next development goals I’m going to work towards are as follows in this order:

  1. Make the player able to call a GA and play an attacking animation.
  2. Make a GA enabling the player to attack-move, so that they can first move to a target location, and then make an attack when they reach the required range and can hit the target location with an attack.
  3. Create simple enemy AI that have a notion of health and will die when their health is depleted. Probably add this onto the player as well.
  4. Implement the hitboxes and damage pipeline necessary to inflict damage on these simple enemy AI.
  5. Close the game loop so that the game mode spawns a set number of enemies, and then indicates a win condition, allowing for players to either restart the game or exit the game.
Once that’s all done, I can start tackling implementing player abilities and making combat actually satisfying. Thanks again so much for reading. Any feedback or questions are welcome, and I hope y’all look forward to the next update!