Setting up a Materials Module #

This tutorial will walk you through implementing a player data feature for managing collectable resource items. We’ll call these items materials, and in our tutorial scenario, players will collect and use these materials in-game.

Material data needs to be modeled with two properties: a unique identifier and a numerical balance. Players will gain and lose materials throughout their playtime, so we should expect each material’s balance to frequently change. The game client and game servers will need endpoints for modifying and managing each player’s materials.

To create such a feature, we’ll author a Player Data Module called Materials using the Player Data Service. By the end of this tutorial, this module will include:

  • A data model for persisted materials.
  • An API for game servers and Pragma Engine services to grant and add to a material’s balance.
  • An API for the game client, game servers, and Pragma Engine services to deduct from a material’s balance.
  • Handling validation and expected error flows for when an update would result in a negative balance, go over a defined limit, or result in an arithmetic overflow.

Get started #

If you haven’t already built Pragma Engine and your project, see the Pragma Engine Quickstart guide.

Run the following command in a terminal from the platform directory to start with a clean build of your project.

./pragma build project -s

1. Define the data model #

Goal #

Define a class to persist and contain unique material data per player.

Steps #

Add the Materials Module directory #

Player Data code needs to be defined under [project-name]/[project-name]-player-data/player-data/. We recommend organizing each Player Data Module under its own directory so your module’s code shares a common package.

  1. Create a new package at [project-name]/[project-name]-player-data/player-data/src/main/kotlin/materials/.

Define the class for material data #

Now let’s define a class to persist and contain players’ unique material data. This class needs two properties: a unique string identifier and an integer balance. The Player Data service identifies classes for persisted player data with the Pragma Engine defined Component interface. Any class you define that represents persisted player data must inherit Component.

  1. Create a new Kotlin file named MaterialsComponents.kt under the materials directory.
  2. Copy the following code into the file.
    • The @SerializedName annotation is required by Pragma Engine. Provide a unique string identifier.
    • The @FieldNumber annotation is required on each property and is required by Pragma Engine for serialization. Note that id is an immutable property and balance is mutable and will get updated frequently.
// Generate the serialized name
@SerializedName("TIMESTAMP-IN-EPOCH")
data class Material(
    @FieldNumber(1) val materialId: String,
    @FieldNumber(2) var balance: Int
): Component
See the Supported Code Generation Types reference section for all the supported property types.

2. Author the Materials Module #

Now that the data model is complete, we need to add the functionality for creating and updating Material data.

Goal #

Define the APIs available for the game server, client, and other Pragma Services to modify material data.

Steps #

To implement this logic in Pragma, we need to author some Player Data Operations.

Player Data Operations are functions that can modify player’s data by creating, updating, and deleting Components. These operations are also used to generate methods available in the Pragma SDK for your game client and gamer server to use.

For our Materials Module, we’re going to define two PlayerDataOperations: one that lets you grant and add a material, and one that lets you deduct from the balance.

Define the Request and Response payloads for each Player Data Operation #

First we need to define the payload classes of the request and response for each PlayerDataOperation. Pragma Engine identifies these classes through the PlayerDataRequest and PlayerDataResponse interfaces. Each PlayerDataOperation requires its own unique PlayerDataRequest and PlayerDataResponse classes.

  1. Create a new Kotlin file named MaterialsRpcs.kt under the materials directory.
  2. Copy the following code into the file. All the PlayerDataRequest classes contain a String property for materialId and an Int value for the amount attached to the requests.
data class DeductMaterial(
    val materialId: String,
    val amount: Int
): PlayerDataRequest
class DeductedMaterial: PlayerDataResponse

data class AddMaterial(
    val materialId: String,
    val amount: Int
): PlayerDataRequest
class MaterialAdded: PlayerDataResponse

Author the data modifying logic. #

Now we’re going to define the Player Data Operations.

  1. Create a new Kotlin file named MaterialsSubService.kt under the materials directory.

