Custom services #
A custom service is a Kotlin class that runs inside the Pragma engine and exposes RPCs to players, partners, and operators. Each service owns its own configuration, plugins, and (optionally) database tables.
The examples in this section use DemoService which is a small service in the Unicorn reference project that demonstrates each customization primitive: custom service creation, plugin definition and implementation, service and plugin configuration, database persistence, and player broadcast notifications. This page covers the service skeleton and a single player RPC. For persistence, see Persistence. For plugins, see Plugins.
Create the service #
A service extends DistributedService, is annotated with @PragmaService, and declares its backend type and dependencies. Implementing ConfigHandler<T> gives the service a typed, hot-reloadable configuration (defined in Define the service config below):
DemoService.kt
package unicorn.demo
import java.util.UUID
import pragma.config.ConfigHandler
import pragma.PragmaNode
import pragma.services.DistributedService
import pragma.services.PragmaService
import pragma.settings.BackendType
import unicorn.demo.dao.DemoProgressTrackerDaoNodeService
//...
@PragmaService(
backendTypes = [BackendType.GAME],
dependencies = [DemoProgressTrackerDaoNodeService::class]
)
class DemoService(pragmaNode: PragmaNode, instanceId: UUID) :
DistributedService(pragmaNode, instanceId),
ConfigHandler<DemoServiceConfig> {
}
The @PragmaService annotation registers the service with the engine. backendTypes controls which backend(s) the service runs on, and dependencies lists node services the service needs access to at runtime.
The service constructor must start with PragmaNode and UUID parameters. All other constructor parameters must have defaults.
Define the service config #
A service config extends ServiceConfig and declares typed fields with optional defaults. The service that relies on the config implements ConfigHandler to receive config updates from the engine.
DemoServiceConfig.kt
package unicorn.demo
import pragma.config.ConfigBackendModeFactory
import pragma.config.ServiceConfig
import pragma.settings.BackendType
//...
class DemoServiceConfig private constructor(type: BackendType) : ServiceConfig<DemoServiceConfig>(type) {
override val description = "Demo service configuration."
var prefix by types.string("Prefix prepended to the echo response message.")
init {
prefix = "echo:"
}
companion object : ConfigBackendModeFactory<DemoServiceConfig> {
override fun getFor(type: BackendType) = DemoServiceConfig(type)
}
}
Declare the config values in YAML under the serviceConfigs block:
game:
serviceConfigs:
DemoServiceConfig:
prefix: "hello:" // overrides the default specified in the config class
Define an RPC #
An RPC requires two pieces: a protobuf request/response pair and a handler method on the service.
Define the proto messages. Place the proto file in your project’s proto directory. Each message declares its session type (player, partner, or operator) and message type (request, response, or notification):
demoServiceRpc.proto
package unicorn.demo;
message EchoV1Request {
option (pragma.pragma_session_type) = PLAYER;
option (pragma.pragma_message_type) = REQUEST;
string message = 1;
}
message EchoV1Response {
option (pragma.pragma_session_type) = PLAYER;
option (pragma.pragma_message_type) = RESPONSE;
string response_message = 1;
}
Build the project protos to generate the Java types:
./pragma build project-protos
Add the handler. The @PragmaRPC annotation wires the method to the proto messages. The method name must match the request name without Request (e.g., EchoV1Request maps to echoV1):
DemoService.kt
package unicorn.demo
import pragma.PlayerSession
import pragma.rpcs.PragmaRPC
import pragma.rpcs.RoutingMethod
import pragma.rpcs.SessionType
import unicorn.demo.DemoServiceRpc.EchoV1Request
import unicorn.demo.DemoServiceRpc.EchoV1Response
//...
@PragmaRPC(SessionType.PLAYER, RoutingMethod.SESSION_PRAGMA_ID)
suspend fun echoV1(session: PlayerSession, request: EchoV1Request): EchoV1Response {
val prefix = config.prefix
val responseMessage = "$prefix ${request.message}"
return EchoV1Response.newBuilder()
.setResponseMessage(responseMessage)
.build()
}
Conformance rules to keep in mind:
- Service class names must end in
Service. - Request and response message names must contain
V<number>RequestandV<number>Response. - The proto file name must contain the service name prefix followed by
Rpc(e.g.,demoServiceRpc.proto). - The
pragma_session_typeon the proto must match theSessionTypeon the handler annotation.
Run your project’s conformance tests to verify: [project]/[project]-lib/src/test/kotlin/conformance/ConformanceTest.kt.
Build the full project:
./pragma build
Start Pragma with ./pragma run or the IntelliJ run-pragma run configuration. When the engine starts, your service appears in the running services list in the startup output.
Call from the load simulator #
A load simulator step exercises RPCs programmatically. Define a Step subclass that sends the request and verifies the response:
LoadSimulatorMain.kt
import unicorn.demo.DemoServiceRpc.EchoV1Request
import unicorn.demo.DemoServiceRpc.EchoV1Response
//...
val echoRequest = EchoV1Request.newBuilder().setMessage("load test").build()
val echoResponse = player.game.sendOrThrow(echoRequest, EchoV1Response::class, tracker)
println("echoV1: ${echoResponse.responseMessage}")
The sendOrThrow method sends the request over the player’s game session and returns the typed response. The tracker parameter records the call for load simulator reporting.
Call from the SDK #
Run update-pragma-sdk.sh from the game project’s Plugins directory to generate SDK bindings for the new service and RPC:
cd Plugins
./update-pragma-sdk.sh
Player RPCs are available through the generated raw service class on PragmaPlayer. The example below calls EchoV1 from an Unreal console command:
RpcBasics.cpp
#include "BackendBasics/Pragma/RpcBasics.h"
#include "Dto/UnicornDemoServiceRaw.h"
//...
void URpcBasics::SendEcho(const FString& Message, FOnEchoComplete OnComplete)
{
if (!Player.IsValid()) { return; }
FPragma_Demo_EchoV1Request Request;
Request.Message = Message;
Player->Api<UUnicornDemoServiceRaw>().EchoV1(Request,
UUnicornDemoServiceRaw::FEchoV1Delegate::CreateWeakLambda(this,
[OnComplete = MoveTemp(OnComplete)](TPragmaResult<FPragma_Demo_EchoV1Response> Result, const FPragmaMessageMetadata&)
{
OnComplete.ExecuteIfBound(Result.IsSuccessful(),
Result.IsSuccessful() ? Result.Payload().ResponseMessage : Result.Error().ToString());
}));
}
Related topics #
- Plugins for injecting custom logic into services.
- Persistence for adding database tables and partner RPCs.
- Configuration for database config classes, override precedence, dynamic reload, and encrypted secrets.
- Player broadcasts for sending notifications to connected players.
- Custom errors for defining service-specific error codes.