Setting up a Materials Module #

This tutorial will walk you through implementing a player data feature for managing collectable resource items.

This is our most comprehensive guide that shows how to incorporate content and custom error handling with player data. If you are just getting started with player data, check out our key-value store guide.

Get started #

Start with a fresh build of your project.

./pragma build -s

Define the data model #

Define a component class that will be used to store material data.

Define a Component #

Add a file:

[project]/[project]-player-data/player-data/src/main/kotlin/materials/Materials.kt

package documentation 

import pragma.playerdata.Component
import pragma.playerdata.FieldNumber
import pragma.types.SerializedName

@SerializedName("<TIMESTAMP-IN-EPOCH>")
data class Material(
    @FieldNumber(1) val materialId: String,
    @FieldNumber(2) var balance: Int
): Component
Requirements for Components
  • A unique value within the @SerializedName(val name: String) annotation

    • Values must never change
    • Values must be unique across the project
    • We recommend using EPOCH in seconds
  • The @FieldNumber annotation must be a unique integer for each property

    • Use @ReservedFieldNumbers([retired-value-1],[retired-value-2]) when you remove properties from a component to ensure these values are not accidentally reused

Supported Data Types:
#

  • String

  • Numeric Types: Int, UInt, Byte, UByte, Short, UShort, Long, ULong, Boolean, Float, and Double

  • UUID

  • Map and MutableMap

    • Keys must be Int or String
  • List and MutableList

  • A class with properties using the above data types

  • All basic types and nested classes can be marked nullable.

    • Example: Int?, String?, UUID?, MyCustomObject?
    • Nullable markers are not supported with collection types.

Define APIs #

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 #

You define the payload of the request and response for each operation.

Add the following to the Materials.kt file.

import pragma.playerdata.PlayerDataRequest
import pragma.playerdata.PlayerDataResponse

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

Define the operations #

Operations must be defined on a class that inherits from PlayerDataSubService and has a constructor that takes a PlayerDataContentLibrary instance.

  • addMaterial() will allow calls from trusted backends like game servers and other pragma game services and plugins.
  • deductMaterial() can be called from trusted backends and clients.
package documentation

import pragma.playerdata.Context
import pragma.playerdata.PlayerDataContentLibrary
import pragma.playerdata.PlayerDataOperation
import pragma.playerdata.PlayerDataSubService
import pragma.rpcs.SessionType

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 }
        
        // updates the balance
        materialsToUpdate.balance += amount
    }
}
Interface of an Player Data Operation
// Required annotation - controls access
@PlayerDataOperation(
  sessionTypes = [SessionType.PLAYER, SessionType.PARTNER]
) 
// function names must be unique across all operations
fun playerDataOperationExample(
    request: RequestForThisOperation,
    // Pragma provided object that provides access the player's data and other utilities
    context: Context 
): ResponseForThisOperation {
   
   // implement logic to add and update data using `context.snapshot`
   
   return ResponseForThisOperation()
}

All Player Data Operations require the Pragma Engine defined annotation @PlayerDataOperation. This annotation lets you control the 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 Game Operator Portal access
  • In this example, we’ve decided to put all material Components under one Entity.
  • 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.
  • To see all of the helper functions available for managing Entities and Components, see the Reference documentation.

Add validation and error handling #

We have the basics of a materials module! However, the module currently does not have any validation There is no way to prevent unwanted scenarios such as a balance going negative or ability to cap at a limit.

Let’s add logic to handle expected problems. This is where we will integrate with the content data system and use custom errors.

Define a materials content catalog #

1. Define the content protobuf #

Add a new file at:

[project]/[project]-protos/src/main/proto/[project]/contentdata/materials.proto.

syntax = "proto3";

package documentation.customcontent;

import "pragmaOptions.proto";

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

message MaterialTemplate {
  string material_id = 1;
  int32 max_balance = 2;
}

Run the Pragma CLI command to generate protos

./pragma build project-protos

2. 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 the content handler to:

[project]/[project]-player-data/player-data/src/main/kotlin/materials/Materials.kt

package documentation

import documentation.customcontent.Materials.MaterialTemplate
import pragma.content.ContentHandler

class MaterialsContentHandler
  : ContentHandler<MaterialTemplate>(MaterialTemplate::class) {
  override fun idFromProto(proto: MaterialTemplate): String {
      return proto.materialId
  }
}

3. Add the catalog JSON files #

  • Under [project]/content/src/. Add a file Materials.json and Materials.metadata
  • Copy the following code in the Materials.json.
    [
      {
        "materialId" : "iron_ore",
        "maxBalance" : 1000
      },
      {
        "materialId" : "iron_ingot",
        "maxBalance" : 750
      },
      {
        "materialId" : "copper_ore",
        "maxBalance" : 500
      }
    ]
    
    • Copy the following into Materials.metadata
    {
      "applyTriggers": {
        "contentHandler": "MaterialsContentHandler"
      },
      "versionsToRetain": 1
    }
    