Define the class to house the Player Data Operations #

PlayerDataOperations must always be housed in a class inheriting from PlayerDataSubService. This class requires a constructor that takes one parameter: contentLibrary: PlayerDataContentLibrary. This object provides access for loading content data catalogs.

  1. Copy the following class into the MaterialsSubService.kt file.
class Materials(
    contentLibrary: PlayerDataContentLibrary
): PlayerDataSubService(contentLibrary) {}

Before we define the PlayerDataOperations, let’s take a close look at the interface of a PlayerDataOperation function and how to define one.

// Required annotation
@PlayerDataOperation(sessionTypes = [SessionType.PLAYER, SessionType.PARTNER]) fun playerDataOperationExample( // Name of function - defined by you
    request: RequestForThisOperation, // Request payload - defined by you
    context: Context // Pragma provided class with access to player's data and other utilities
): ResponseForThisOperation { // Response payload - defined by you
   // implement logic to add and update data using context.snapshot
   // return the response class you defined for this operation
   return ResponseForThisOperation()
}

All Player Data Operations require the Pragma Engine defined annotation @PlayerDataOperation. This annotation takes one parameter: a list of the sessionTypes to control access to this operation. Below are the available sessionTypes and their use cases:

  • PLAYER provides the game client access
  • PARTNER provides the game server access
  • SERVICE provides access to other Pragma services and Plugins
  • OPERATOR provides the Pragma Portal access

Define the Player Data Operations #

We’ll define two PlayerDataOperations: addMaterial() and deductMaterial().

addMaterial() can only be called from game servers and other Pragma Engine services and Plugins, whereas deductMaterial() can be called from the game client, game servers, and other Pragma Engine services and Plugins.

  1. Add the Player Data Operations to your Materials class. The file should now look like the code block below:
class Materials(
    contentLibrary: PlayerDataContentLibrary
): PlayerDataSubService(contentLibrary) {

    @PlayerDataOperation(sessionTypes = [SessionType.PARTNER, SessionType.SERVICE])
    fun addMaterial(request: AddMaterial, context: Context): MaterialAdded {
        updateMaterial(request.materialId, request.amount, context)
        return MaterialAdded()
    }

    @PlayerDataOperation(sessionTypes = [SessionType.PLAYER, SessionType.PARTNER, SessionType.SERVICE])
    fun deductMaterial(request: DeductMaterial, context: Context): DeductedMaterial {
        updateMaterial(request.materialId, (-1 * request.amount), context)
        return DeductedMaterial()
    }

    companion object {
        const val MATERIALS_ENTITY_ID = "materials"
    }

    private fun updateMaterial(materialId: String, amount: Int, context: Context) {
        // have one Entity to hold all Material Components
        val materialsEntity = context.snapshot.getOrCreateUniqueEntity(MATERIALS_ENTITY_ID, ::emptyList)
        // check if there is a Material component for this materialId - if not, add one
        materialsEntity.getComponentsByType<Material>().singleOrNull { it.materialId == materialId }
            ?: materialsEntity.addComponent(Material(materialId = materialId, balance = 0))
        // filter out the component to update
        val materialsToUpdate = materialsEntity.getComponentsByType<Material>().single { it.materialId == materialId }
        materialsToUpdate.balance += amount // updates the balance
    }
}

In this example, we’ve decided to put all material Components under one Entity. Entity is a class in Player Data that is used to hold a collection of Components. We are using context.snapshot.getOrCreateUniqueEntity() to ensure each player has one Entity named “materials”. You can add any number of Components to an Entity with the Entity.addComponent function. In our logic, we check if there is a Component for the materialId, and if not we add a Material Component. From there, we can then update the balance property.

To see all of the helper functions available for managing Entities and Components, see the Reference documentation.

3. Validate and handle expected error flows #

The Materials Module is now up and running! However, the module currently does not have any validation and there is no way to prevent unwanted balances such as a negative balance or ability to cap at a limit.

