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
- Changing our Client Policy
- Creating Stats in the Developer Portal
- Simulating a stat with mouse clicks
- Ingesting Stats
- Querying Stats
- Usage limitations
- Get the code
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:
- Log in to the Developer Portal at https://dev.epicgames.com/portal/.
- Navigate to your product > Product Settings in the left menu and click on the Clients tab in the product settings screen.
- Click on the three dots next to the client policy you’re using and click on Details
- Scroll down to Features and click on the toggle button next to Stats.
- 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.
- Click Save & Exit to confirm.
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.
- Navigate to your product > Game Services > Stats in the left menu.
- 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.
- Enter a Name of “SumStat” and select the SUM aggregation type. Click on the blue “Create” button to finalize its creation.
- Repeat step three for the LATEST, MIN, and MAX aggregation type, naming the stats “LatestStat”, “MinStat”, and “MaxStat” respectively.
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.
- Create a new User Control in the Views folder called StatsView:
<StackPanel Grid.Column="1" Grid.Row="0" Grid.RowSpan="2"> <StackPanelGrid.Column="0" Grid.Row="0"> <Button HorizontalAlignment="Left" Width="100" Height="23" Margin="2" Content="Add click" Command="{Binding StatsClick}" /><Grid>
<Grid.ColumnDefinitions>
<ColumnDefinition Width="*" />
<ColumnDefinition Width="Auto" />
</Grid.ColumnDefinitions>
<Grid.RowDefinitions>
<RowDefinition Height="Auto" />
<RowDefinition Height="*" />
</Grid.RowDefinitions>
<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>
<StackPanelOrientation="Horizontal">
<TextBlock Text="Clicks:" Margin="2" />
<TextBlock Text="{Binding Clicks}" Margin="2" />
</StackPanel>
</StackPanel>
</Grid>
- Open StatsView.xaml.cs to attach the ViewModel:
public StatsView()public partial class StatsView : UserControl
{
public StatsViewModel ViewModel { get { return ViewModelLocator.Stats; } }
{
InitializeComponent();
DataContext = ViewModel;
}
}
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); }
}
}
- Add a reference to StatsViewModel in ViewModelLocator.cs:
private static StatsViewModel _statsViewModel;
public static StatsViewModel Stats
{
get { return _statsViewModel ??= new StatsViewModel(); }
}
- Add a StatsClickCommand.cs class to the Commands folder:
public override void Execute(object parameter)public class StatsClickCommand : CommandBase
{
public override bool CanExecute(object parameter)
{
return !string.IsNullOrWhiteSpace(ViewModelLocator.Main.ProductUserId);
}
{
ViewModelLocator.Stats.Clicks++;
}
}
- Open StatsViewModel.cs to declare and instantiate the command:
public StatsViewModel()public StatsClickCommand StatsClick { get; set; }
{
StatsClick = new StatsClickCommand();
}
- 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();
- 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.
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.
- Add a StatsService.cs class to the Services folder to hold our ingestion logic:
ViewModelLocator.Main.StatusBarText = $"Ingesting stats (count: <{count}>)..."; App.Settings.PlatformInterface.GetStatsInterface() if (ingestStatCompleteCallbackInfo.ResultCode ==Result.Success)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 }
}
};
.IngestStat(ingestStatOptions, null, (IngestStatCompleteCallbackInfo ingestStatCompleteCallbackInfo) =>
{
Debug.WriteLine($"IngestStat {ingestStatCompleteCallbackInfo.ResultCode}");
{
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.
- Add a StatsIngestCommand.cs class to the Commands folder:
public override void Execute(object parameter)public class StatsIngestCommand : CommandBase
{
public override bool CanExecute(object parameter)
{
return !string.IsNullOrWhiteSpace(ViewModelLocator.Main.ProductUserId);
}
{
StatsService.Ingest(ViewModelLocator.Stats.Clicks);
}
}
- Open StatsViewModel.cs to declare and instantiate the command:
public StatsViewModel()public StatsClickCommand StatsClick { get; set; }
public StatsIngestCommand StatsIngest { get; set; }
{
StatsClick = new StatsClickCommand();
StatsIngest = new StatsIngestCommand();
}
- 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:
- Copy the ProductUserId from our app’s UI to the clipboard.
- Navigate to your product > Game Services > Stats in the left menu.
- 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.
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:
- 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>
- Open StatsViewModel.cs and add the following member:
private ObservableCollection<Stat> _stats;
public ObservableCollection<Stat> Stats
{
get { return _stats; }
set { SetProperty(ref _stats, value); }
}
- Open StatsService.cs and add the following method:
ViewModelLocator.Main.StatusBarText = $"Querying stats..."; App.Settings.PlatformInterface.GetStatsInterface() if (onQueryStatsCompleteCallbackInfo.ResultCode == Result.Success) for (uint i = 0; i < statCount; i++) if (result ==Result.Success) ViewModelLocator.Main.StatusBarText = string.Empty;public static void Query()
{
var queryStatsOptions = new QueryStatsOptions()
{
LocalUserId = ProductUserId.FromString(ViewModelLocator.Main.ProductUserId),
TargetUserId = ProductUserId.FromString(ViewModelLocator.Main.ProductUserId)
};
.QueryStats(queryStatsOptions, null, (OnQueryStatsCompleteCallbackInfo onQueryStatsCompleteCallbackInfo) =>
{
Debug.WriteLine($"QueryStats {onQueryStatsCompleteCallbackInfo.ResultCode}");
{
var getStatCountOptions = new GetStatCountOptions()
{
TargetUserId = ProductUserId.FromString(ViewModelLocator.Main.ProductUserId)
};
var statCount = App.Settings.PlatformInterface
.GetStatsInterface().GetStatsCount(getStatCountOptions);
{
var copyStatByIndexOptions = new CopyStatByIndexOptions()
{
StatIndex = i,
TargetUserId = ProductUserId.FromString(ViewModelLocator.Main.ProductUserId)
};
var result = App.Settings.PlatformInterface.GetStatsInterface()
.CopyStatByIndex(copyStatByIndexOptions, out var stat);
{
ViewModelLocator.Stats.Stats.Add(stat);
}
}
}
});
}
- We use Stats.QueryStats to populate our cache with the stats from Epic Online Services.
- You can optionally specify an array of stat names in Stats.QueryStatOptions to only query specific stats.
- We then use Stats.GetStatsCount to know how many stats we have to iterate over and use Stats.CopyStatByIndex to retrieve the stat data from the cache.
- Add a StatsQueryCommand.cs class to the Commands folder:
public override void Execute(object parameter)public class StatsQueryCommand : CommandBase
{
public override bool CanExecute(object parameter)
{
return !string.IsNullOrWhiteSpace(ViewModelLocator.Main.ProductUserId);
}
{
ViewModelLocator.Stats.Stats = new ObservableCollection<Stat>();
StatsService.Query();
}
}
- Open StatsViewModel.cs to declare and instantiate the command:
public StatsViewModel()public StatsClickCommand StatsClick { get; set; }
public StatsIngestCommand StatsIngest { get; set; }
public StatsQueryCommand StatsQuery { get; set; }
{
StatsClick = new StatsClickCommand();
StatsIngest = new StatsIngestCommand();
StatsQuery = new StatsQueryCommand();
}
- 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:
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.