Getting Started With Custom Services #

This guide outlines the steps to implement a custom service in Pragma.

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

  • A distributed service that stores and updates global quest data.
  • An RPC for game clients to fetch global quest data.
  • An RPC for game servers to update global quest data.

Backend: Building the Global Quests Service #

1. Define the RPC Payloads #

Pragma uses protobuf to define RPC payloads.

Add a new file at: [project]/[project]-protos/src/main/proto/guides/globalquests/globalQuestsRpc.proto.

globalQuestsRpc.proto

syntax = "proto3";

package guides.globalquests;

import "pragmaOptions.proto";
import "pragma/types.proto";

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

// RPC payloads for game servers to use to update quests
message UpdateGlobalQuestV1Request {
  option (pragma.pragma_session_type) = PARTNER;
  option (pragma.pragma_message_type) = REQUEST;

  string name = 1;
  int32 delta = 2;
}

message UpdateGlobalQuestV1Response {
  option (pragma.pragma_session_type) = PARTNER;
  option (pragma.pragma_message_type) = RESPONSE;
}

// RPC payloads for game clients to fetch global quest data
message GetGlobalQuestsV1Request {
  option (pragma.pragma_session_type) = PLAYER;
  option (pragma.pragma_message_type) = REQUEST;
}

message GetGlobalQuestsV1Response {
  option (pragma.pragma_session_type) = PLAYER;
  option (pragma.pragma_message_type) = RESPONSE;

  repeated GlobalQuest quests = 1;
  // Define your protobuf fields in snack_case 
  // so the Java generated types are correctly generated with PascalCase.
  int64 last_update_time_millis = 2; 
}

message GlobalQuest {
  string quest_name = 1;
  int32 score = 2;
}

Pragma Conformance Rules

  • Requests and Responses must contain the substring V#Requestand V#Response respectively, where the # symbol represents some number.
  • The proto file name must contain the name prefix of the service, followed by the string Rpc.
    • We will be naming the service GlobalQuestsService so globalQuestsRpcs.proto and globalQuestsServiceRpc.proto are both valid files names.

Pragma projects come with tests to verify Pragma conformance requirements. You can find these tests at: [project]/[project]-lib/src/test/kotlin/conformance/ConformanceTest.kt

In a bash terminal at /pragma-engine/platform/ build the project protos to generate the Java types.

./pragma build project-protos

2. Create the database using liquibase #

The global quest data will be peristed in its own database table.

Create a liquibase file at [project]/[project]-lib/src/main/resources/db-changelogs/globalQuests.sql.

globalQuests.sql

--liquibase formatted sql

--changeset GlobalQuestGuide:1
CREATE TABLE `global_quest_data`
(
  `id`                 int UNSIGNED NOT NULL AUTO_INCREMENT,
  `quest_name`         varchar(255) NOT NULL,
  `total_score`        bigint       NOT NULL DEFAULT 0,
  `createdTimestampMs` bigint UNSIGNED NOT NULL,
  `updatedTimestampMs` bigint UNSIGNED NOT NULL,
  PRIMARY KEY (`id`),
  UNIQUE KEY `quest_name_key` (`quest_name`)
) ENGINE=InnoDB DEFAULT CHARACTER SET=utf8mb4 COLLATE utf8mb4_unicode_ci;

3. Define the database service layer and config #

Pragma requires a configuration per database schema. Add a file at [project]/[project]-lib/src/main/kotlin/guides/globalquests/GlobalQuestsDaoConfig.kt.

GlobalQuestsDaoConfig.kt

package guides.globalquests

import pragma.config.ConfigBackendModeFactory
import pragma.config.ServiceConfig
import pragma.databases.UnpartitionedDatabaseConfig
import pragma.settings.BackendType

