Hello again everyone! Welcome to another entry in this on-going blog/development thread for my GAS Course Project. For more context of this project, please see the very first post in this thread, and as usual, you can find my socials here:
Twitch : Twitch
Discord : UE5 - GAS Course Project - JevinsCherries
Tiktok : Devin Sherry (@jevinscherriesgamedev) | TikTok
Youtube : Youtube
Twitter : https://twitter.com/jevincherries
Instagram : Jevins Cherries Gamedev (@jevinscherriesgamedev) • Instagram photos and videos
Now that Unreal Engine 5.4 is live, I wanted to make a quick blog post about some of the issues I faced when migrating my project over from 5.3; some issues were minor such as changes to the required includes for certain classes while others were more difficult to fix, such as the replication methods in the Gameplay Ability System component when responding to gameplay effect application and removal. I will write the issues and the solutions I implemented in order of difficulty in hopes others will find these solutions useful.
1. FOverlap Syntax Error.
In 5.4, in order to get proper access to the FOverlapResult struct, we need to include a specific class. The simple fix here is to add the following to your includes based on this documentation: FOverlapResult::FOverlapResult | Unreal Engine 5.0 Documentation | Epic Developer Community
#include "Engine/OverlapResult.h"
GASCourseTargetActor_CameraTrace.cpp(205): Error C2027 : use of undefined type 'FOverlapResult'
PrimitiveComponent.h(52): Reference C2027 : see declaration of 'FOverlapResult'
GASCourseTargetActor_CameraTrace.cpp(205): Error C2059 : syntax error: ')'
Here is the code that caused this error to generate:
bool AGASCourseTargetActor_CameraTrace::OverlapMultiByObjectTypes(TArray<TWeakObjectPtr<AActor>>& OutHitActors, const FVector& Pos,
const FQuat& Rot, const FCollisionShape& OverlapCollisionShape, const FCollisionQueryParams& Params) const
{
TArray<FOverlapResult> Overlaps;
bool bTraceSuccessful = false;
if(QueryChannels.Num() == 0)
{
return bTraceSuccessful;
}
for(const ECollisionChannel QueryChannel : QueryChannels)
{
SourceActor->GetWorld()->OverlapMultiByObjectType(Overlaps, Pos, Rot, FCollisionObjectQueryParams(QueryChannel), OverlapCollisionShape, Params);
for(int32 i = 0; i < Overlaps.Num(); ++i)
{
//Should this check to see if these pawns are in the AimTarget list?
AActor* HitActor = Overlaps[i].OverlapObjectHandle.FetchActor<AActor>();
if (HitActor && !OutHitActors.Contains(HitActor) && Filter.FilterPassesForActor(HitActor))
{
OutHitActors.Add(HitActor);
}
}
}
return bTraceSuccessful = OutHitActors.Num() > 0 ? true : false;
}
2. Deprecated AnimMontage member of the FGameplayAbilityRepAnimMontage struct.
If you are like me and you wanted to learn more advanced topics in regards to networking and GAS, then you may have come across this blog from Alvaro Jover-Alvarez where he discusses and shows advanced GAS replication tricks: Gameplay Ability System - Advanced Network Optimizations - Devtricks. I definitely recommend reading through it and to try to implement it into your own projects, however, from Unreal Engine 5.4, the AnimMontage member variable of the FGameplayAbilityRepAnimMontage is no longer supported and it has been marked as deprecated with its own variable within the struct called AnimMontage_DEPRECATED:
GASCourseAbilitySystemComponent.cpp(359): Error C2039 : 'AnimMontage': is not a member of 'FGameplayAbilityRepAnimMontage'
GASCourseAbilitySystemComponent.h(20): Reference C2039 : see declaration of 'FGameplayAbilityRepAnimMontage'
GASCourseAbilitySystemComponent.cpp(363): Error C2039 : 'AnimMontage': is not a member of 'FGameplayAbilityRepAnimMontage'
GASCourseAbilitySystemComponent.h(20): Reference C2039 : see declaration of 'FGameplayAbilityRepAnimMontage'
GASCourseAbilitySystemComponent.cpp(366): Error C2039 : 'AnimMontage': is not a member of 'FGameplayAbilityRepAnimMontage'
GASCourseAbilitySystemComponent.h(20): Reference C2039 : see declaration of 'FGameplayAbilityRepAnimMontage'
/** AnimMontage ref */|
UPROPERTY(meta = (DeprecatedProperty, DeprecationMessage = Use the GetAnimMontage function instead))|
TObjectPtr AnimMontage_DEPRECATED;|
You can either use this deprecated member variable in place of the original AnimMontage member (which I do not recommend as deprecation will eventually lead to full deletion of the variable and could cause worse problems down the line), or you can replace the AnimMontage member variable with either the Animation member variable or the helper get function GetAnimMontage():
/** Animation ref. When playing a dynamic montage this points to the AnimSequence the montage was created from */
UPROPERTY()
TObjectPtr Animation;UAnimMontage* GetAnimMontage() const;
Below I am showing a before & after snippet of the PlayMontage() function that is overriden based on the original article where you can see how I addressed this problem. For more information, please check out the GameplayAbilityRepAnimMontage class.
Before:
float UGASCourseAbilitySystemComponent::PlayMontage(UGameplayAbility* InAnimatingAbility, FGameplayAbilityActivationInfo ActivationInfo, UAnimMontage* NewAnimMontage, float InPlayRate, FName StartSectionName, float StartTimeSeconds)
{
float Duration = -1.f;
UAnimInstance* AnimInstance = AbilityActorInfo.IsValid() ? AbilityActorInfo->GetAnimInstance() : nullptr;
if (AnimInstance && NewAnimMontage)
{
Duration = AnimInstance->Montage_Play(NewAnimMontage, InPlayRate, EMontagePlayReturnType::MontageLength, StartTimeSeconds);
if (Duration > 0.f)
{
if (LocalAnimMontageInfo.AnimatingAbility.Get() && LocalAnimMontageInfo.AnimatingAbility != InAnimatingAbility)
{
// The ability that was previously animating will have already gotten the 'interrupted' callback.
// It may be a good idea to make this a global policy and 'cancel' the ability.
//
// For now, we expect it to end itself when this happens.
}
if (NewAnimMontage->HasRootMotion() && AnimInstance->GetOwningActor())
{
UE_LOG(LogRootMotion, Log, TEXT("UAbilitySystemComponent::PlayMontage %s, Role: %s")
, *GetNameSafe(NewAnimMontage)
, *UEnum::GetValueAsString(TEXT("Engine.ENetRole"), AnimInstance->GetOwningActor()->GetLocalRole())
);
}
LocalAnimMontageInfo.AnimMontage = NewAnimMontage;
LocalAnimMontageInfo.AnimatingAbility = InAnimatingAbility;
LocalAnimMontageInfo.PlayInstanceId = (LocalAnimMontageInfo.PlayInstanceId < UINT8_MAX ? LocalAnimMontageInfo.PlayInstanceId + 1 : 0);
if (InAnimatingAbility)
{
InAnimatingAbility->SetCurrentMontage(NewAnimMontage);
}
// Start at a given Section.
if (StartSectionName != NAME_None)
{
AnimInstance->Montage_JumpToSection(StartSectionName, NewAnimMontage);
}
// Replicate to non owners
if (IsOwnerActorAuthoritative())
{
IGCAbilitySystemReplicationProxyInterface* ReplicationInterface = GetExtendedReplicationInterface();
FGameplayAbilityRepAnimMontage& MutableRepAnimMontageInfo = ReplicationInterface ? ReplicationInterface->Call_GetRepAnimMontageInfo_Mutable() : GetRepAnimMontageInfo_Mutable();
// Those are static parameters, they are only set when the montage is played. They are not changed after that.
MutableRepAnimMontageInfo.AnimMontage = NewAnimMontage;
MutableRepAnimMontageInfo.PlayInstanceId = (MutableRepAnimMontageInfo.PlayInstanceId < UINT8_MAX ? MutableRepAnimMontageInfo.PlayInstanceId + 1 : 0);
MutableRepAnimMontageInfo.SectionIdToPlay = 0;
if (MutableRepAnimMontageInfo.AnimMontage && StartSectionName != NAME_None)
{
// we add one so INDEX_NONE can be used in the on rep
MutableRepAnimMontageInfo.SectionIdToPlay = MutableRepAnimMontageInfo.AnimMontage->GetSectionIndex(StartSectionName) + 1;
}
// Update parameters that change during Montage life time.
AnimMontage_UpdateReplicatedData(MutableRepAnimMontageInfo);
// Force net update on our avatar actor
if (AbilityActorInfo->AvatarActor != nullptr)
{
AbilityActorInfo->AvatarActor->ForceNetUpdate();
}
}
else
{
// If this prediction key is rejected, we need to end the preview
FPredictionKey PredictionKey = GetPredictionKeyForNewAction();
if (PredictionKey.IsValidKey())
{
PredictionKey.NewRejectedDelegate().BindUObject(this, &UGASCourseAbilitySystemComponent::OnPredictiveMontageRejected, NewAnimMontage);
}
}
}
}
return Duration;
}
After:
UGASCourseAbilitySystemComponent::PlayMontage(UGameplayAbility* InAnimatingAbility, FGameplayAbilityActivationInfo ActivationInfo, UAnimMontage* NewAnimMontage, float InPlayRate, FName StartSectionName, float StartTimeSeconds)
{
float Duration = -1.f;
UAnimInstance* AnimInstance = AbilityActorInfo.IsValid() ? AbilityActorInfo->GetAnimInstance() : nullptr;
if (AnimInstance && NewAnimMontage)
{
Duration = AnimInstance->Montage_Play(NewAnimMontage, InPlayRate, EMontagePlayReturnType::MontageLength, StartTimeSeconds);
if (Duration > 0.f)
{
if (LocalAnimMontageInfo.AnimatingAbility.Get() && LocalAnimMontageInfo.AnimatingAbility != InAnimatingAbility)
{
// The ability that was previously animating will have already gotten the 'interrupted' callback.
// It may be a good idea to make this a global policy and 'cancel' the ability.
//
// For now, we expect it to end itself when this happens.
}
if (NewAnimMontage->HasRootMotion() && AnimInstance->GetOwningActor())
{
UE_LOG(LogRootMotion, Log, TEXT("UAbilitySystemComponent::PlayMontage %s, Role: %s")
, *GetNameSafe(NewAnimMontage)
, *UEnum::GetValueAsString(TEXT("Engine.ENetRole"), AnimInstance->GetOwningActor()->GetLocalRole())
);
}
LocalAnimMontageInfo.AnimMontage = NewAnimMontage;
LocalAnimMontageInfo.AnimatingAbility = InAnimatingAbility;
LocalAnimMontageInfo.PlayInstanceId = (LocalAnimMontageInfo.PlayInstanceId < UINT8_MAX ? LocalAnimMontageInfo.PlayInstanceId + 1 : 0);
if (InAnimatingAbility)
{
InAnimatingAbility->SetCurrentMontage(NewAnimMontage);
}
// Start at a given Section.
if (StartSectionName != NAME_None)
{
AnimInstance->Montage_JumpToSection(StartSectionName, NewAnimMontage);
}
// Replicate to non owners
if (IsOwnerActorAuthoritative())
{
IGCAbilitySystemReplicationProxyInterface* ReplicationInterface = GetExtendedReplicationInterface();
FGameplayAbilityRepAnimMontage& MutableRepAnimMontageInfo = ReplicationInterface ? ReplicationInterface->Call_GetRepAnimMontageInfo_Mutable() : GetRepAnimMontageInfo_Mutable();
// Those are static parameters, they are only set when the montage is played. They are not changed after that.
MutableRepAnimMontageInfo.Animation = NewAnimMontage;
MutableRepAnimMontageInfo.PlayInstanceId = (MutableRepAnimMontageInfo.PlayInstanceId < UINT8_MAX ? MutableRepAnimMontageInfo.PlayInstanceId + 1 : 0);
MutableRepAnimMontageInfo.SectionIdToPlay = 0;
if (MutableRepAnimMontageInfo.GetAnimMontage() && StartSectionName != NAME_None)
{
// we add one so INDEX_NONE can be used in the on rep
MutableRepAnimMontageInfo.SectionIdToPlay = MutableRepAnimMontageInfo.GetAnimMontage()->GetSectionIndex(StartSectionName) + 1;
}
// Update parameters that change during Montage life time.
AnimMontage_UpdateReplicatedData(MutableRepAnimMontageInfo);
// Force net update on our avatar actor
if (AbilityActorInfo->AvatarActor != nullptr)
{
AbilityActorInfo->AvatarActor->ForceNetUpdate();
}
}
else
{
// If this prediction key is rejected, we need to end the preview
FPredictionKey PredictionKey = GetPredictionKeyForNewAction();
if (PredictionKey.IsValidKey())
{
PredictionKey.NewRejectedDelegate().BindUObject(this, &UGASCourseAbilitySystemComponent::OnPredictiveMontageRejected, NewAnimMontage);
}
}
}
}
return Duration;
}
3. Gameplay Effect Application & Removal Replication
The last and final issue that I faced when migrating my project over from 5.3 to 5.4 was that in the latest version of the engine, replication of gameplay effect application events was not working correctly for clients! This was a major issue and very surprising to experience as there was not code change on my side during migration that I thought could have caused this. I had spent a few days trying to figure out the issue, and even turned to the Unreal Source discord server, specifically the Gameplay-Ability-System channel for help. Unfortunately, I did not receive any responses but luckily I found a solution that works for me anyway. Although I didn’t receive the help I was asking for, I still recommend others to join the server as there are many helpful members of the community: Unreal Source. For context, here are my posts from discord:
First Post:
Hey everyone, I am experiencing an issue when moving my project to 5.4 where it seems that clients aren’t receiving events that a Gameplay Effect was applied on an NPC on said NPC’s Event Begin Play through Blueprint. The gameplay effect in question applies a Passive Healing status effect that adds a UI element above the NPC health bar to indicate its presence. The functionality of the passive effect is working on the client (the NPC is passive regaining health after receiving damage), but the UI element is not added.
I use a custom component called StatusEffectListenerComponent that listens for gameplay effects with the asset tag (TagName=“Effect.AssetTag.Status”) and sends an active gameplay effect handle back to the owning character (the NPC) that then uses this handle to add the associated icon data to its health bar.
By default, the NPC ability system component is set to replicate but when I manually override it to not replicate in BP, the icon correctly appears but then data such as the health is not correctly present in the health bar. This problem did not exist in 5.3 and only started happening after migrating to 5.4. To avoid giant code spam, I have much of the code as it was working in 5.3 written in this forum blog post: Gameplay Ability System Course Project - Development Blog - #13 by DevinSherry.
Does anyone have any advice as to what could be happening here? Thanks in advance
Second Post:
Hi again, I wanted to post once more about my issue with client-side gameplay effect application and receiving events when said gameplay effects are applied to an NPC target.
Some progress has been made since then, but I am still running into some roadblocks. I have discovered there are two different delegates to bind to when it comes to responding to gameplay effect application: OnActiveGameplayEffectAddedDelegateToSelf that is called on both server and client when duration-based GEs are applied, and OnGameplayEffectAppliedDelegateToSelf which is called only on server. Originally, I was using OnGameplayEffectAppliedDelegateToSelf which seemed to work fine on my project on 5.3 but not on 5.4 and now using OnActiveGameplayEffectAddedDelegateToSelf fixes the original issue of the passive healing icon appearing for clients on begin play, but introduces a new issue where if the client applies the burning status, the icon appears twice, and the data for the duration between the two instances are completely different. I will provide a video of this example.
I have tried different combinations of server, client, and net multicast events in C++ for when the component is listening for the events and broadcasting it back out to apply the UI data (icon, duration, etc) and nothing has worked 100% as its needed to and I am stuck. Does anyone have any thoughts or advice as to what could be the issue here? Thanks in advance!
Final Post:
Good news is that I finally fixed the problem, but I am still not 100% why it works or why the problem occurred in the first place when migrating from 5.3 to 5.4; maybe others can shine a light on it now that I can provide a solution that worked for me. Attached is a video of the final result, as well as the code changes I made to get it working. I hope this helps others in some way
In short, what I ended up doing was creating separate server and client functions inside of the status effect listener component. On begin play of my character class, on authority, I bind to OnGameplayEffectAppliedDelegateToSelf and have it call the server function of the status effect listener component. For non-authority, I bind to OnActiveGameplayEffectAddedDelegateToSelf and have it call the client function of the status effect listener component. For gameplay effect removal, I still use a multicast function from the status effect listener component and bind it to OnAnyGameplayEffectRemovedDelegate.
Here are the required code changes that I made:
GASCourseCharacter.cpp
void AGASCourseCharacter::BeginPlay()
{
// Call the base class
Super::BeginPlay();
GameplayEffectAssetTagsToRemove.AddTag(FGameplayTag::RequestGameplayTag(FName("Effect.AssetTag.Status")));
if(AbilitySystemComponent)
{
if(StatusEffectListenerComp)
{
if(GetLocalRole() == ROLE_Authority)
{
AbilitySystemComponent->OnGameplayEffectAppliedDelegateToSelf.AddUObject(StatusEffectListenerComp, &UGASCStatusEffectListenerComp::OnStatusEffectApplied_Server);
}
else
{
AbilitySystemComponent->OnActiveGameplayEffectAddedDelegateToSelf.AddUObject(StatusEffectListenerComp, &UGASCStatusEffectListenerComp::OnStatusEffectApplied_Client);
}
AbilitySystemComponent->OnAnyGameplayEffectRemovedDelegate().AddUObject(StatusEffectListenerComp, &UGASCStatusEffectListenerComp::OnStatusEffectRemoved);
StatusEffectListenerComp->ApplyDefaultActiveStatusEffects();
}
}
}
GASCStatusEffectListenerComp.h
// Fill out your copyright notice in the Description page of Project Settings.
#pragma once
#include "ActiveGameplayEffectHandle.h"
#include "GameplayEffectTypes.h"
#include "GameplayEffect.h"
#include "Components/ActorComponent.h"
#include "GASCStatusEffectListenerComp.generated.h"
DECLARE_DYNAMIC_MULTICAST_DELEGATE_OneParam(FStatusEffectApplied, FActiveGameplayEffectHandle, StatusEffectSpec);
UCLASS( ClassGroup=(Custom), meta=(BlueprintSpawnableComponent), Blueprintable )
class GASCOURSE_API UGASCStatusEffectListenerComp : public UActorComponent
{
GENERATED_BODY()
public:
// Sets default values for this component's properties
UGASCStatusEffectListenerComp();
UFUNCTION(Client, Reliable)
void OnStatusEffectApplied_Client(UAbilitySystemComponent* Source, const FGameplayEffectSpec& GameplayEffectSpec, FActiveGameplayEffectHandle ActiveGameplayEffectHandle);
UFUNCTION(Server, Reliable)
void OnStatusEffectApplied_Server(UAbilitySystemComponent* Source, const FGameplayEffectSpec& GameplayEffectSpec, FActiveGameplayEffectHandle ActiveGameplayEffectHandle);
UFUNCTION(NetMulticast, Reliable)
void OnStatusEffectRemoved(const FActiveGameplayEffect& ActiveGameplayEffect);
UPROPERTY(BlueprintAssignable)
FStatusEffectApplied OnStatusEffectAppliedHandle;
UPROPERTY(BlueprintAssignable)
FStatusEffectApplied OnStatusEffectRemovedHandle;
UPROPERTY(EditAnywhere, Category = "GASCourse|StatusEffect|Tags")
FGameplayTag StatusEffectAssetTag;
UFUNCTION()
void ApplyDefaultActiveStatusEffects();
protected:
// Called when the game starts
virtual void BeginPlay() override;
virtual void EndPlay(const EEndPlayReason::Type EndPlayReason) override;
virtual void Deactivate() override;
virtual void InitializeComponent() override;
virtual void BeginReplication() override;
};
// Fill out your copyright notice in the Description page of Project Settings.
#include "Game/Character/Components/GASCStatusEffectListenerComp.h"
#include "GASCourseCharacter.h"
// Sets default values for this component's properties
UGASCStatusEffectListenerComp::UGASCStatusEffectListenerComp()
{
// Set this component to be initialized when the game starts, and to be ticked every frame. You can turn these features
// off to improve performance if you don't need them.
PrimaryComponentTick.bCanEverTick = false;
SetIsReplicatedByDefault(true);
// ...
}
GASCStatusEffectListenerComp.cpp
// Fill out your copyright notice in the Description page of Project Settings.
#include "Game/Character/Components/GASCStatusEffectListenerComp.h"
#include "GASCourseCharacter.h"
// Sets default values for this component's properties
UGASCStatusEffectListenerComp::UGASCStatusEffectListenerComp()
{
// Set this component to be initialized when the game starts, and to be ticked every frame. You can turn these features
// off to improve performance if you don't need them.
PrimaryComponentTick.bCanEverTick = false;
SetIsReplicatedByDefault(true);
// ...
}
void UGASCStatusEffectListenerComp::OnStatusEffectApplied_Server_Implementation(UAbilitySystemComponent* Source, const FGameplayEffectSpec& GameplayEffectSpec, FActiveGameplayEffectHandle ActiveGameplayEffectHandle)
{
FGameplayTagContainer GameplayEffectAssetTags;
GameplayEffectSpec.GetAllAssetTags(GameplayEffectAssetTags);
if(GameplayEffectAssetTags.IsEmpty())
{
return;
}
if(GameplayEffectAssetTags.HasTag(StatusEffectAssetTag))
{
OnStatusEffectAppliedHandle.Broadcast(ActiveGameplayEffectHandle);
}
}
void UGASCStatusEffectListenerComp::OnStatusEffectApplied_Client_Implementation(UAbilitySystemComponent* Source, const FGameplayEffectSpec& GameplayEffectSpec, FActiveGameplayEffectHandle ActiveGameplayEffectHandle)
{
FGameplayTagContainer GameplayEffectAssetTags;
GameplayEffectSpec.GetAllAssetTags(GameplayEffectAssetTags);
if(GameplayEffectAssetTags.IsEmpty())
{
return;
}
if(GameplayEffectAssetTags.HasTag(StatusEffectAssetTag))
{
OnStatusEffectAppliedHandle.Broadcast(ActiveGameplayEffectHandle);
}
}
void UGASCStatusEffectListenerComp::OnStatusEffectRemoved_Implementation(const FActiveGameplayEffect& ActiveGameplayEffect)
{
FGameplayTagContainer GameplayEffectAssetTags;
ActiveGameplayEffect.Spec.GetAllAssetTags(GameplayEffectAssetTags);
if(GameplayEffectAssetTags.IsEmpty())
{
return;
}
if(GameplayEffectAssetTags.HasTag(StatusEffectAssetTag))
{
OnStatusEffectRemovedHandle.Broadcast(ActiveGameplayEffect.Handle);
}
}
void UGASCStatusEffectListenerComp::ApplyDefaultActiveStatusEffects()
{
if(const AGASCourseCharacter* OwningCharacter = Cast<AGASCourseCharacter>(GetOwner()))
{
if(const UAbilitySystemComponent* ASC = OwningCharacter->GetAbilitySystemComponent())
{
TArray<FActiveGameplayEffectHandle> ActiveHandles = ASC->GetActiveEffectsWithAllTags(StatusEffectAssetTag.GetSingleTagContainer());
for(const FActiveGameplayEffectHandle InActiveHandle : ActiveHandles)
{
OnStatusEffectAppliedHandle.Broadcast(InActiveHandle);
}
}
}
}
// Called when the game starts
void UGASCStatusEffectListenerComp::BeginPlay()
{
Super::BeginPlay();
}
void UGASCStatusEffectListenerComp::EndPlay(const EEndPlayReason::Type EndPlayReason)
{
if(OnStatusEffectAppliedHandle.IsBound())
{
OnStatusEffectAppliedHandle.Clear();
}
if(OnStatusEffectRemovedHandle.IsBound())
{
OnStatusEffectRemovedHandle.Clear();
}
Super::EndPlay(EndPlayReason);
}
void UGASCStatusEffectListenerComp::Deactivate()
{
if(OnStatusEffectAppliedHandle.IsBound())
{
OnStatusEffectAppliedHandle.Clear();
}
if(OnStatusEffectRemovedHandle.IsBound())
{
OnStatusEffectRemovedHandle.Clear();
}
Super::Deactivate();
}
void UGASCStatusEffectListenerComp::InitializeComponent()
{
Super::InitializeComponent();
}
void UGASCStatusEffectListenerComp::BeginReplication()
{
Super::BeginReplication();
}
To recap briefly, I had to use a combination of two delegate bindings and server/client functions inside of my Status Effect Listener Component to ensure both the server and clients were properly receiving the events for both application and removal of Gameplay Effects in order to properly add and draw UI data on the top of the NPC healthbar.
Thank you for taking the time to read this post, and I hope you were able to take something away from it. Please let me know if I got any information wrong, or explained something incorrectly! Also add any thoughts or questions or code review feedback so we can all learn together
Next Blog Post Topic:
*View Models & Health Components