DEV Community

Ahmed Castro for Filosofía Código EN

Posted on

How to make an Ethereum Real-Time dApp with no Spaghetti code

I spent two weeks building a real time dApp and this article summarizes how I had to rethink architecture, design and UX for very fast blocktimes.

The dApp that my friend and I built is a simple War game: the player and the house each draw a card, and the highest card wins. The challenge is generating 100% on-chain random numbers with the lowest latency possible.

war game

A betting game?

Yes! I personally have a thing for casinos. I understand that players have to be responsible when playing and that they can be dangerous, but still, I just happen to like them.

As a matter of fact, the first blockchain application I released was a casino.

dogecoin casino game

That was on 2013, it was a Ruby on Rails backend connecting a local Dogecoin node I ran.

There were no smart contracts so I decided to take it down because I had no way to prove that I was operating in a fair manner.

I'm from a 3rd world country where building is hard, and back then it was harder. This was my first time monetizing my work so that project was very meaningful to me, also I was able to connect to many people trough IRC. Fun times!

Then, I learned about Ethereum smart contracts and decided to give it another try, this time using Chainlink VRF.

breadacle game the chainlink vrf casino

My second aptempt was Breadacle, a coin toss game where you bet if the bread will come out toasted or burnt.

Now my game had great provably guarantees of fairness, but was way too slow. Also a bit expensive and still had to trust the oracles.

Building a fast and fair game

Now, with blockchains with low latency I can build a fast, provably fair, oracleless game.

This time I did it with a Commit-Reveal scheme. A 3 transaction process that has to happen very fast.

commit reveal scheme behind war game

Currently, our demo offers 1.3s average gameplay latency, this means 3 transactions in 1.3s which we think it's ok but not good enough. We believe we can bring it down to sub 500ms times with improvements in real time chains infra optimization and our own too.

In this architecture the player runs the client in their browser and the house (me) hosts a backend that responds to each players game.

Let's explore both starting with the backend

To be able to serve many players, the backend need to listen to new player games, index them in memory, and batch them with Multicall.

In case of down time, we can have a centralized database that serves as backup. Or better yet, using the on-chain state to recover. So what I'm saying is using the chain as backup, quite interesting IMO.

real time ethereum war game backend architecture

Alternatively, in case public RPCs for players are too slow, we can open an endpoint to query the game state from our server. This is debatable, on one had hand this can make the game more responsive but on the other it means that the house (me) could trick the player to reveal his random number before I post mine.

In this point in time we haven't been able to test in fullness beacuse we built the game in MegaETH (a 20ms block time chain, very fast!) but we haven't been able to access a web socket that right now is restricted to internal teams only.

We are ready to test it once we get access to that WSS key.

Let's now talk about the frontend, imo the most interesting part.

The frontend

Little by little I found out that building a real time ethereum dApp is very different from a normal dApp frontend or moblile app.

When building a real time dApp we have to stop thinking about listener/callback flows and start thinking similar to what a gamedev would.

real time ethereum war game frontend architecture

As a quick side note, and a shameless self promotion, I used to be a gamedev. Built my own game engine, release a game on steam with it, built a C SDK used by 10s of thousands of players concurrently and was interviewed by Unreal Engine in a livestream.

So the most important thing that I noticed is that instead of developing with a listeners and callbacks in-mind we need a gameLoop() such as

gameLoop() {
  readState()
  processInputs()
  sendTransactions()
  processUI()
}
Enter fullscreen mode Exit fullscreen mode

This function process the entire game and should be called continuously with fast intervals.

This is because real time apps, just like games, are very chaotic and if you try to do everything with callbacks, like a normal dApp, you will end with spaghetti code and glitchy UX.

This should apply not only to games, but also defi, social, id, etc...

So a few things to note while doing something like this:

1. Only read the state once per tick

A tick is one gameLopp() pass and you should only make one call() to ethereum. Each call takes from 100ms to 300ms, they are too slow.

And yes, this includes nonce and gasPrice checks. So don't use web3.js, ethers or viem abstractions, build your transactions manually yourself. Do it by keeping track of your nonces locally and reading the gasPrice only once every 5 minutes or so.

Also consider writing a function that returns all the information that you will need every tick. I like to call it "Singleton Call" and currently mine looks like this:

    function getGameState(address player) external view returns (
        uint player_balance,
        State gameState,
        bytes32 playerCommit,
        bytes32 houseHash,
        uint256 gameId,
        GameResult[] memory recentHistory
    ) {
       ...
    }
Enter fullscreen mode Exit fullscreen mode

Notice how I'm returning a list of the 10 latest games so I can display them in the frontend. And I'm even returning the player balance by calling player.balance internally just so I don't have to do it a separate call.

Yes, this function doesn't look elegant so a good alternative is to use Multicall instead. I like it better with no Multicall in the frontend to keep it simple, but to each their own.

2. Buffer inputs

When the user clicks a button, don't react to it immediatly. Instead, store it in a list and process it later.

This concept is very familiar in fighting games, where you have to press a very precise combination on inputs to trigger special attacks.

fighting game input buffer

Of course, my game is not as complex as a fighting game but still, we don't have control of the multithreading nature of javascript. So you have two options: a. implement an input buffer b. enter the realm of thread locks hell.

I recommend starting with a list-based buffer and handling only one action per tick. Later, you can try processing multiple actions per tick or updating the UI before handling input in the game loop. But in general I would stick with a simple buffer, if the app feels unresponsive, it might be a sign that the chain you're using is not fast enough.

3. Local storage wallet

Of course you don't want your user to click to confirm every transaction. So instead generate a private key on the player's browser like this:

function generateWallet() {
  const account = web3.eth.accounts.create();
  localStorage.setItem('localWallet', JSON.stringify({
    address: account.address,
    privateKey: account.privateKey
  }));
  return account;
}
Enter fullscreen mode Exit fullscreen mode

This will generate a new wallet and store it in the browser's local storage.

Of course users should not treat it as their cold storage because it just isn't. If they clean their browser data goodbye wallet.

Regardless, this will give the users a good experience. Remember that my casino game is a 3-transaction process? In my case I can even reveal the random number automatically, even without the player clicking on something.

This is currently being used by Dark forest and other autonomous world gmames.

4. Paralelization

You have to think how make every action as soon as possible and how to give feedback as fast as possible to the player.

In my case this meant that I can start a new game even before the last one finished.

So normally a game will be played like this:

  1. Player sends commitment tx
  2. Player detects the house responded
  3. Player sends reveal transaction

We can parallelize by starting a new game even before the reveal is confirmed on-chain. This is expecting that our new commit will be sequenced after the reveal does.

Also, by the time the player has the house randomness he can know the game result.

So in the UI, we can show the game result even before the result is calculated on-chain.

I like to call this "Optimistic Rendering".

I think optimizations like these are very important and should be implemented, but of course, we don't have control of the sequencer. We are doing this expecting that the sequencer would operate in a FiFo manner. So we will also need to implement a way to recover and auto-retry in the rare case the sequencer didn't acted as expected.

Thanks for reading!

Please try our demo in MegaETH testnet at realtimeplay.xyz and follow us and give us feedback at @RealTimePlayXYZ.

This is open source work as always so PRs very welcome on github.

Top comments (0)