To finish the Module, let’s add logic to handle expected problems and to send a custom error response back to the caller to indicate why the update failed.

Goal #

Define a catalog of valid material ids and a custom error response for expected failure cases.

Steps #

Catalog the valid material types #

To define a catalog of valid materials, we’re going to use Pragma’s Content Data system.

To add a catalog using the Content Data system we will need to: define the structure of this data using protobufs, define a Pragma Engine ContentHandler class, and catalog the data in JSON.


Define the Protobuf: #
  1. Create a new directory at [project-name]/[project-name]-protos/src/main/proto/[project-name]/contentdata/.
  2. Create a new protobuf file in that directory called materials.proto.
  3. Copy the following code into the materials.proto file. The MaterialTemplate message will be used to store the material’s id property (iron, copper, silver, etc.), and the max_balance property for how many of this material a player can have.
syntax = "proto3";

package [project-name].customcontent;

import "pragmaOptions.proto";

option csharp_namespace = "CustomContent";
option (pragma.unreal_namespace) = "CustomContent";

message MaterialTemplate {
  string material_id = 1;
  int32 max_balance = 2;
}
  1. Generate the proto messages so they’re available to use in the project.
./pragma build project-protos

Define the Content Handler: #

Content Handlers are classes that are responsible for loading, validating, and providing access to content data. When implementing, you must override the function idFromProto and have it return the String property that represents the id.

  1. Add a new Kotlin file named ContentHandlers.kt under the materials directory..
  2. Copy the following code into the ContentHandlers.kt file to author the MaterialsContentHandler class.
class MaterialsContentHandler: ContentHandler<MaterialTemplate>(MaterialTemplate::class) {
    override fun idFromProto(proto: MaterialTemplate): String {
        return proto.materialId
    }
}

Catalog the JSON files: #
  1. Run the following contentdata init command to generate the MaterialTemplate content files. You can find these files under [project]/content/src/.
java -jar [project]/target/pragma.jar contentdata init -d [project]/content -c MaterialsContentHandler MaterialTemplate
  1. Copy the following code in the MaterialTemplate.json. This file will act as the catalog of rules and behaviors for material data, which will help validate material requests.
[
  {
    "materialId" : "iron_ore",
    "maxBalance" : 999
  },
  {
    "materialId" : "iron_ingot",
    "maxBalance" : 999
  },
  {
    "materialId" : "copper_ore",
    "maxBalance" : 999
  },
  {
    "materialId" : "copper_ingot",
    "maxBalance" : 999
  },
  {
    "materialId" : "silver_ore",
    "maxBalance" : 999
  },
  {
    "materialId" : "silver_ingot",
    "maxBalance" : 999
  },
  {
    "materialId" : "gold_ore",
    "maxBalance" : 999
  },
  {
    "materialId" : "gold_ingot",
    "maxBalance" : 999
  },
  {
    "materialId" : "wood",
    "maxBalance" : 9999
  },
  {
    "materialId" : "wood_planks",
    "maxBalance" : 9999
  },
  {
    "materialId" : "stone",
    "maxBalance" : 9999
  },
  {
    "materialId" : "refined_stone_slab",
    "maxBalance" : 9999
  },
  {
    "materialId" : "hero_tokens",
    "maxBalance" : 99
  }
]

Register the content data changes by using a provided run configuration in IntelliJ.

  • From the IntelliJ toolbar in the upper right corner, ensure contentdata apply is selected and then click the play button.

Handle expected failures #

We’re going to handle some expected error flows using Pragma Application Errors

Application Errors are an error type in Pragma Engine for handling expected error cases. They are thrown using the Pragma defined type ApplicationErrorException and are always thrown with a proto as the payload to inform the caller what went wrong.