class GlobalQuestsDaoConfig private constructor(
    type: BackendType
) : ServiceConfig<GlobalQuestsDaoConfig>(type) {
  override val description = "Global Quest database configuration"

  var databaseConfig by types.embeddedObject(
    UnpartitionedDatabaseConfig::class, 
    "database config for the global quests dao"
  )

  companion object : ConfigBackendModeFactory<GlobalQuestsDaoConfig> {
    override fun getFor(type: BackendType) = GlobalQuestsDaoConfig(type).apply {
      databaseConfig = UnpartitionedDatabaseConfig.getFor(type)
    }
  }
}

Here is an example of the config in yaml. Add the GlobalQuestsDaoConfig config block to [project]/config/common.yml.

game:
  core:
  serviceConfigs:
    GlobalQuestsDaoConfig:
      databaseConfig:
        identifierSchema:
          identifier: "defaultIdentifier"
          schema: "game_global_quests"

Next, a UnpartitionedDaoNodeService service needs to be defined. This will act as the layer that communicates between the service and the database.

Add a new file called GlobalQuestsDaoNodeService.kt.

package guides.globalquests

import java.sql.ResultSet
import pragma.PragmaNode
import pragma.databases.SharedDatabaseConfigNodeService
import pragma.databases.UnpartitionedDaoNodeService
import pragma.services.PragmaService
import pragma.settings.BackendType
import pragma.settings.DatabaseValidator
import pragma.utils.TimeProxy

// Pragma Engine constructs this class through reflection,
// so you can suppress the unused warning.
@Suppress("unused") 
@PragmaService(
    backendTypes = [BackendType.GAME],
    dependencies = [SharedDatabaseConfigNodeService::class]
)
class GlobalQuestsDaoNodeService(
    pragmaNode: PragmaNode,
    databaseValidator: DatabaseValidator = pragmaNode.databaseValidator,
    private val timeProxy: TimeProxy = TimeProxy.defaultInstance,
) : UnpartitionedDaoNodeService<GlobalQuestsDaoConfig>(pragmaNode, databaseValidator) {

    companion object {
        data class GlobalQuest(
            val name: String,
            val score: Int,
        ) {
            fun toProto(): GlobalQuestsRpc.GlobalQuest =
                GlobalQuestsRpc.GlobalQuest.newBuilder()
                    .setQuestName(name)
                    .setScore(score)
                    .build()
        }

        object GlobalQuests {
            const val tableName = "global_quest_data"
            const val questName = "quest_name"
            const val totalScore = "total_score"
            const val createdTimeStampMs = "createdTimestampMs"
            const val updatedTimeStampMs = "updatedTimestampMs"
        }

        const val GET_QUESTS_SQL =
            """
                SELECT * 
                FROM `${GlobalQuests.tableName}`
            """

        const val QUEST_UPSERT_SQL =
            """
                INSERT INTO `${GlobalQuests.tableName}` (
                    `${GlobalQuests.tableName}`.`${GlobalQuests.questName}`,
                    `${GlobalQuests.tableName}`.`${GlobalQuests.totalScore}`,
                    `${GlobalQuests.tableName}`.`${GlobalQuests.createdTimeStampMs}`,
                    `${GlobalQuests.tableName}`.`${GlobalQuests.updatedTimeStampMs}`
                ) VALUES (?, ?, ?, ?)
                ON DUPLICATE KEY UPDATE 
                    `${GlobalQuests.tableName}`.`${GlobalQuests.totalScore}` = 
                        `${GlobalQuests.tableName}`.`${GlobalQuests.totalScore}` + ?,
                    `${GlobalQuests.tableName}`.`${GlobalQuests.updatedTimeStampMs}` = ?
            """
    }
 
    override fun changelogFilepath() = "db-changelogs/globalQuests.sql"
    
    override fun getDatabaseConfigFrom(
        serviceConfig: GlobalQuestsDaoConfig
     ) = serviceConfig.databaseConfig

    suspend fun getGlobalQuests(): List<GlobalQuest> {
        return executeSingle(::getGlobalQuests.name) { connection ->
            connection.prepareStatement(GET_QUESTS_SQL).use { preparedStatement ->
                val quests = mutableListOf<GlobalQuest>()
                val resultSet: ResultSet = preparedStatement.executeQuery()
                if (resultSet.next()) {
                    do {
                        quests.add(
                            GlobalQuest(
                                resultSet.getString("quest_name"),
                                resultSet.getInt("total_score")
                             )
                        )
                    } while (resultSet.next())
                }
                return@executeSingle quests
            }
        }
    }

    suspend fun updateGlobalQuestProgress(questName: String, delta: Int) {
        executeSingle(::updateGlobalQuestProgress.name) { connection ->
            val currentTimeMs = timeProxy.currentEpochMillis()
            connection.prepareStatement(QUEST_UPSERT_SQL).use { preparedStatement ->
                preparedStatement.setString(1, questName)
                preparedStatement.setInt(2, delta)
                preparedStatement.setLong(3, currentTimeMs)
                preparedStatement.setLong(4, currentTimeMs)
                preparedStatement.setInt(5, delta)
                preparedStatement.setLong(6, currentTimeMs)
                preparedStatement.execute()
            }
        }
    }
}

