Create a client Key Value Store #

This guide will introduce you to the basics of the player data service.

A key-value store is useful for early development to allow rapid iteration by the design team; however, it is strongly encouraged that more formal data models and APIs are developed for production. Key-value patterns scale poorly and make content management in production extremely difficult.

By the end of this guide, you’ll have:

  • An endpoint for the client to store, update, and delete persisted data.
  • Access the persisted data through the client side cache. All retrieval and syncing is handled automatically by the Pragma SDK.

Setup #

Start with a clean build of your project.

./pragma build -s

Define the persisted data structure #

Add a new file at:

[project]/[project]-player-data/player-data/src/main/kotlin/[game-studio]/keyvalue/KeyValue.kt

Add the definition below:

package documentation

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

@SerializedName("[timestamp-epoch]")
data class PersistedData(
    @FieldNumber(1) var value: String,
): 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.

Author the APIs #

To create an API for the game client, we need to author a PlayerDataOperation.

Add the following to KeyValue.kt

package documentation

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

data class AddOrUpdate(
    val key: String,
    val value: String
): PlayerDataRequest
class AddOrUpdateResponse: PlayerDataResponse

data class DeleteData(
    val key: String,
): PlayerDataRequest
class DeleteDataResponse: PlayerDataResponse

// Pragma Engine constructs this class through reflection
// so we suppress the unused warning.
@Suppress("unused")
class KeyValueStore(
    contentLibrary: PlayerDataContentLibrary
): PlayerDataSubService(contentLibrary) {

    @PlayerDataOperation(sessionTypes = [SessionType.PLAYER])
    fun addOrUpdate(request: AddOrUpdate, context: Context): AddOrUpdateResponse {
        val entity = context.snapshot.getOrCreateUniqueEntity(request.key) {
            listOf(PersistedData(""))
        }

        val component = entity.getComponentsByType<PersistedData>().single()
        component.value = request.value

        return AddOrUpdateResponse()
    }

    @PlayerDataOperation(sessionTypes = [SessionType.PLAYER])
    fun delete(request: DeleteData, context: Context): DeleteDataResponse {
        val entity = context.snapshot.getOrCreateUniqueEntity(request.key)
        context.snapshot.deleteEntity(entity.instancedId)
        return DeleteDataResponse()
    }
}
Operation Requirements
  • The sessionTypes parameter controls which session types can invoke the operation

    • Supported values:
      • SessionType.PLAYER
      • SessionType.OPERATOR
      • SessionType.PARTNER
      • SessionType.SERVICE
  • The function’s name must be unique across all defined operations.

  • PlayerDataSubService classes must provide a constructor that accepts a PlayerDataContentLibrary.

  • Each operation requires a unique PlayerDataRequest and PlayerDataResponse.

Build the project to run player data code generation and run tests.

./pragma build

Adding and accessing data from the client #

Unreal #

Below is an example of calling the KeyValueStore.AddOrUpdateData operation through the Pragma SDK and then accessing the data from the client side cache.

This Unreal example codes extends from the Unreal SDK Setup tutorial.

From you Unreal project, run the Pragma SDK update script to incorporate the new APIs.

update-pragma-sdk.sh

Header file: Source\Unicorn\UnicornPlayerController.h

#pragma once

#include "CoreMinimal.h"
#include "Dto/CodeGen_PlayerData_Documentation.h"
#include "GameFramework/PlayerController.h"
#include "Services/PragmaPlayerDataCache.h"
#include "PragmaPtr.h"
#include "UnicornPlayerController.generated.h"

PRAGMA_FWD(FPlayer);

// example data to add to key-value store
USTRUCT()
struct FLoadOutData {
  GENERATED_BODY()

  UPROPERTY()
  FString SelectedHero;
};

UCLASS()
class UNICORN_API AUnicornPlayerController : public APlayerController
{
  GENERATED_BODY()

public:
  // ...
  
  UFUNCTION(Exec)
  void AddOrUpdateData(const FLoadOutData& LoadOutData);

  UFUNCTION(Exec)
  FPragma_PlayerData_PersistedDataProto
  GetValueFromPlayerDataCache(const FString &EntityName);

  // ...
};

Source\Unicorn\UnicornPlayerController.cpp

void AUnicornPlayerController::AddOrUpdateData(
   const FLoadOutData &LoadOutData
) {
  FString JsonString;
  FJsonObjectConverter::UStructToJsonObjectString<FLoadOutData>(
    LoadOutData,
    JsonString
  );

  Player->PlayerDataService().KeyValueStore().AddOrUpdate(
      "LoadOut", JsonString,
      FOnKeyValueStoreAddOrUpdateDelegate::CreateLambda(
          [this](TPragmaResult<FPragma_PlayerData_AddOrUpdateResponseProto>
                     PragmaResult) {
            if (PragmaResult.IsFailure()) {
              return; // check failure type and handle.
            }

            // The client cache has been updated
            const FString StringDataFromCache =
                GetValueFromPlayerDataCache("LoadOut").Value;
          }));
}

// example show structure of cache
FPragma_PlayerData_PersistedDataProto
AUnicornPlayerController::GetValueFromPlayerDataCache(
    const FString &EntityName) {
  if (!Player->PlayerDataService().GetCache().IsValid()) {
    return {};
  }

  const TSharedPtr<FPlayerDataCache> PlayerDataCache =
      Player->PlayerDataService().GetCache().Pin();
  const TWeakPtr<FPragmaEntity> EntitySaved =
      PlayerDataCache->GetUniqueEntityByName(EntityName);
  if (!EntitySaved.IsValid()) {
    return {};
  }

  const TSharedPtr<FPragmaEntity> Entity = EntitySaved.Pin();
  const TMap<FString, FPragmaComponent> &Components = Entity->Components;

  for (const auto &Component : Components) {
    if (Component.Value.Is<FPragma_PlayerData_PersistedDataProto>()) {
      return Component.Value
          .ReadValue<FPragma_PlayerData_PersistedDataProto>();
    }
  }

  return {};
}