Apply the catalog #

Run the content apply Pragma CLI command.

./pragma build -s # ensure jar has new types
./pragma content apply

Define a custom error #

We will use Pragma Application Errors to control expected error flows.

First we need to define a custom proto to use when throwing an ApplicationErrorException.

Add a new file: [project]/[project]-protos/src/main/proto/[project]/playerdata/applicationErrors.proto

syntax = "proto3";

package documentation.playerdata;

import "pragmaOptions.proto";

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

enum MaterialErrorTypes {
  UNKNOWN = 0;
  LIMIT_EXCEEDED = 1;
  NEGATIVE_BALANCE = 2;
  UNKNOWN_TYPE = 3;
  INVALID_REQUEST_AMOUNT = 4;
}

message MaterialApplicationError {
  // required option - force includes this proto in the sdk type generation
  option (pragma.force_include_type) = true;

  MaterialErrorTypes error_type = 2;
}

Run the generate protos command:

./pragma build project-protos

Update Materials sub-service #

We can now update the Materials sub-service to use our content catalog to verify material types and use application errors when an update would create an unwanted result.

[project]/[project]-player-data/player-data/src/main/kotlin/materials/Materials.kt

package documentation

// new imports
import documentation.customcontent.Materials.MaterialTemplate
import documentation.playerdata.ApplicatonErrors.MaterialApplicationError
import documentation.playerdata.ApplicatonErrors.MaterialErrorTypes
import documentation.playerdata.ApplicatonErrors.MaterialErrorTypes.*
import pragma.applicationRequire
import pragma.content.ContentData

// updated logic with validation and errors
class Materials(
    contentLibrary: PlayerDataContentLibrary
): PlayerDataSubService(contentLibrary) {
    companion object {
        const val MATERIALS_ENTITY_ID = "materials"
    }

    // use contentLibrary to get the materials content catalog
    private fun materialsTemplates(): ContentData<MaterialTemplate>
        = contentLibrary.getContentData("MaterialTemplate.json")

    // helper to build application error proto
    private fun materialApplicationError(
        errorType: MaterialErrorTypes
    ): MaterialApplicationError = MaterialApplicationError.newBuilder()
        .setErrorType(errorType)
        .build()

    private fun validateRequestAmount(amount: Int) {
        applicationRequire(amount >= 0 ) {
            materialApplicationError( INVALID_REQUEST_AMOUNT)
        }
    }

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

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

    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(UNKNOWN_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

        applicationRequire(currentBalance + amount >= 0) {
            materialApplicationError(NEGATIVE_BALANCE)
        }
        applicationRequire(amount + currentBalance <= materialTemplate.maxBalance) {
            materialApplicationError(LIMIT_EXCEEDED)
        }

        materialsToUpdate.balance += amount
    }
}

Call operations from game server and client #

Once operations are defined generate the sdk.

From your game project run the provided SDK update script.

update-pragma-sdk.sh

Call addMaterial from Partner SDK #

Unreal SDK: #

void AUnicornGameMode::AddBalance(const FString &PlayerId) {
  // providing type - assign as private property on the GameMode etc ...
  const Pragma::FServerPtr PartnerSdk;
  PartnerSdk->PlayerDataService().Materials().AddMaterial(
      PlayerId, "health-potions", 10,
      FOnMaterialsAddMaterialDelegate::CreateLambda(
          [](const TPragmaResult<FPragma_PlayerData_MaterialAddedProto>
                 &Result) {
            if (Result.IsSuccessful()) {
              const auto PlayersLoadout =
                  Result.Payload<FPragma_PlayerData_GetLoadoutResponseProto>();
              UE_LOG(LogTemp, Display, TEXT("Current character from load: %s"),
                     *PlayersLoadout.SelectedCharacter);

            } else {
              // failure occurred check types and handle as wanted
            }
      }));
}

Call deductMaterial from Player SDK #

Unreal SDK #

void AUnicornPlayerController::DeductMaterial(
  const FString MaterialId,
  const int32 Amount
) {
  Pragma::FPlayerPtr Player; // type provided - save as property on controller                                              
  Player->PlayerDataService().Materials().DeductMaterial(
      MaterialId, Amount,
      FOnMaterialsDeductMaterialDelegate::CreateLambda(
          [](const TPragmaResult<FPragma_PlayerData_DeductedMaterialProto>
                 &Result) {
            if (Result.IsFailure()) {
              return; // check failure type and handle.
            }
            // cache has been updated - access current balance through it
            const auto Payload =
                Result.Payload<FPragma_PlayerData_DeductedMaterialProto>();
          }));
}