Cardinal Plugin
Go-based Nakama containers can be extended using shared objects (aka plugins (opens in a new tab)). The starter-game-template (opens in a new tab) leverages this shared object pattern to expand on Nakama to better interact with a Cardinal based backend.
The Nakama plugin bundled with the starter-game-template is general purpose, and should work with your Cardinal based game without any modifications. As you work on your game, you may find that you want to further change the Nakama plugin to better serve your specific use case. This document serves as a reference for the various aspects of the Cardinal-focused Nakama plugin.
RPC Endpoints
Messages and Queries registered with a cardinal.World
object will automatically get Nakama RPC endpoints created for them. For example, if you set up a Message and a Query with the following code:
var MoveMsg = cardinal.NewMessageType[MoveMsg, MoveResult]("move-a-player")
var PositionRead = cardinal.NewQueryType[PositionRequest, PositionReply]("position", handlePositionRead)
And register those objects with world.RegisterMessages
and world.RegisterQueries
, the following set of RPC endpoints will be created in Nakama:
- /tx/game/move-a-player
- /query/game/position
RPC requests to those Nakama endpoints will be passed along to your Cardinal implementation. Messages must be processed via the transaction endpoint. The Transaction contains both the underlying message (a client intention to modify game state) and a cryptographic signature (verifying the client is allowed to submit the message).
Transactions (prefixed with /tx
) are added to the transaction queue, and a transaction hash is immediately returned. The transaction will be processed at the next game tick, and the transaction hash can be used to identify the results of previously submitted transactions. See Transaction Hashes and Receipts and Streaming Transaction Receipts for more details.
Queries (prefixed with /query
) are processed right away and the result of the query will be returned in the request response.
The communication from a client (e.g. the Nakama console or a custom-built game client) to Nakama uses the RPC protocol. Communication between Nakama and Cardinal makes use of HTTP. Request payloads and response payloads are JSON encoded strings.
Transaction Signatures
When submitting transactions from Nakama to Cardinal, transactions must be cryptographically signed. The Cardinal plugin will use an existing private key, or generate one if one does not exist. This key generation and signing happens automatically. To make use of this automatic signing, a client must first register a Persona Tag. Once a Persona Tag has been requested and accepted, future transactions will be automatically signed.
After generation, the key is stored in Nakama's storage layer under the admin User ID.
Collection Name | Key Name | User ID | Description |
---|---|---|---|
private_key_collection | private_key_key | 00000000-0000-0000-0000-000000000000 | Private key using ECSDA and the secp256k1 curve |
private_key_collection | private_key_nonce | 00000000-0000-0000-0000-000000000000 | The next nonce to use when signing a transaction |
Local UserID to PersonaTag
Nakama manages User Accounts, however these User Accounts cannot be used directly by Cardinal based games. Cardinal maintains a separate list of authenticated users called "Persona Tags" (like gamer-tags). Before a user may interact with Cardinal, they must first register a Persona Tag with Cardinal.
Within Cardinal, Persona Tag creation is managed by the ecs.CreatePersonaTx
transaction as well as the ecs.RegisterPersonaSystem
. This transaction gets its own RPC endpoint (/tx/persona/create-persona
), however Nakama cannot pass these transaction requests directly to Cardinal. Nakama must perform some pre-processing before handing off the request to Cardinal.
Specifically, Nakama:
- Attempts to associate the Persona Tag with the Nakama user.
- If some other user has already claimed this Persona Tag, Nakama will fail the request.
- Alternatively, if the active user has already successfully claimed a Persona Tag, Nakama will fail the request.
- Saves that association to Nakama's storage layer under "cardinalCollection"/"personaTag" storage object.
- The User ID to Persona Tag association starts off as "pending" and will later be updated to either "accepted" or "rejected"
- Creates a signed System level transaction using
sign.NewSystemTransaction
.- The System signature indicated to Cardinal that there is not a previously-registered Persona Tag generating this transaction.
- Sends a request to Cardinal's
/tx/persona/create-persona
endpoint.- This gives Cardinal a chance to decide if the Persona Tag truly is up for grabs, or if someone has already claimed it.
- It also gives Cardinal the opportunity to select a "winner" if the same Persona Tag is registered multiple times on the same tick.
The RPC endpoints related to setting up Persona Tags in Cardinal are nakama/claim-persona
and nakama/show-persona
.
The nakama/claim-persona
endpoint should be called with a body of:
{"personaTag": "the-persona-tag-you-want-to-claim"}
Persona Tag Storage Object
If the claiming of the Persona Tag was successful, the storage object in Nakama will have this form:
{
"tick": 245,
"status": "accepted",
"txHash": "0xba57f759722cfd42be659665e46883c6f46e6e0d54430a472c50313915d458ae",
"personaTag": "cool-mage"
}
The meaning of each field:
tick
: The Cardinal World tick that thisclaim-persona
request was made.status
: Whether thisclaim-persona
request was successful or not. This field can be:pending
: Cardinal has not yet had a chance to process this claim Persona Tag request.rejected
: Nakama or Cardinal decided to not allocate this Persona Tag to the user.accepted
: Nakama and Cardinal agree that this Persona Tag is associated with the user.
txHash
: The transaction hash that uniquely identifies the originalclaim-persona
requestpersonaTag
: The actual Persona Tag that was requested
Transaction Hashes and Receipts
When submitting a transaction to Cardinal, the transaction is not processed right away. Transactions are only processed in Systems registered with cardinal.RegisterSystems
, and Systems are only executed during a world tick. Because the transaction result cannot be resolved right away, a "transaction hash" is returned from Cardinal when a transaction is submitted. For example:
{
"tick": 245,
"txHash": "0xba57f759722cfd42be659665e46883c6f46e6e0d54430a472c50313915d458ae"
}
Eventually the transaction will be processed and a "Receipt" will be generated. A Receipt consists of:
- A txHash to identify it
- A single result that was generated for this transaction.
- A list of errors that were generated while processing this transaction
Cardinal exposes the endpoint /query/receipt/list
, however Nakama does not generate a corresponding RPC for this endpoint. Clients shouldn't call this endpoint directly. Instead, see the Streaming Transaction Receipts section for instructions on how to consume these receipts.
The remainder of this section describes the communication between Nakama and Cardinal.
Nakama periodically gets a list of completed transaction receipts by sending a payload like this:
{
"startTick": 245,
}
To the /qery/receipt/list
endpoint. Cardinal responds with something like:
{
"startTick": 245,
"endTick": 301,
"receipts": [
{
"txHash": "0xba57f759722cfd42be659665e46883c6f46e6e0d54430a472c50313915d458ae",
"tick": 250,
"result": {
// Some JSON body specific to this particluar transaction.
},
"errors": [
"invalid player",
"invalid world"
]
},
{
"txHash": "0xc6f46e6e0d54430a472c50313915d458a5ba57f759722cfd42be659665e46883",
"tick": 251,
"result": {
// Some JSON body specific to this particluar transaction.
},
"errors": []
}
]
}
The result field is game specific. For example, the following go code:
type MoveInput struct{
Dx, Dy int
}
type MoveOutput struct {
FinalX, FinalY int
}
var MoveTx = cardinal.NewTransactionType[MoveInput, MoveOutput]("move")
Would result in a transaction receipt like:
{
"txHash": "0xc6f46e6e0d54430a472c50313915d458a5ba57f759722cfd42be659665e46883",
"tick": 251,
"result": {
"FinalX": 100,
"FinalY": 200
},
"errors": [
"invalid position"
]
}
The result of a transaction and relevant errors can be set like this:
func MyMoveSystem(worldCtx cardinal.WorldContext) error {
for _, tx := range MoveTx.In(worldCtx) {
MoveTx.SetResult(worldCtx, tx.TxHash(), MoveOutput{
FinalX: 100,
FinalY: 200,
})
MoveTx.AddError(worldCtx, tx.TxHash(), errors.New("invalid position")
}
return nil
}
Note that only 1 result may be set, while any number of errors may be added to a transaction hash.
Streaming Transaction Receipts
Clients cannot ask Nakama directly for transaction receipts. Instead, they must subscribe to a data socket and handle receipts as they are generated. The Cardinal plugin uses Nakama's in-app notifications (opens in a new tab) to deliver receipts to clients.
The Cardinal Nakama plugin sets up a background process that periodically gets receipts form Cardinal (see dispatcher.go (opens in a new tab). Other parts of the Cardinal Nakama plugin subscribe to this dispatcher to handle transaction receipts. For example notifications.go (opens in a new tab) broadcasts receipts to any players that have registered to receive notifications (opens in a new tab).