To make a custom Application Error, we need to define a custom proto to use when throwing an ApplicationErrorException in the Materials module.

  1. Create a new package at [project-name]/[project-name]-protos/src/main/proto/[project-name]/playerdata.
  2. In the newly created package, add a new protobuf file named applicationErrors.proto.
  3. Copy the following code into the applicationErrors.proto file. The MaterialApplicationError message uses a string to relay the error’s message back to the client and will utilize the MaterialErrorTypes enum to decide what error type is sent.
syntax = "proto3";

package [project-name].playerdata;

import "pragmaOptions.proto";

option csharp_namespace = "PlayerData";
option (pragma.unreal_namespace) = "PlayerData";

enum MaterialErrorTypes {
  UNKNOWN = 0;
  BALANCE_OVERFLOW = 1;
  LIMIT_EXCEEDED = 2;
  NEGATIVE_BALANCE = 3;
  INVALID_TYPE = 4;
  TYPE_ALREADY_ACQUIRED = 5;
  INSUFFICIENT_REQUEST_TYPE_AMOUNT = 6;
}

message MaterialApplicationError {
  option (pragma.force_include_type) = true;

  string message = 1;
  MaterialErrorTypes error_type = 2;
}
Every proto used with application errors must include option (pragma.force_include_type) = true; field. This ensures that an SDK type is generated from this proto.
  1. Generate the protos.
./pragma build project-protos

Update the Materials Module #

We can now update the Materials Module and add the validation and error handling. For each operation’s request, we’ll validate that the materialId is part of the Materials content data catalog. We’ll also enforce that the amount on the request is a positive value. Then we’ll add in some requirements so the balance of a material cannot be negative and will not go over a max balance as defined in the content catalog. If any problem occurs, we throw an ApplicationErrorException and return the MaterialApplicationError back to the caller.

  1. Replace everything in the [project-name]/[project-name]-player-data/player-data/src/main/kotlin/materials/MaterialsSubService.kt file with the following code that includes validation and error handling.
class Materials(
    contentLibrary: PlayerDataContentLibrary
): PlayerDataSubService(contentLibrary) {
    // PlayeDataOperation that lets you deduct from a material balance - allowed on all session types
    @PlayerDataOperation(sessionTypes = [SessionType.PLAYER, SessionType.PARTNER, SessionType.SERVICE])
    fun deductMaterial(request: DeductMaterial, context: Context): DeductedMaterial {
        applicationRequire(request.amount >= 0 ) {
            materialApplicationError( MaterialErrorTypes.INSUFFICIENT_REQUEST_TYPE_AMOUNT)
        }

        updateMaterial(request.materialId, (-1 * request.amount), context)
        return DeductedMaterial()
    }

    // PlayerDataOperation that lets you add to a material balance - allowed on backend trusted session types
    @PlayerDataOperation(sessionTypes = [SessionType.PARTNER, SessionType.SERVICE])
    fun addMaterial(request: AddMaterial, context: Context): MaterialAdded {
        applicationRequire(request.amount >= 0 ) {
            materialApplicationError( MaterialErrorTypes.INSUFFICIENT_REQUEST_TYPE_AMOUNT)
        }

        updateMaterial(request.materialId, request.amount, context)
        return MaterialAdded()
    }

    companion object {
        const val MATERIALS_ENTITY_ID = "materials"
    }

    // access content data to use with validation
    private fun materialsTemplates(): ContentData<MaterialTemplate> = contentLibrary.getContentData("MaterialTemplate.json")

    // build out the application error proto to return when expected error occurs
    private fun materialApplicationError(
        errorType: MaterialErrorTypes
    ): MaterialApplicationError {
        return MaterialApplicationError.newBuilder().setErrorType(errorType).build()
    }

    private fun updateMaterial(materialId: String, amount: Int, context: Context) {
        // use the content data catalog to validate the materialId sent on the request
        val materialTemplates = materialsTemplates()
        applicationRequire(materialsTemplates().contains(materialId)) { materialApplicationError(MaterialErrorTypes.INVALID_TYPE) }
        val materialTemplate = materialTemplates[materialId]!!

        val materialsEntity = context.snapshot.getOrCreateUniqueEntity(MATERIALS_ENTITY_ID, ::emptyList)
        materialsEntity.getComponentsByType<Material>().singleOrNull { it.materialId == materialId }
            ?: materialsEntity.addComponent(Material(materialId, balance = 0))

        val materialsToUpdate = materialsEntity.getComponentsByType<Material>().single { it.materialId == materialId }
        val currentBalance = materialsToUpdate.balance

        // validate updating the balance will not result in unwanted states
        applicationRequire(sumWillNotOverflow(currentBalance, amount)) { materialApplicationError(MaterialErrorTypes.BALANCE_OVERFLOW) }
        applicationRequire(currentBalance + amount >= 0) { materialApplicationError(MaterialErrorTypes.NEGATIVE_BALANCE) }
        applicationRequire(amount + currentBalance <= materialTemplate.maxBalance) { materialApplicationError(MaterialErrorTypes.LIMIT_EXCEEDED) }

        materialsToUpdate.balance += amount
    }

    private fun sumWillNotOverflow(a: Int, b: Int): Boolean {
        return try {
            Math.addExact(a, b)
            true
        } catch(e: ArithmeticException) {
            false
        }
    }
}