4. Define the Service #

Now to define the service layer that brings all of the pieces together.

Add a file called GlobalQuestsService.kt.

package guides.globalquests

import guides.globalquests.GlobalQuestsDaoNodeService.Companion
import guides.globalquests.GlobalQuestsRpc.GetGlobalQuestsV1Request
import guides.globalquests.GlobalQuestsRpc.GetGlobalQuestsV1Response
import guides.globalquests.GlobalQuestsRpc.UpdateGlobalQuestV1Request
import guides.globalquests.GlobalQuestsRpc.UpdateGlobalQuestV1Response
import java.util.UUID
import pragma.PartnerSession
import pragma.PlayerSession
import pragma.PragmaNode
import pragma.rpcs.PragmaRPC
import pragma.rpcs.RoutingMethod
import pragma.rpcs.SessionType
import pragma.services.DistributedService
import pragma.services.PragmaService
import pragma.settings.BackendType
import pragma.utils.TimeProxy
import pragma.utils.TimeProxy.Companion.millisToSeconds

// Pragma Engine constructs this class through reflection,
// so you can suppress the unused warning.
@Suppress("unused")
@PragmaService(
  backendTypes = [BackendType.GAME],
  // if a service does not declare its dependencies,
  // access to those services is not guaranteed.  
  dependencies = [GlobalQuestsDaoNodeService::class] 
)
class GlobalQuestsService(
  pragmaNode: PragmaNode,
  instanceId: UUID
) : DistributedService(pragmaNode, instanceId) {
    private lateinit var globalQuestDaoNodeService: GlobalQuestsDaoNodeService

    override fun run() {
        globalQuestDaoNodeService = nodeServicesContainer[GlobalQuestsDaoNodeService::class]
    }

    private val timeProxy: TimeProxy = TimeProxy.defaultInstance
    private var lastUpdateTime: Long = 0
    private val cachedGlobalQuestData: MutableSet<Companion.GlobalQuest> = mutableSetOf()
    private suspend fun updateCache() {
        cachedGlobalQuestData.clear()
        globalQuestDaoNodeService.getGlobalQuests().forEach { 
            cachedGlobalQuestData.add(it) 
        }
        lastUpdateTime = timeProxy.currentEpochMillis().millisToSeconds
    }

    @Suppress("UNUSED_PARAMETER", "RedundantSuspendModifier")
    @PragmaRPC(SessionType.PLAYER, RoutingMethod.RANDOM)
    suspend fun getGlobalQuestsV1(
        session: PlayerSession,
        request: GetGlobalQuestsV1Request
    ): GetGlobalQuestsV1Response {
        val quests = cachedGlobalQuestData.map { it.toProto() }
        return GetGlobalQuestsV1Response
            .newBuilder()
            .addAllQuests(quests)
            .setLastUpdateTimeMillis(lastUpdateTime)
            .build()
    }

    @Suppress("UNUSED_PARAMETER")
    @PragmaRPC(SessionType.PARTNER, RoutingMethod.RANDOM)
    suspend fun updateGlobalQuestV1(
        session: PartnerSession,
        request: UpdateGlobalQuestV1Request
    ): UpdateGlobalQuestV1Response {
        globalQuestDaoNodeService.updateGlobalQuestProgress(request.name, request.delta)
        updateCache()
        return UpdateGlobalQuestV1Response.newBuilder().build()
    }
}

