Nakama, Meet Blockchain

Benjamin Jordan
Spyre.io
Published in
7 min readSep 12, 2024

Let me give a quick overview of the Spyre stack. Like Newton, we stand on shoulders and like that random person Stephen Hawking once met, it’s shoulders all the way down.

Our clients connect directly to the best open-source game server on the planet: Nakama. If you’re unfamiliar, Nakama is a monolithic game server with a boatload of features built in, including real-time multiplayer. It’s written in Go, and has a great module system which lets you plug-in your own go modules.

Behind Nakama we have our own creation, creatively called chain-chomp. There’s a little-known IP called Mario Brothers about two brothers — only one of whom is named Mario — and a “chain chomp” is one of the more interesting creatures in this mythology. I defy you to come up with a name as clever as “chain-chomp” for a blockchain integration server. It’s genius is what it is. It essentially sits between Nakama and blockchains (specifically, EVM-compatible JSON-RPCs).

A question came up on the Nakama forum a bit ago, and while I did respond with a few tidbits, I forgot to follow up because I was busy building our latest HIT GAME, Hangman Clash and being on-boarded as a member of the Thirdweb Startup Program!!! (I digress) The question was essentially: how are folks integrating Nakama with blockchains?

I gathered, from the tumbleweed that blew through that forum post, that there are not many of us doing this. Tip-sharing is always appreciated, so here I am. Consider this article a long-form tip.

“Shaw’s Garden” | Paul Sableman

Distributed Computing is Asynchronous

Blockchains — the good ones anyway, are distributed. And the most essential thing to know about dealing with distributed transactions is that you need an asynchronous API, and by this I don’t mean an async/await. I don’t care which blockchain you’re using right now, it’s slower than a Postgres write. This means that if you’re used to writing endpoint that waits for some db writes, this is an approach that will not work here. Furthermore, if you really want to be “multi-chain”, well then you’ve gotta support the chains that take ages to confirm anyway.

By asynchronous API, I mean that we need to decouple the blockchain transaction confirmation from the logical request a client is making.

For clarity I’m going to use simple HTTP semantics while we dive into this. Say you have some endpoint on your server:

POST /v1/rpc/mint

Note that we’re good boys and girls who version our APIs (shakes fist at non-versioners) — but more than that, when we POST, we expect a reasonable response time. Let’s say less than 200 ms. If this endpoint needs to do some blockchain transaction, like mint a rocket-launcher-cat, that’s not going to happen. Thus, it needs to return some sort of reference to an ongoing transaction. In the case of the incredibly named chain-chomp, we return a transaction resource. I do mean this in the usual “REST API” respect: it’s an object stored in a relational database with an id and a status.

Chain-chomp provides a typical API for inspecting transaction resources via GET, with find or get-by-id semantics. You can see it in our docs here but in short, a transaction has a status and a txnHash. These, as you would expect, are populated after the transaction is confirmed. Currently, we use viem to monitor transactions, and these watchers are really only in-memory. This means that if chain-chomp restarts, it does not yet know how to restart those watchers for in-progress transactions. This is a “meh, we’ll get to it” sorta thing — but it is admittedly something we need to get to eventually.

So from a high level, chain-chomp submits transactions to a blockchain via the typical EVM JSON-RPC API, creates resources to represent the transactions, and watches for confirmation.

Now let’s back up to Nakama. Chain-chomp is great, but what do we do on our game server? Say we need to do a blockchain transaction: Nakama calls chain-chomp to do the thing, receives a transaction resource, and stores it via Nakama’s storage interface. Then what does Nakama do? Well, it just polls chain-chomp.

This is both delightfully simple and also a bit naive. In future versions of chain-chomp, we’re planning on adding web-hook registration. This would allow chain-chomp to simply call Nakama back when the transaction is confirmed. This does not completely remove the burden, however. Even with webhooks we would still keep polling, just increase the interval. This is because web-hooks can and do fail — no big deal, we still have polling as a simple backup.

Follow our adventure on X.

Play our newest game: Hangman Clash.

Chat with us on Discord.

“Mullit” | Paul Sableman

What does polling look like in go?

How do we actually manage polling on the Nakama side? There is no specific API in Nakama for setting up this type of job, but we don’t actually need anything from Nakama: we have Go.