Pragma provides some helpers when throwing an ApplicationErrorException. In this example, we are using the Prama provided function applicationRequire(). To learn more about all the helper functions available for throwing Application Errors, see the Custom Error docs on using the ApplicationErrorException to throw application errors.

4. Generate APIs for Game Client and Server #

Now we need the ability to call the Player Data Operations from our game client and server.

Goal #

Make the APIs available for the game client and server.

Steps #

Now that we have our Materials Module, we can generate the Pragma SDK. This will include methods to call the Player Data Operations directly from the game client and/or game server.

Generate the SDK #

Unreal

To generate the Unreal SDK run:

./pragma sdk generate

The Pragma SDK will come with functions to call the Materials Player Data Operations directly. You can find the generated SDK in [project-name]/[project-name]-player-data/player-data-generated-types/target/unreal-sdk/.

Let’s take a look at the MaterialsSubServicePartner.h file. This file shows the game server will have access to call both operations AddMaterial and DeductMaterial. Remember: this is controlled through the @PlayerDataOperation annotation.

#pragma once

/* This file was auto-generated, and should not be manually edited. See pragma.playerdata.codegen.generators.SubServiceUnrealGenerator */

#include "Dto/MaterialsSubServiceDelegates.h"
#include "Dto/PlayerDataCommon.h"
#include "Dto/PragmaPlayerDataTypesDto.h"

class UPragmaPlayerDataPartnerServiceRaw;

namespace PragmaPartnerPlayerData {

class PRAGMASDK_API MaterialsSubService {
public:
    void SetDependencies(FPlayerDataRequestDelegate Delegate);            
    
    void AddMaterial(const FString& PlayerId, const FString& MaterialId, int32 Amount, FOnMaterialsAddMaterialDelegate Delegate) const;
    void DeductMaterial(const FString& PlayerId, const FString& MaterialId, int32 Amount, FOnMaterialsDeductMaterialDelegate Delegate) const;
    
private:
    FPlayerDataRequestDelegate RequestDelegate;
};

}

Game Servers can access Player Data Operations through the FServer/FServerPtr.

Server()->PlayerDataService().Materials().AddMaterial(PlayerId, "materialId", 100, FOnMaterialsAddMaterialDelegate::CreateLambda([Server](
	TPragmaResult<FPragma_PlayerData_MaterialAddedProto> Result) {
		if (Result.IsSuccessful())
		{
			const FPragma_PlayerData_MaterialAddedProto Response = Result.Payload<>();
		}
		else
		{
			// check for expected application errors
			if (Result.GetErrorType() == FPragma_PlayerData_MaterialApplicationError::StaticStruct())
			{
				FPragma_PlayerData_MaterialApplicationError MaterialApplicationError = Result.ReadError<FPragma_PlayerData_MaterialApplicationError>();
				// log, inform player, etc ... 
			}
		}
	}));});

