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#Request
andV#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
soglobalQuestsRpcs.proto
andglobalQuestsServiceRpc.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
andInstanceId
. 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 namedupdateGlobalV1
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 ==="));
}));
}