Pragma Conformance Rules for Services and RPCs

  • The class name must end in Service
  • Service definition must include the @PragmaService annotation with its backend types and dependencies assigned.
  • The Service constructor must start with parameters of type PragmaNode and InstanceId. All other parameters must be defaulted.
  • Each RPC function must have the @PragmaRPC annotation with the session type and routing method set.
    • The session type must match the session option assigned on the request and response protos.
  • The RPC function name must match the request protos name without Request.
    • Example: if the proto is named UpdateGlobalV1Request, the RPC function must be named updateGlobalV1

Pragma projects come with tests to verify Pragma conformance requirements. You can find these tests at: [project]/[project]-lib/src/test/kotlin/conformance/ConformanceTest.kt

Build the project and run all tests. Run in a bash terminal at /pragma-engine/platform/.

./pragma build

At this point you have successfully implemented a custom service for global quests.

Game: Generating and using the SDK #

Update the SDK #

From your game project, run the update Pragma SDK script:

./update-pragma-sdk.sh

Unreal #

Every service and its RPCs comes with a generated SDK.

Below is an example of calling GetGlobalQuestsV1 from the game client. All player RPCs are available through Pragma::FPlayerPtr provided by the UPragmaLocalPlayerSubsystem.

// AMyPlayerController implements APlayerController
void AMyPlayerController::GetGlobalQuests() {
  PragmaPlayerSDK->Api<UGuidesGlobalQuestsServiceRaw>().GetGlobalQuestsV1(
      {},
      UGuidesGlobalQuestsServiceRaw::FGetGlobalQuestsV1Delegate::CreateLambda(
          [](const TPragmaResult<FPragma_GlobalQuests_GetGlobalQuestsV1Response>
                 &Response,
             const FPragmaMessageMetadata &_) {
            if (Response.IsFailure()) {
              UE_LOG(LogTemp, Display,
                     TEXT("=== failure check error/error type to see what went wrong ==="));
              return;
            }

            UE_LOG(LogTemp, Display,TEXT("=== success ==="));
            auto [Quests, LastUpdateTimeMillis] =
                Response
                    .Payload<FPragma_GlobalQuests_GetGlobalQuestsV1Response>();
            for (auto [QuestName, Score] : Quests) {
              UE_LOG(LogTemp, Display, TEXT("quest: `%s`, score `%d`"),*QuestName, Score);
            }
          }));
}

Below is an example of calling the partner RPC UpdateGlobalQuestV1. All partner RPCs are available through Pragma::FServerPtr provided by the UPragmaGameServerSubsystem.

// ATutorialGameMode implements AGameModeBase
void ATutorialGameMode::UpdateGlobalQuest() {
  // Pragma::FServerPtr PragmaServerSDK;
  PragmaServerSDK->Api<UGuidesGlobalQuestsPartnerServiceRaw>()
      .UpdateGlobalQuestV1(
          {"SquashBugs", 1260},
          UGuidesGlobalQuestsPartnerServiceRaw::FUpdateGlobalQuestV1Delegate::
              CreateLambda(
                  [](const TPragmaResult<
                         FPragma_GlobalQuests_UpdateGlobalQuestV1Response>
                         &Response,
                     const FPragmaMessageMetadata _) {
                    if (Response.IsFailure()) {
                      UE_LOG(LogTemp, Display,
                             TEXT("==== Failure - check error/error type ==="));
                      return;
                    }

                    UE_LOG(LogTemp, Display,
                           TEXT("=== Success quest data was updated ==="));
                  }));
}