Manage player statistics with Epic Online Services

Rajen Kishna, Technical Account Manager, Epic Games |
December 14, 2021
In the next three articles of this ‘Getting Started’ series, we’ll dig into three closely-related interfaces: Stats, Leaderboards, and Achievements. We’ll start out with Stats, which is used to manage player statistics, such as the number of items collected, the player's fastest completion time for a level, the total number of victories or losses, etc. In this article, we’ll go over:
 

Stats, Leaderboards, and Achievements

Before we dive into Stats, I want to take a moment to explain the correlation between Stats, Leaderboards, and Achievements. Stats provides the underlying system that tracks player statistics, which can be used to rank players using Leaderboards or automatically unlock Achievements. Stats can also be used stand-alone, without using these other features of Epic Online Services.

While the Achievements Interface offers APIs to manually trigger achievement unlocks instead of automatic unlocks based on Stats, Leaderboards can only exist in connection with an underlying Stat. We’ll go into more detail about these connections in the next two articles.

Changing our Client Policy

To use Stats, we must first add actions to our Client Policy:
 
  1. Log in to the Developer Portal at https://dev.epicgames.com/portal/.
  2. Navigate to your product > Product Settings in the left menu and click on the Clients tab in the product settings screen.
  3. Click on the three dots next to the client policy you’re using and click on Details
  4. Scroll down to Features and click on the toggle button next to Stats.
  5. Tick the boxes next to the “findStatsForLocalUser” and “ingestForLocalUser” actions.
    • It’s important to note here that we only use the minimal amount of actions we require, which helps prevent abuse of service calls.
  6. Click Save & Exit to confirm.
 
Developer Portal Client Policy Stats
Stats Client Policy allowed features and actions

Creating Stats in the Developer Portal

Stats are created in the Developer Portal and can be defined as one of four aggregation types:
 
  • SUM—a total of all ingested stats
  • LATEST—the last of all ingested stats
    • Note that this is the only type that can’t be used for automatic Achievement unlocks
  • MIN—the lowest integer of all ingested stats
  • MAX—the highest integer of all ingested stats

All the stat aggregation types rely on the stat being ingested as a single 32-bit integer.

Let’s define one stat for each aggregation type, so we can see their behavior in our sample app.
  1. Navigate to your product > Game Services > Stats in the left menu.
  2. Here we can see any existing stats for each deployment we have set up for our product. Select the deployment you’re using in the sample app and click on the blue “New Stat” button at the top-right of the screen.
  3. Enter a Name of “SumStat” and select the SUM aggregation type. Click on the blue “Create” button to finalize its creation.
  4. Repeat step three for the LATEST, MIN, and MAX aggregation type, naming the stats “LatestStat”, “MinStat”, and “MaxStat” respectively.

Developer Portal Stats
Stats in the Developer Portal

Note the connection icon in the top right corner next to the Search button. Clicking this will let us inspect which Leaderboards and Achievements our Stats are connected to. For now, we won’t see anything here, but we’ll revisit this in our upcoming Leaderboards and Achievements articles.

The last thing to note is the “Reset Player Stats” button next to the blue “New Stat” button. This will show us a flyout where we can search for a player by their PUID and reset any of their individual stats without affecting the stat definition itself.

Simulating a stat with mouse clicks

To simulate a stat in our sample app, we’ll set up a button with a click counter.
 
  1. Create a new User Control in the Views folder called StatsView:

<Grid>
    <Grid.ColumnDefinitions>
        <ColumnDefinition Width="*" />
        <ColumnDefinition Width="Auto" />
    </Grid.ColumnDefinitions>
    <Grid.RowDefinitions>
        <RowDefinition Height="Auto" />
        <RowDefinition Height="*" />
    </Grid.RowDefinitions>

    <StackPanel Grid.Column="1" Grid.Row="0" Grid.RowSpan="2">
        <Button Width="100" Height="23" Margin="2" Content="Ingest stats" Command="{Binding StatsIngest}" />
        <Button Width="100" Height="23" Margin="2" Content="Query stats" Command="{Binding StatsQuery}" />
    </StackPanel>

    <StackPanel Grid.Column="0" Grid.Row="0">
        <StackPanel Orientation="Horizontal">
            <TextBlock Text="Clicks:" Margin="2" />
            <TextBlock Text="{Binding Clicks}" Margin="2" />
        </StackPanel>

        <Button HorizontalAlignment="Left" Width="100" Height="23" Margin="2" Content="Add click" Command="{Binding StatsClick}" />
    </StackPanel>