The MaterialsSubServicePlayer.h file shows the game client can only call the DeductMaterial operation.

#pragma once

/* This file was auto-generated, and should not be manually edited. See pragma.playerdata.codegen.generators.SubServiceUnrealGenerator */

#include "Dto/MaterialsSubServiceDelegates.h"
#include "Dto/PlayerDataCommon.h"
#include "Dto/PragmaPlayerDataTypesDto.h"

class UPragmaPlayerDataServiceRaw;

namespace PragmaPlayerData {

class PRAGMASDK_API MaterialsSubService {
public:
    void SetDependencies(FPlayerDataRequestDelegate Delegate);            
    
    void DeductMaterial(const FString& MaterialId, int32 Amount, FOnMaterialsDeductMaterialDelegate Delegate) const;
    
private:
    FPlayerDataRequestDelegate RequestDelegate;
};

}

Game clients can access Player Data Operations through FPlayer/FPlayerPtr.

Player()->PlayerDataService().Materials().DeductMaterial("materialId", 100, FOnMaterialsDeductMaterialDelegate::CreateLambda([Player](
	TPragmaResult<FPragma_PlayerData_DeductedMaterialProto> Result
	) {
		if (Result.IsSuccessful())
		{
			const FPragma_PlayerData_DeductedMaterialProto Response = Result.Payload<>();
			// Delegates are executed after the client side cache has been updated - pull Entity and Component data from the cache  
			const TSharedPtr<FPlayerDataCache> Entities = Player()->PlayerDataService().GetCache().Pin();
			auto MaterialEntity = Entities->GetEntitiesByName("materials");
		}
		else
		{
			// check for expected application errors
			if (Result.GetErrorType() == FPragma_PlayerData_MaterialApplicationError::StaticStruct())
			{
                     FPragma_PlayerData_MaterialApplicationError MaterialApplicationError = Result.ReadError<FPragma_PlayerData_MaterialApplicationError>();
// can log, inform player, etc ... 
			}
		}
	}));
});
Unity

To generate the Unity SDK run:

./pragma sdk generate

To see the generated Unity SDK, look in [project-name]/[project-name]-player-data/player-data-generated-types/target/unity-sdk/.

5. Test the Module #

Congratulations! You now have a fully functioning Materials Module. To see our work in action, let’s test the Materials Module by logging into a test player account and granting materials to the player.

Start Pragma Engine #

Run Pragma Engine via one of the following methods.

Running via Command Line
From your pragma-engine/platform working directory, run ./pragma run to start the platform.
Running in IntelliJ
Run Pragma from the IDE by selecting the ‘Run Pragma’ run configuration from the dropdown in the upper right.

Once the engine has started successfully, it prints the message INFO main - Pragma server startup complete.

Simulate Operation calls #

Test everything we’ve built so far by running the required authentication calls, granting materials to a test player, and running a material exchange Request.

  1. Open Postman.
  2. Navigate to the two service calls: PragmaDev ➨ Public ➨ Operator - AuthenticateOrCreateV2 and PragmaDev ➨ Public ➨ Player - AuthenticateOrCreateV2.
  3. Click Send for both service calls and check that the response body for each call has pragmaTokens with a filled pragmaGameToken and pragmaSocialToken.
  4. Navigate to Game ➨ RPC - Partner ➨ Player Data ➨ DoOperationPartner
  5. Copy the following code into the RPC’s body and click Send. This call will grant 5 iron_ore to a player, but you can replace the materialId with any corresponding material defined in MaterialTemplate.json.
{
    "requestId": 1,
    "type": "PlayerDataServiceRpc.DoOperationPartnerV1Request",
    "payload": {
        "playerId": "{{test01PlayerId}}",
        "ext": {
            "addMaterial": {
                "materialId": "iron_ore",
                "amount": 5
            }
        }
    }
}
If you want to see the player’s serialized data, including these materials, run Game ➨ RPC - Partner ➨ Player Data ➨ GetPartner.
  1. Copy the following code into the RPC’s body and click Send. This call will deduct 3 iron_ore to a player.
{
    "requestId": 1,
    "type": "PlayerDataServiceRpc.DoOperationPartnerV1Request",
    "payload": {
        "playerId": "{{test01PlayerId}}",
        "ext": {
            "deductMaterial": {
                "materialId": "iron_ore",
                "amount": 3
            }
        }
    }
}
  1. Edit the RPC’s body from the code above with an amount of 5 and click Send. This should send back an application error with the response NEGATIVE_BALANCE. Run other requests with an invalid amount, negative balance, overflow amount, or invalid materialId to test all the application errors.

