Creating a Custom Service #

Let’s create a simple Echo service that receives a message and responds with that same message.

This tutorial was authored on Pragma Engine 0.6.0.

Unreal example project #

This tutorial is based on an example Unreal project using the Third Person Unreal C++ project template. The example project is named ‘Unicorn’, update references accordingly.

This tutorial was built and tested using Unreal 5.3.X, see the SDK overview for the full sdk compatibility chart.

This guide builds on the example project started in the Unreal SDK setup guide, which can be referenced regarding assumptions about project setup.

Relative paths #

Relative paths start from the pragma project directory or game engine project directory, respectively.

  • unicorn-lib\... -> ~\[pragma_repo_root_dir]\pragma-engine\platform\unicorn\unicorn-lib\...
  • Plugins\PragmaSDK -> ~\[game_repo_root_dir]\Unicorn\Plugins\PragmaSDK

Overview #

Steps for authoring the Echo service:

  1. Define the Echo service
  2. Define the echoV1 RPC
  3. Build and run Pragma with our new service
  4. Test the echoV1 endpoint via the load-simulator
  5. Perform the SDK Generation step to generate bindings for the new service and RPC.
  6. Invoke the echoV1 RPC via from the game client via the SDK.

1. Define the Echo service #

  • Create EchoService.kt at unicorn-lib\src\main\kotlin\unicorn\echo\EchoService.kt
  • Add the following:

EchoService.kt

package unicorn.echo

import java.util.UUID
import pragma.PragmaNode
import pragma.services.DistributedService
import pragma.services.PragmaService
import pragma.settings.BackendType

@Suppress("UNUSED_PARAMETER") // suppress warning caused by reflection based invocation of Pragma RPCs
@PragmaService(backendTypes = [BackendType.GAME])
class EchoService(pragmaNode: PragmaNode, instanceId: UUID) : DistributedService(pragmaNode, instanceId) {

}

The engine identifies the @PragmaService annotation in order to initialize and register it. Services can be registered as GAME or SOCIAL types, which will dictate which Pragma nodes the service will run on.

2. Define the echoV1 RPC #

Define request and response protos #

  • Create echoRpc.proto at unicorn-protos\src\main\proto\unicorn\echo\echoRpc.proto
  • Add the following:
syntax = "proto3";

package unicorn.echo;
option csharp_namespace = "Unicorn.Echo";
option (pragma.unreal_namespace) = "Echo";

import "pragmaOptions.proto";

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;
}
RPC protobuf conventions

pragma_session_type can be one of PLAYER, PARTNER, OPERATOR, or SERVICE.

pragma_message_type can be one of REQUEST, RESPONSE, or NOTIFICATION.

Requests map 1-to-1 with Responses, meaning each request needs a matching response type, both adhering to a shared naming convention. The naming conventions apply to both the protobuf definition as well as the RPC definition in code, we’ll use the conformance tests provided by the engine to check whether all naming requirements have been met in the steps below.

Protobuf’s cross-compilation attempts to match the conventions of the target language. This means that although proto definitions use the lowercase_underscore convention, the generated Java types use camelCase. Keep this in mind as generated names may not match exactly due to this behavior of protobuf.

Build protos #

We’ll run the protobuf generation to make the request and response objects available to our service code.

  • Run in git-bash from the platform directory:
    ./pragma build project-protos
    

Define the RPC Handler #

  • Add the echoV1 method to handle the RPC request in the service:

EchoService.kt

package unicorn.echo

// ...

import pragma.PlayerSession
import pragma.rpcs.PragmaRPC
import pragma.rpcs.RoutingMethod
import pragma.rpcs.SessionType
import unicorn.echo.EchoRpc.EchoV1Request
import unicorn.echo.EchoRpc.EchoV1Response

@PragmaService(/**/)
class EchoService(/**/) {
   
   @PragmaRPC(SessionType.PLAYER, RoutingMethod.SESSION_PRAGMA_ID)
   suspend fun echoV1(session: PlayerSession, request: EchoV1Request): EchoV1Response {
       val response = EchoV1Response.newBuilder()
       response.setResponseMessage(request.message)
       return response.build()
   }

}
RPC conventions
  • The method name echoV1 must match the proto message echoV1Request, absent the Request suffix.
  • The session type specified in the @PragmaRPC annotation must match the session type specified in the proto definition
  • The session type parameter will match the specified session type. SessionType.PLAYER accepts a PlayerSession param and so on.
  • The request type will be the generated proto type, EchoV1Request in this case.
  • The return type will be the response type, EchoV1Response
  • The response object is built using the builder provided by the generated proto type.