There are many ways to cook this egg. In our case, we prefer simplicity first.

In many languages, say Java or C#, you’d probably be tempted to make a long-running, heavyweight Thread or .NET Task object and use a producer/consumer approach. The thread has a queue, you add stuff to the queue, it runs through the queue and polls stuff, removing them when confirmed. In Java and C# I tend to favor the Actor Model with Akka and Akka.NET, respectively. Actors let you write simple, serial code.

However, none of this is necessary here. Let’s not build a tank when a bicycle will do: Go has goroutines. These are, for reasons deeply explained in a book I highly recommend: Concurrency in Go: Tools and Techniques for Developers, perfectly situated to solve this polling issue.

Let’s look at some code pulled from our not-open-source-yet Nakama module:

// submit a txn to the blockchain
func SubmitTxn(
ctx context.Context,
nk runtime.NakamaModule,
callback func(int64, string, error)) (int64, error) {
// send to chain-chomp, receive a txnId
txnId, err := web3.SubmitDeposit(ctx, nk, reqId, userId, deposit, signature)
if err != nil {
util.Error(ctx, "Failed to submit Deposit! @Error", err)

return -1, err
}

util.Info(ctx, "Successfully submitted Deposit, received chain-chomp id @TxnId. Polling for result.", txnId)

// start a goroutine to poll
go func() {
// we love composable contexts
ctx = util.WithTag(
util.WithTag(
// we detach the context because the parent may be canceled
context.WithoutCancel(ctx), "reqId", reqId),
"method", "submitTxn")

// now poll for confirmation
for {
// retrieve txn information from chain-chomp
txn, err := web3.GetTxn(ctx, reqId, txnId)
if err != nil {
util.Error(ctx, "Could not watch txn: failed to get txn: @Error", err)
} else {
// the status may not have changed from our in-memory model
if txn.Status == "success" {
util.Info(ctx, "Submit txn confirmed.")

callback(txnId, txn.Status, nil)
return
} else if txn.Status == "failure" {
util.Info(ctx, "Submit txn failed.")

callback(txnId, txn.Status, errors.New(txn.Error))
return
}
}

util.Info(ctx, "Submit txn not confirmed yet: @Status.", txn.Status)

// easy sleep
time.Sleep(5 * time.Second)
}
}()

return txnId, nil
}

I know, you’re enamored with our logging API (we stole the idea from C#’s Serilog). But let’s keep our mind on the task at hand.

First of all, this method returns synchronously, before the polling. The caller of this method can store the transaction id as soon as it returns using Nakama’s usual storage mechanisms. This lets us start the poll back up or lazily ask for the transaction in case of a restart or other event.

Secondly, we don’t need some complicated job scheduling service, we just need a loop and a goroutine. Chain-chomp handles all the nasty stuff around keeping that transaction status up to date. The Go scheduler does the heavy lifting of making sure goroutines scale (see here and here, but a better reference is that book!). “Enterprise job schedulers” usually focus on persistence, resumption, and perhaps some sort of sharding support. In our case, chain-chomp is handling (most of) those things, and since we’re storing the transaction id we can resume polling or lazily ask for status.

“Garden Sculpture” | Paul Sableman

Last Step

Finally, the callback.

We sent a callback into that method, and it is called when the transaction is confirmed. From here, we do some storage updates using the Nakama API, and we use Nakama to send notifications over a socket to connected players. On the client side (i.e. in the browser), this allows us to provide instant feedback when we start an action that requires a lengthy transaction, and we get an async socket event when it’s finished. On socket disconnect/reconnect, we check for notifs we may have missed and catch-up.

All in all, it’s proven an effective solution. Hopefully this long-form tip saves someone as much time as it took us to write. Happy building!

Follow our adventure on X.

Play our newest game: Hangman Clash.

Chat with us on Discord.

--

--

Spyre.io
Spyre.io

Published in Spyre.io

We are a casual-competitive game studio, building skill-based browser games.

Benjamin Jordan
Benjamin Jordan

Written by Benjamin Jordan

Tech, thought, teaching. Total loser. Founder @SpyreIO, Adjunct @SLU. Formerly VPE @N3TWORK, CTO @Big Run Studios, CTO @Enklu, Studio Tech Director @NCSOFT.