</Grid>

 
  1. Open StatsView.xaml.cs to attach the ViewModel:

public partial class StatsView : UserControl
{
    public StatsViewModel ViewModel { get { return ViewModelLocator.Stats; } }

    public StatsView()
    {
        InitializeComponent();
        DataContext = ViewModel;
    }
}

 
  1. Add a StatsViewModel.cs class to the ViewModels folder:

public class StatsViewModel : BindableBase
{
    private int _clicks;
    public int Clicks
    {
        get { return _clicks; }
        set { SetProperty(ref _clicks, value); }
    }
}

 
  1. Add a reference to StatsViewModel in ViewModelLocator.cs:

private static StatsViewModel _statsViewModel;
public static StatsViewModel Stats
{
    get { return _statsViewModel ??= new StatsViewModel(); }
}

 
  1. Add a StatsClickCommand.cs class to the Commands folder:

public class StatsClickCommand : CommandBase
{
    public override bool CanExecute(object parameter)
    {
        return !string.IsNullOrWhiteSpace(ViewModelLocator.Main.ProductUserId);
    }

    public override void Execute(object parameter)
    {
        ViewModelLocator.Stats.Clicks++;
    }
}

 
  1. Open StatsViewModel.cs to declare and instantiate the command:

public StatsClickCommand StatsClick { get; set; }

public StatsViewModel()
{
    StatsClick = new StatsClickCommand();
}

 
  1. Add the following line to the RaiseConnectCanExecuteChanged() method in ViewModelLocator.cs to ensure we can only call Stats UI functionality after successfully logging in through the Connect Interface:

Stats.StatsClick.RaiseCanExecuteChanged();
 
  1. Lastly, add the StatsView to our TabControl in MainWindow.xaml:

<TabItem x:Name="Stats" Header="Stats">
    <views:StatsView />
</TabItem>


Now when we run the app and authenticate through Auth and Connect, we can go to the Stats tab and use the button to raise the click counter. We’ll use this to ingest our stats next.

App Stats Click
Simulating clicks in our app UI

Ingesting Stats

We’ll use the click counter that we just created to ingest into all four of our created Stats at the same time. This will highlight the differences in behavior between the aggregation types and simplify the stat ingestion call. 
 
  1. Add a StatsService.cs class to the Services folder to hold our ingestion logic:

public static class StatsService
{
    public static void Ingest(int count)
    {
        var ingestStatOptions = new IngestStatOptions()
        {
            LocalUserId = ProductUserId.FromString(ViewModelLocator.Main.ProductUserId),
            TargetUserId = ProductUserId.FromString(ViewModelLocator.Main.ProductUserId),
            Stats = new IngestData[]
            {
                new IngestData() { StatName = "SumStat", IngestAmount = count },
                new IngestData() { StatName = "LatestStat", IngestAmount = count },
                new IngestData() { StatName = "MinStat", IngestAmount = count },
                new IngestData() { StatName = "MaxStat", IngestAmount = count }
            }
        };

        ViewModelLocator.Main.StatusBarText = $"Ingesting stats (count: <{count}>)...";

        App.Settings.PlatformInterface.GetStatsInterface()
.IngestStat(ingestStatOptions, null, (IngestStatCompleteCallbackInfo ingestStatCompleteCallbackInfo) =>
        {
            Debug.WriteLine($"IngestStat {ingestStatCompleteCallbackInfo.ResultCode}");

            if (ingestStatCompleteCallbackInfo.ResultCode == Result.Success)
            {
                ViewModelLocator.Stats.Clicks = 0;
            }
            ViewModelLocator.Main.StatusBarText = string.Empty;
        });
    }
}

 
  • The calls here are pretty straightforward: we instantiate Stats.IngestStatOptions with our PUID and an array of stats we want to ingest.
  • We then call Stats.IngestStat, passing in the options structure to complete the ingestion.
 
  1. Add a StatsIngestCommand.cs class to the Commands folder:

public class StatsIngestCommand : CommandBase
{
    public override bool CanExecute(object parameter)
    {
        return !string.IsNullOrWhiteSpace(ViewModelLocator.Main.ProductUserId);
    }

    public override void Execute(object parameter)
    {
        StatsService.Ingest(ViewModelLocator.Stats.Clicks);
    }
}

 
  1. Open StatsViewModel.cs to declare and instantiate the command:

public StatsClickCommand StatsClick { get; set; }
public StatsIngestCommand StatsIngest { get; set; }

public StatsViewModel()
{
    StatsClick = new StatsClickCommand();
    StatsIngest = new StatsIngestCommand();
}

 
  1. Add the following line to the RaiseConnectCanExecuteChanged() method in ViewModelLocator.cs:

Stats.StatsIngest.RaiseCanExecuteChanged();

Now let’s run the app again, authenticate, and raise the click counter a bit. We can then click on the Ingest stats button to send our values to the back-end. Repeat this once more (clicking a few times and ingesting) and head to the Developer Portal to look at the results:
 
  1. Copy the ProductUserId from our app’s UI to the clipboard.
  2. Navigate to your product > Game Services > Stats in the left menu.
  3. Click on the “Reset Player Stats” button and in the flyout, paste the PUID we just copied into the search box and click on the “Search” button.

You should now see all four stats for this user, with the corresponding aggregation behavior.
 
Developer Portal Stats Reset Player Stats
Reset Player Stats UI in the Developer Portal

Querying Stats

The last thing we have to do is query these stats in our app, so we can view them directly, rather than through the Developer Portal:
 
  1. Add the following ListView in StatsView.xaml as the last child node of the main Grid element:

<ListView x:Name="StatsListView" Grid.Row="1" Margin="2" ItemsSource="{Binding Stats}">
    <ListView.View>
        <GridView>
            <GridViewColumn Header="Name" Width="200" DisplayMemberBinding="{Binding Name}">
                <GridViewColumn.HeaderContainerStyle>
                    <Style TargetType="{x:Type GridViewColumnHeader}">
                        <Setter Property="HorizontalContentAlignment" Value="Left" />
                    </Style>
                </GridViewColumn.HeaderContainerStyle>
            </GridViewColumn>
            <GridViewColumn Header="StartTime" Width="150" DisplayMemberBinding="{Binding StartTime}">
                <GridViewColumn.HeaderContainerStyle>
                    <Style TargetType="{x:Type GridViewColumnHeader}">
                        <Setter Property="HorizontalContentAlignment" Value="Left" />
                    </Style>
                </GridViewColumn.HeaderContainerStyle>
            </GridViewColumn>
            <GridViewColumn Header="EndTime" Width="150" DisplayMemberBinding="{Binding EndTime}">
                <GridViewColumn.HeaderContainerStyle>
                    <Style TargetType="{x:Type GridViewColumnHeader}">
                        <Setter Property="HorizontalContentAlignment" Value="Left" />
                    </Style>
                </GridViewColumn.HeaderContainerStyle>
            </GridViewColumn>
            <GridViewColumn Header="Value" Width="100" DisplayMemberBinding="{Binding Value}">
                <GridViewColumn.HeaderContainerStyle>
                    <Style TargetType="{x:Type GridViewColumnHeader}">
                        <Setter Property="HorizontalContentAlignment" Value="Left" />
                    </Style>
                </GridViewColumn.HeaderContainerStyle>
            </GridViewColumn>
        </GridView>
    </ListView.View>
</ListView>

 
  1. Open StatsViewModel.cs and add the following member:

private ObservableCollection<Stat> _stats;
public ObservableCollection<Stat> Stats
{
    get { return _stats; }
    set { SetProperty(ref _stats, value); }
}

 
  1. Open StatsService.cs and add the following method:

public static void Query()
{
    var queryStatsOptions = new QueryStatsOptions()
    {
        LocalUserId = ProductUserId.FromString(ViewModelLocator.Main.ProductUserId),
        TargetUserId = ProductUserId.FromString(ViewModelLocator.Main.ProductUserId)
    };

    ViewModelLocator.Main.StatusBarText = $"Querying stats...";

    App.Settings.PlatformInterface.GetStatsInterface()
.QueryStats(queryStatsOptions, null, (OnQueryStatsCompleteCallbackInfo onQueryStatsCompleteCallbackInfo) =>
    {
        Debug.WriteLine($"QueryStats {onQueryStatsCompleteCallbackInfo.ResultCode}");

        if (onQueryStatsCompleteCallbackInfo.ResultCode == Result.Success)
        {
            var getStatCountOptions = new GetStatCountOptions()
            {
                TargetUserId = ProductUserId.FromString(ViewModelLocator.Main.ProductUserId)
            };
            var statCount = App.Settings.PlatformInterface
.GetStatsInterface().GetStatsCount(getStatCountOptions);

            for (uint i = 0; i < statCount; i++)
            {
                var copyStatByIndexOptions = new CopyStatByIndexOptions()
                {
                    StatIndex = i,
                    TargetUserId = ProductUserId.FromString(ViewModelLocator.Main.ProductUserId)
                };
                var result = App.Settings.PlatformInterface.GetStatsInterface()
.CopyStatByIndex(copyStatByIndexOptions, out var stat);

                if (result == Result.Success)
                {
                    ViewModelLocator.Stats.Stats.Add(stat);
                }
            }
        }

        ViewModelLocator.Main.StatusBarText = string.Empty;
    });
}
 
  1. Add a StatsQueryCommand.cs class to the Commands folder:

public class StatsQueryCommand : CommandBase
{
    public override bool CanExecute(object parameter)
    {
        return !string.IsNullOrWhiteSpace(ViewModelLocator.Main.ProductUserId);
    }

    public override void Execute(object parameter)
    {
        ViewModelLocator.Stats.Stats = new ObservableCollection<Stat>();
        StatsService.Query();
    }
}

 
  1. Open StatsViewModel.cs to declare and instantiate the command:

public StatsClickCommand StatsClick { get; set; }
public StatsIngestCommand StatsIngest { get; set; }
public StatsQueryCommand StatsQuery { get; set; }

public StatsViewModel()
{
    StatsClick = new StatsClickCommand();
    StatsIngest = new StatsIngestCommand();
    StatsQuery = new StatsQueryCommand();
}

 
  1. Add the following line to the RaiseConnectCanExecuteChanged() method in ViewModelLocator.cs:

Stats.StatsQuery.RaiseCanExecuteChanged();

Now we can launch our app again and use the Query stats button to view the current player’s stats directly in the UI:
 
App Stats Query
Query stats in our app UI

Usage limitations

Stats has usage limitations in place to ensure reliability and availability for all users. At the time of writing, these are the limitations (be sure to check the documentation for the most up-to-date information):
  • 500 total stat definitions per deployment
  • 3000 player stats maximum per ingestion call
  • 256 character stat name length

Additionally, there are per-user or per-deployment rate limits for API calls for client or server calls respectively, which you can find in the documentation.

Get the code

Get the code for this article below. Follow the Usage instructions in the GitHub repo to set up the downloaded code.
Now that we know how to manage player statistics, we can dive into the ranking of these scores using EOS Leaderboards next.

The full list of articles in this series can be found in the series reference. For feedback or questions, head over to the Community forum.

    We succeed when you succeed

    Epic believes in an open, integrated games community.
    By offering our online services to everyone for free, we aim to empower more developers to serve their own player communities.