Run the conformance tests #

To confirm your proto and RPC handler are correct, run the conformance tests for a quick feedback loop.

  • Open ConformanceTest.kt located at unicorn-lib\src\test\kotlin\conformance\ConformanceTest.kt
  • Run the tests via the green play button in the editor next to class ConformanceTest
    • The example code above is correct, but if you’re developing your own APIs, the error messages from the conformance tests will help identify any mismatches / missing conventions.

Run Pragma #

  1. Start Pragma with the Intellij run-pragma run configuration available in the drop-down in the upper right of the IDE.

Once Pragma Engine successfully starts, you’ll see EchoService listed under the running services in the Game node startup output:

Echo Service Running

Test the Echo service #

Let’s test our service via the load simulator. The load simulator is a powerful tool for validating, testing, and exercising workflows and features within Pragma.

Open LoadSimulatorMain.kt at unicorn-load-simulator\src\main\kotlin\LoadSimulatorMain.kt

We’ll add a call to our new RPC from within the example step provided within the file.

LoadSimulatorMain.kt

// ...
import unicorn.echo.EchoRpc.EchoV1Request
import unicorn.echo.EchoRpc.EchoV1Response

class ExampleStep(/**/) : Step(/**/) {

    override suspend fun runStep(/**/) {
        // ... existing example code

        val echoRequest = EchoV1Request.newBuilder().setMessage("hello world!").build()
        val echoResponse = leader.game.sendOrThrow(echoRequest, EchoV1Response::class, tracker)
        println("EchoV1 - ${leader.displayName.displayName}: ${echoResponse.responseMessage}")
    }

}
  1. Start the load simulator via the run-load-simulator Intellij run configuration (located in a dropdown in the top right)
  2. Observe the output of the echoV1 response streaming during the simulation run:
    • EchoV1 - LoadSimulatorPlayer1: hello world!
  3. Success!

Call echoV1 from the game client #

Now that we know our EchoV1 RPC is working, let’s call it from the game client.

Switch to a new git-bash terminal in your game code. If using Rider, you can launch this terminal window within the IDE.

# new git-bash terminal
cd ~/Unreal/Unicorn

Run update-pragma-sdk.sh in git bash from the game engine plugins directory

cd Plugins

# this will run the sdk generation step and then copy the updated plugin sources to the plugin dir
./update-pragma-sdk.sh

 

  1. Now that our new EchoV1 RPC is available in the sdk, let’s add an exec function to call it!

Source\Unicorn\UnicornPlayerController.h

// ...
class UNICORN_API AUnicornPlayerController : public APlayerController
{
    // ... 
    
    public:
        UFUNCTION(Exec)
        void Echo(const FString& Message);
        
   // ...
}

Source\Unicorn\UnicornPlayerController.cpp

// ...

#include "Dto/PragmaEchoRpcDto.h"
#include "Dto/UnicornEchoServiceRaw.h"

// ...

void AUnicornPlayerController::Echo(const FString& Message)
{
    const FPragma_Echo_EchoV1Request request = FPragma_Echo_EchoV1Request {
        { Message }
    };
    Player->Api<UUnicornEchoServiceRaw>().EchoV1(request, UUnicornEchoServiceRaw::FEchoV1Delegate::CreateWeakLambda(
        this, [this](const TPragmaResult<FPragma_Echo_EchoV1Response>& EchoResult, const FPragmaMessageMetadata&)
        {
            if (EchoResult.IsSuccessful())
            {
                UE_LOG(LogTemp
                    , Display
                    , TEXT("Pragma -- EchoV1 response: %s")
                    , *EchoResult.Payload<FPragma_Echo_EchoV1Response>().ResponseMessage);
            } else
            {
                UE_LOG(LogTemp, Display, TEXT("Pragma -- EchoV1 something went wrong: %s"), *EchoResult.GetErrorAsString());
            }
        })
    );
}

Run the game #

  1. Ensure Pragma is running via ./pragma run or the Intellij run-pragma run configuration.
  2. Run the game
  3. Call the Echo exec function
    • Open the console with backtick in the running game window and type Echo hello world!
  4. Confirm the echo response in the log
    1. Pragma -- EchoV1 response: hello world!
  5. Success!