Appendix #

[project-name]/[project-name]-player-data/player-data/src/main/kotlin/materials/MaterialsComponents.kt
// Generate the serialized name
@SerializedName("TIMESTAMP-IN-EPOCH")
data class Material(
    @FieldNumber(1) val materialId: String,
    @FieldNumber(2) var balance: Int
): Component
[project-name]/[project-name]-player-data/player-data/src/main/kotlin/materials/MaterialsRpcs.kt
data class DeductMaterial(
    val materialId: String,
    val amount: Int
): PlayerDataRequest
class DeductedMaterial: PlayerDataResponse

data class AddMaterial(
    val materialId: String,
    val amount: Int
): PlayerDataRequest
class MaterialAdded: PlayerDataResponse
[project-name]/[project-name]-player-data/player-data/src/main/kotlin/materials/MaterialsSubService.kt
class Materials(
    contentLibrary: PlayerDataContentLibrary
): PlayerDataSubService(contentLibrary) {
    // PlayeDataOperation that lets you deduct from a material balance - allowed on all session types
    @PlayerDataOperation(sessionTypes = [SessionType.PLAYER, SessionType.PARTNER, SessionType.SERVICE])
    fun deductMaterial(request: DeductMaterial, context: Context): DeductedMaterial {
        applicationRequire(request.amount >= 0 ) {
            materialApplicationError( MaterialErrorTypes.INSUFFICIENT_REQUEST_TYPE_AMOUNT)
        }

        updateMaterial(request.materialId, (-1 * request.amount), context)
        return DeductedMaterial()
    }

    // PlayerDataOperation that lets you add to a material balance - allowed on backend trusted session types
    @PlayerDataOperation(sessionTypes = [SessionType.PARTNER, SessionType.SERVICE])
    fun addMaterial(request: AddMaterial, context: Context): MaterialAdded {
        applicationRequire(request.amount >= 0 ) {
            materialApplicationError( MaterialErrorTypes.INSUFFICIENT_REQUEST_TYPE_AMOUNT)
        }

        updateMaterial(request.materialId, request.amount, context)
        return MaterialAdded()
    }

    companion object {
        const val MATERIALS_ENTITY_ID = "materials"
    }

    // access content data to use with validation
    private fun materialsTemplates(): ContentData<MaterialTemplate> = contentLibrary.getContentData("MaterialTemplate.json")

    // build out the application error proto to return when expected error occurs
    private fun materialApplicationError(
        errorType: MaterialErrorTypes
    ): MaterialApplicationError {
        return MaterialApplicationError.newBuilder().setErrorType(errorType).build()
    }

    private fun updateMaterial(materialId: String, amount: Int, context: Context) {
        // use the content data catalog to validate the materialId sent on the request
        val materialTemplates = materialsTemplates()
        applicationRequire(materialsTemplates().contains(materialId)) { materialApplicationError(MaterialErrorTypes.INVALID_TYPE) }
        val materialTemplate = materialTemplates[materialId]!!

        val materialsEntity = context.snapshot.getOrCreateUniqueEntity(MATERIALS_ENTITY_ID, ::emptyList)
        materialsEntity.getComponentsByType<Material>().singleOrNull { it.materialId == materialId }
            ?: materialsEntity.addComponent(Material(materialId, balance = 0))

        val materialsToUpdate = materialsEntity.getComponentsByType<Material>().single { it.materialId == materialId }
        val currentBalance = materialsToUpdate.balance

        // validate updating the balance will not result in unwanted states
        applicationRequire(sumWillNotOverflow(currentBalance, amount)) { materialApplicationError(MaterialErrorTypes.BALANCE_OVERFLOW) }
        applicationRequire(currentBalance + amount >= 0) { materialApplicationError(MaterialErrorTypes.NEGATIVE_BALANCE) }
        applicationRequire(amount + currentBalance <= materialTemplate.maxBalance) { materialApplicationError(MaterialErrorTypes.LIMIT_EXCEEDED) }

        materialsToUpdate.balance += amount
    }

    private fun sumWillNotOverflow(a: Int, b: Int): Boolean {
        return try {
            Math.addExact(a, b)
            true
        } catch(e: ArithmeticException) {
            false
        }
    }
}
[project-name]/[project-name]-player-data/player-data/src/main/kotlin/ContentHandlers.kt
class MaterialsContentHandler: ContentHandler<MaterialTemplate>(MaterialTemplate::class) {
    override fun idFromProto(proto: MaterialTemplate): String {
        return proto.materialId
    }
}
[project-name]/[project-name]-protos/src/main/proto/[project-name]/playerdata/applicationErrors.proto
syntax = "proto3";

