Stats tracking in C++

Show me an example of how to keep statistics of an object in c++ unreal
**TL;DR:** Add a reusable `UStatsComponent` to any Actor. It tracks counters/timers, replicates in multiplayer, and can persist via `USaveGame`. Use `Increment*`, `Add*`, and `ExportToJson()` to read/write. Hook into gameplay events.

### Plan (pseudocode)
– Create `FTrackedStat` (name, value, last updated time).
– Create `FObjectStats` (maps for Int/Float, uptime seconds).
– Make `UStatsComponent`:
– `UPROPERTY(ReplicatedUsing=OnRep_Stats)` for replication.
– Ticks uptime (server-only).
– Public API: `IncrementInt`, `AddFloat`, `GetInt`, `GetFloat`, `ExportToJson`, `ImportFromJson`, `ResetAll`.
– Delegates: `OnStatChanged`.
– Save/Load with a `UStatsSaveGame` slot.
– Multiplayer:
– Server mutates; clients request via `Server*` RPCs (optional example).
– `OnRep_Stats` fires delegate on clients.
– Example usage in a pawn/actor (`AMyHero`).
– Edge cases:
– Null owner/world, missing slot, first-time save.
– Clamping, negative inputs.
– Time tracked only while component is active.

“`cpp
// File: Source/YourGame/Public/Stats/StatsComponent.h

#pragma once

#include “CoreMinimal.h”
#include “Components/ActorComponent.h”
#include “StatsComponent.generated.h”

// Why: Notify Blueprints/C++ when a stat changes (UI updates, achievements).
DECLARE_DYNAMIC_MULTICAST_DELEGATE_ThreeParams(FOnStatChanged, FName, StatName, float, NewValue, float, Delta);

USTRUCT(BlueprintType)
struct FTrackedStat
{
GENERATED_BODY()

UPROPERTY(EditAnywhere, BlueprintReadWrite, Category=”Stats”)
float Value = 0.f;

UPROPERTY(VisibleAnywhere, BlueprintReadOnly, Category=”Stats”)
FDateTime LastUpdatedUtc = FDateTime(0);
};

USTRUCT(BlueprintType)
struct FObjectStats
{
GENERATED_BODY()

UPROPERTY(EditAnywhere, BlueprintReadWrite, Category=”Stats”)
TMap IntStats;

UPROPERTY(EditAnywhere, BlueprintReadWrite, Category=”Stats”)
TMap FloatStats;

/** Accumulated alive/active seconds (server-auth). */
UPROPERTY(EditAnywhere, BlueprintReadWrite, Category=”Stats”)
float UptimeSeconds = 0.f;
};

UCLASS(ClassGroup=(Custom), meta=(BlueprintSpawnableComponent))
class YOURGAME_API UStatsComponent : public UActorComponent
{
GENERATED_BODY()

public:
UStatsComponent();

// —– Delegates —–
UPROPERTY(BlueprintAssignable, Category=”Stats”)
FOnStatChanged OnStatChanged;

// —– Query API —–
UFUNCTION(BlueprintCallable, Category=”Stats”)
int32 GetInt(FName Name, bool& bFound) const;

UFUNCTION(BlueprintCallable, Category=”Stats”)
float GetFloat(FName Name, bool& bFound) const;

UFUNCTION(BlueprintPure, Category=”Stats”)
float GetUptimeSeconds() const { return ReplicatedStats.UptimeSeconds; }

// —– Mutation API (Authority or via RPC) —–
UFUNCTION(BlueprintCallable, Category=”Stats”)
void IncrementInt(FName Name, int32 Amount = 1);

UFUNCTION(BlueprintCallable, Category=”Stats”)
void AddFloat(FName Name, float Amount);

UFUNCTION(BlueprintCallable, Category=”Stats”)
void SetFloat(FName Name, float NewValue);

UFUNCTION(BlueprintCallable, Category=”Stats”)
void ResetAll();

// —– Serialization helpers —–
UFUNCTION(BlueprintCallable, Category=”Stats”)
FString ExportToJson(bool bPretty = true) const;

UFUNCTION(BlueprintCallable, Category=”Stats”)
bool ImportFromJson(const FString& Json);

// —– Persistence (SaveGame) —–
UFUNCTION(BlueprintCallable, Category=”Stats|Save”)
bool SaveToSlot(const FString& SlotName, int32 UserIndex = 0);

UFUNCTION(BlueprintCallable, Category=”Stats|Save”)
bool LoadFromSlot(const FString& SlotName, int32 UserIndex = 0);

// —– Networking —–
virtual void GetLifetimeReplicatedProps(TArray& OutLifetimeProps) const override;

protected:
virtual void BeginPlay() override;
virtual void EndPlay(const EEndPlayReason::Type EndPlayReason) override;
virtual void TickComponent(float DeltaTime, ELevelTick TickType, FActorComponentTickFunction* ThisTickFunction) override;

private:
/** Server is source of truth for time & mutations. */
UPROPERTY(ReplicatedUsing=OnRep_Stats, VisibleAnywhere, Category=”Stats”)
FObjectStats ReplicatedStats;

UFUNCTION()
void OnRep_Stats();

// Why: buffer stat changes while applying to broadcast once.
void BroadcastChange(const FName Name, float NewValue, float Delta);

// Server-only mutators
void IncrementInt_Server(const FName Name, const int32 Amount);
void AddFloat_Server(const FName Name, const float Amount);
void SetFloat_Server(const FName Name, const float NewValue);

// RPCs for clients to request mutations in multiplayer.
UFUNCTION(Server, Reliable)
void Server_IncrementInt(FName Name, int32 Amount);

UFUNCTION(Server, Reliable)
void Server_AddFloat(FName Name, float Amount);

UFUNCTION(Server, Reliable)
void Server_SetFloat(FName Name, float NewValue);

// Helpers
static void Touch(FTrackedStat& Stat);
static float GetCurrentValue(const TMap& Map, const FName& Name, bool& bFound);
};

// File: Source/YourGame/Private/Stats/StatsComponent.cpp

#include “StatsComponent.h”
#include “Net/UnrealNetwork.h”
#include “Serialization/JsonWriter.h”
#include “Serialization/JsonReader.h”
#include “Serialization/JsonSerializer.h”
#include “Kismet/GameplayStatics.h”
#include “StatsSaveGame.h”

UStatsComponent::UStatsComponent()
{
PrimaryComponentTick.bCanEverTick = true;
SetIsReplicatedByDefault(true);
}

void UStatsComponent::BeginPlay()
{
Super::BeginPlay();
}

void UStatsComponent::EndPlay(const EEndPlayReason::Type EndPlayReason)
{
Super::EndPlay(EndPlayReason);
}

void UStatsComponent::TickComponent(float DeltaTime, ELevelTick TickType, FActorComponentTickFunction* ThisTickFunction)
{
Super::TickComponent(DeltaTime, TickType, ThisTickFunction);

// Server tracks uptime only.
if (GetOwner() && GetOwner()->HasAuthority())
{
ReplicatedStats.UptimeSeconds += FMath::Max(0.f, DeltaTime);
}
}

void UStatsComponent::GetLifetimeReplicatedProps(TArray& OutLifetimeProps) const
{
Super::GetLifetimeReplicatedProps(OutLifetimeProps);
DOREPLIFETIME(UStatsComponent, ReplicatedStats);
}

void UStatsComponent::OnRep_Stats()
{
// Clients could refresh UI here; fine-grained diffs omitted for simplicity.
}

void UStatsComponent::Touch(FTrackedStat& Stat)
{
Stat.LastUpdatedUtc = FDateTime::UtcNow();
}

float UStatsComponent::GetCurrentValue(const TMap& Map, const FName& Name, bool& bFound)
{
if (const FTrackedStat* Found = Map.Find(Name))
{
bFound = true;
return Found->Value;
}
bFound = false;
return 0.f;
}

int32 UStatsComponent::GetInt(FName Name, bool& bFound) const
{
return static_cast(GetCurrentValue(ReplicatedStats.IntStats, Name, bFound));
}

float UStatsComponent::GetFloat(FName Name, bool& bFound) const
{
return GetCurrentValue(ReplicatedStats.FloatStats, Name, bFound);
}

void UStatsComponent::BroadcastChange(const FName Name, float NewValue, float Delta)
{
OnStatChanged.Broadcast(Name, NewValue, Delta);
}

void UStatsComponent::IncrementInt(FName Name, int32 Amount)
{
if (!GetOwner()) return;

if (GetOwner()->HasAuthority())
{
IncrementInt_Server(Name, Amount);
}
else
{
Server_IncrementInt(Name, Amount);
}
}

void UStatsComponent::AddFloat(FName Name, float Amount)
{
if (!GetOwner()) return;

if (GetOwner()->HasAuthority())
{
AddFloat_Server(Name, Amount);
}
else
{
Server_AddFloat(Name, Amount);
}
}

void UStatsComponent::SetFloat(FName Name, float NewValue)
{
if (!GetOwner()) return;

if (GetOwner()->HasAuthority())
{
SetFloat_Server(Name, NewValue);
}
else
{
Server_SetFloat(Name, NewValue);
}
}

void UStatsComponent::IncrementInt_Server(const FName Name, const int32 Amount)
{
FTrackedStat& Stat = ReplicatedStats.IntStats.FindOrAdd(Name);
const float Before = Stat.Value;
Stat.Value = FMath::Clamp(Stat.Value + Amount, -2147483648.f, 2147483647.f);
Touch(Stat);
BroadcastChange(Name, Stat.Value, Stat.Value – Before);
}

void UStatsComponent::AddFloat_Server(const FName Name, const float Amount)
{
FTrackedStat& Stat = ReplicatedStats.FloatStats.FindOrAdd(Name);
const float Before = Stat.Value;
Stat.Value += Amount;
Touch(Stat);
BroadcastChange(Name, Stat.Value, Stat.Value – Before);
}

void UStatsComponent::SetFloat_Server(const FName Name, const float NewValue)
{
FTrackedStat& Stat = ReplicatedStats.FloatStats.FindOrAdd(Name);
const float Before = Stat.Value;
Stat.Value = NewValue;
Touch(Stat);
BroadcastChange(Name, Stat.Value, Stat.Value – Before);
}

void UStatsComponent::Server_IncrementInt_Implementation(FName Name, int32 Amount)
{
IncrementInt_Server(Name, Amount);
}

void UStatsComponent::Server_AddFloat_Implementation(FName Name, float Amount)
{
AddFloat_Server(Name, Amount);
}

void UStatsComponent::Server_SetFloat_Implementation(FName Name, float NewValue)
{
SetFloat_Server(Name, NewValue);
}

void UStatsComponent::ResetAll()
{
if (!GetOwner() || !GetOwner()->HasAuthority()) return;

ReplicatedStats.IntStats.Reset();
ReplicatedStats.FloatStats.Reset();
ReplicatedStats.UptimeSeconds = 0.f;
OnStatChanged.Broadcast(“ResetAll”, 0.f, 0.f);
}

FString UStatsComponent::ExportToJson(bool bPretty) const
{
TSharedRef Root = MakeShared();
Root->SetNumberField(TEXT(“uptimeSeconds”), ReplicatedStats.UptimeSeconds);

// Ints
{
TSharedRef Ints = MakeShared();
for (const auto& Kv : ReplicatedStats.IntStats)
{
Ints->SetNumberField(Kv.Key.ToString(), Kv.Value.Value);
}
Root->SetObjectField(TEXT(“ints”), Ints);
}
// Floats
{
TSharedRef Floats = MakeShared();
for (const auto& Kv : ReplicatedStats.FloatStats)
{
Floats->SetNumberField(Kv.Key.ToString(), Kv.Value.Value);
}
Root->SetObjectField(TEXT(“floats”), Floats);
}

FString Out;
if (bPretty)
{
TSharedRef<TJsonWriter> Writer = TJsonWriterFactory::Create(&Out);
FJsonSerializer::Serialize(Root, Writer);
}
else
{
TSharedRef<TJsonWriter<UTF8CHAR, TCondensedJsonPrintPolicy>> Writer =
TJsonWriterFactory<UTF8CHAR, TCondensedJsonPrintPolicy>::Create(&Out);
FJsonSerializer::Serialize(Root, Writer);
}
return Out;
}

bool UStatsComponent::ImportFromJson(const FString& Json)
{
TSharedPtr Root;
const TSharedRef<TJsonReader> Reader = TJsonReaderFactory::Create(Json);
if (!FJsonSerializer::Deserialize(Reader, Root) || !Root.IsValid()) return false;

if (GetOwner() && !GetOwner()->HasAuthority()) return false; // Why: prevent clients desyncing server.

ReplicatedStats.UptimeSeconds = Root->GetNumberField(TEXT(“uptimeSeconds”));

auto LoadMap = [&](const FString& Field, TMap& Target)
{
Target.Reset();
const TSharedPtr* ObjPtr;
if (Root->TryGetObjectField(Field, ObjPtr) && ObjPtr && ObjPtr->IsValid())
{
for (const auto& Pair : (*ObjPtr)->Values)
{
FTrackedStat S;
S.Value = static_cast(Pair.Value->AsNumber());
Touch(S);
Target.Add(FName(Pair.Key), S);
}
}
};

LoadMap(TEXT(“ints”), ReplicatedStats.IntStats);
LoadMap(TEXT(“floats”), ReplicatedStats.FloatStats);

OnStatChanged.Broadcast(“ImportFromJson”, 0.f, 0.f);
return true;
}

bool UStatsComponent::SaveToSlot(const FString& SlotName, int32 UserIndex)
{
if (SlotName.IsEmpty()) return false;

UStatsSaveGame* SaveObj = Cast(UGameplayStatics::CreateSaveGameObject(UStatsSaveGame::StaticClass()));
if (!SaveObj) return false;

SaveObj->Stats = ReplicatedStats;
return UGameplayStatics::SaveGameToSlot(SaveObj, SlotName, UserIndex);
}

bool UStatsComponent::LoadFromSlot(const FString& SlotName, int32 UserIndex)
{
if (SlotName.IsEmpty()) return false;

if (USaveGame* Raw = UGameplayStatics::LoadGameFromSlot(SlotName, UserIndex))
{
if (UStatsSaveGame* SaveObj = Cast(Raw))
{
if (GetOwner() && !GetOwner()->HasAuthority()) return false;
ReplicatedStats = SaveObj->Stats;
OnStatChanged.Broadcast(“LoadFromSlot”, 0.f, 0.f);
return true;
}
}
return false;
}

// File: Source/YourGame/Public/Stats/StatsSaveGame.h

#pragma once

#include “CoreMinimal.h”
#include “GameFramework/SaveGame.h”
#include “StatsComponent.h”
#include “StatsSaveGame.generated.h”

UCLASS()
class YOURGAME_API UStatsSaveGame : public USaveGame
{
GENERATED_BODY()

public:
// Why: Mark SaveGame so these fields persist automatically.
UPROPERTY(EditAnywhere, BlueprintReadWrite, Category=”Stats”, SaveGame)
FObjectStats Stats;
};

// File: Source/YourGame/Private/Stats/StatsSaveGame.cpp

#include “StatsSaveGame.h”

// Intentionally empty.

// File: Source/YourGame/Public/Characters/MyHero.h

#pragma once

#include “CoreMinimal.h”
#include “GameFramework/Character.h”
#include “MyHero.generated.h”

class UStatsComponent;

UCLASS()
class YOURGAME_API AMyHero : public ACharacter
{
GENERATED_BODY()

public:
AMyHero();

UFUNCTION(BlueprintCallable, Category=”Demo”)
void OnDealtDamage(float Amount);

UFUNCTION(BlueprintCallable, Category=”Demo”)
void OnKillEnemy();

protected:
virtual void BeginPlay() override;

UPROPERTY(VisibleAnywhere, BlueprintReadOnly, Category=”Stats”)
UStatsComponent* StatsComp;
};

// File: Source/YourGame/Private/Characters/MyHero.cpp

#include “MyHero.h”
#include “StatsComponent.h”

AMyHero::AMyHero()
{
StatsComp = CreateDefaultSubobject(TEXT(“StatsComp”));
}

void AMyHero::BeginPlay()
{
Super::BeginPlay();

if (StatsComp)
{
StatsComp->OnStatChanged.AddDynamic(this, [](FName Name, float NewValue, float Delta)
{
// Why: Minimal inline lambda to demo UI hooks; replace with proper handler.
UE_LOG(LogTemp, Log, TEXT(“[StatChanged] %s -> %f (Δ%f)”), *Name.ToString(), NewValue, Delta);
});
}
}

void AMyHero::OnDealtDamage(float Amount)
{
if (!StatsComp) return;
StatsComp->AddFloat(“DamageDealt”, Amount);
StatsComp->IncrementInt(“HitsLanded”, 1);
}

void AMyHero::OnKillEnemy()
{
if (!StatsComp) return;
StatsComp->IncrementInt(“Kills”, 1);
// Example: grant achievement when kills reach threshold.
bool bFound=false;
if (StatsComp->GetInt(“Kills”, bFound) >= 10 && bFound)
{
UE_LOG(LogTemp, Log, TEXT(“Achievement: Slayer”));
}
}

// File: Source/YourGame/Private/Example/StatsUsageExample.cpp
// Quick demonstration (e.g., called from a cheat manager/console)

#include “StatsComponent.h”
#include “GameFramework/Actor.h”

static void Demo_Stats(UStatsComponent* Comp)
{
if (!Comp) return;

Comp->IncrementInt(“Pickups”, 1);
Comp->AddFloat(“MetersTraveled”, 2.5f);

const FString Json = Comp->ExportToJson(true);
UE_LOG(LogTemp, Log, TEXT(“Stats JSON:n%s”), *Json);

Comp->SaveToSlot(“PlayerStats”);
}
“`

**Usage tips**
– Add `UStatsComponent` to any Actor (Character, Weapon, Pickup).
– Call `IncrementInt/AddFloat` from gameplay events (`OnHit`, `OnKilled`, `OnPickup`).
– For UI, bind to `OnStatChanged` or poll getters.
– In MP, only the Server should mutate; clients call the provided RPCs.

**a.** Want a variant that also tracks rolling time windows (last 5/60 mins) and percentile metrics?
**b.** Generate a minimal UMG widget to render these stats with sorting/filtering?