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.
- 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
.
- Create a new Kotlin file named
MaterialsComponents.kt
under thematerials
directory. - 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 thatid
is an immutable property andbalance
is mutable and will get updated frequently.
- The
// 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.
- Create a new Kotlin file named
MaterialsRpcs.kt
under thematerials
directory. - Copy the following code into the file. All the
PlayerDataRequest
classes contain aString
property formaterialId
and anInt
value for theamount
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.
- Create a new Kotlin file named
MaterialsSubService.kt
under thematerials
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.
- 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 aPlayerDataOperation
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 thesessionTypes
to control access to this operation. Below are the availablesessionTypes
and their use cases:
PLAYER
provides the game client accessPARTNER
provides the game server accessSERVICE
provides access to other Pragma services and PluginsOPERATOR
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.
- 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: #
- Create a new directory at
[project-name]/[project-name]-protos/src/main/proto/[project-name]/contentdata/
. - Create a new protobuf file in that directory called
materials.proto
. - Copy the following code into the
materials.proto
file. TheMaterialTemplate
message will be used to store the material’sid
property (iron, copper, silver, etc.), and themax_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;
}
- 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.
- Add a new Kotlin file named
ContentHandlers.kt
under thematerials
directory.. - Copy the following code into the
ContentHandlers.kt
file to author theMaterialsContentHandler
class.
class MaterialsContentHandler: ContentHandler<MaterialTemplate>(MaterialTemplate::class) {
override fun idFromProto(proto: MaterialTemplate): String {
return proto.materialId
}
}
Catalog the JSON files: #
- Run the following
contentdata init
command to generate theMaterialTemplate
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
- 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.
- Create a new package at
[project-name]/[project-name]-protos/src/main/proto/[project-name]/playerdata
. - In the newly created package, add a new protobuf file named
applicationErrors.proto
. - Copy the following code into the
applicationErrors.proto
file. TheMaterialApplicationError
message uses a string to relay the error’s message back to the client and will utilize theMaterialErrorTypes
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.
- 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.
- 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 #
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.
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.
- Open Postman.
- Navigate to the two service calls:
PragmaDev ➨ Public ➨ Operator - AuthenticateOrCreateV2
andPragmaDev ➨ Public ➨ Player - AuthenticateOrCreateV2
. - Click Send for both service calls and check that the response body for each call has
pragmaTokens
with a filledpragmaGameToken
andpragmaSocialToken
. - Navigate to
Game ➨ RPC - Partner ➨ Player Data ➨ DoOperationPartner
- 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 thematerialId
with any corresponding material defined inMaterialTemplate.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 thesematerials
, runGame ➨ RPC - Partner ➨ Player Data ➨ GetPartner
.
- 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
}
}
}
}
- Edit the RPC’s body from the code above with an
amount
of5
and click Send. This should send back an application error with the responseNEGATIVE_BALANCE
. Run other requests with an invalid amount, negative balance, overflow amount, or invalidmaterialId
to test all the application errors.