package [project-name].playerdata;

import "pragmaOptions.proto";

option csharp_namespace = "PlayerData";
option (pragma.unreal_namespace) = "PlayerData";

enum MaterialErrorTypes {
  UNKNOWN = 0;
  BALANCE_OVERFLOW = 1;
  LIMIT_EXCEEDED = 2;
  NEGATIVE_BALANCE = 3;
  INVALID_TYPE = 4;
  TYPE_ALREADY_ACQUIRED = 5;
  INSUFFICIENT_REQUEST_TYPE_AMOUNT = 6;
}

message MaterialApplicationError {
  option (pragma.force_include_type) = true;

  string message = 1;
  MaterialErrorTypes error_type = 2;
}
[project-name]/[project-name]-protos/src/main/proto/[project-name]/contentdata/materials.proto
syntax = "proto3";

package [project-name].customcontent;

import "pragmaOptions.proto";

option csharp_namespace = "CustomContent";
option (pragma.unreal_namespace) = "CustomContent";

message MaterialTemplate {
  string material_id = 1;
  int32 max_balance = 2;
}
[project-name]/content/src/MaterialTemplate.json
[
  {
    "materialId" : "iron_ore",
    "maxBalance" : 999
  },
  {
    "materialId" : "iron_ingot",
    "maxBalance" : 999
  },
  {
    "materialId" : "copper_ore",
    "maxBalance" : 999
  },
  {
    "materialId" : "copper_ingot",
    "maxBalance" : 999
  },
  {
    "materialId" : "silver_ore",
    "maxBalance" : 999
  },
  {
    "materialId" : "silver_ingot",
    "maxBalance" : 999
  },
  {
    "materialId" : "gold_ore",
    "maxBalance" : 999
  },
  {
    "materialId" : "gold_ingot",
    "maxBalance" : 999
  },
  {
    "materialId" : "wood",
    "maxBalance" : 9999
  },
  {
    "materialId" : "wood_planks",
    "maxBalance" : 9999
  },
  {
    "materialId" : "stone",
    "maxBalance" : 9999
  },
  {
    "materialId" : "refined_stone_slab",
    "maxBalance" : 9999
  },
  {
    "materialId" : "hero_tokens",
    "maxBalance" : 99
  }
]