A rock paper scissors blockchain

Published 2/2/2022.

This article goes through how to scaffold a blockchain that implements a very simple game. The starport version used is 0.19.1 and Golang v1.16.2. For details on how to install starport check out the official website. I simply put the binary in my Cosmos folder and run it by referencing the relative path, this makes upgrading easier.

Start by navigating to your Cosmos folder and run the following command with your choice of host repository, if you don't plan to upload your code anywhere it doesn't really matter what you pick, a popular choice is github.com/cosmonaut/. Since I will put the code in my personal repository the command looks as follows: starport scaffold chain gitlab.com/marcus.appelros/rps --no-module --address-prefix rps

Next run cd rps and scaffold the main module. We plan to award players with tokens so add the bank module as a dependency: starport scaffold module rps --dep bank

How should games be started? You could allow people to send challenges, either public or private, but a simpler method is to have a queue where when two people have joined it a game is started. Let's create the Queue datastructure: starport scaffold single queue address --no-message

If we now start the chain with starport chain serve and try to view the queue with rpsd query rps show-queue we will get an error. This is because it is initiated to nil in types/genesis.go (all files we will be modifying are located in x/rps/), go ahead and change this to: Queue: &Queue{""},

Reset the chain with starport chain serve -r and the command should work. Joining the queue will sometimes start a match, so lets create a list to store these in:

starport scaffold list match player1 player2 player1move:uint player2move:uint winner:uint --no-message

Now we are ready to implement the transaction to join the queue. Run starport scaffold message join-queue then replace the TODO in keeper/msg_server_join_queue.go with the following:

queue, found := k.Keeper.GetQueue(ctx)
if !found {
    panic("Queue not found")
}
if queue.Address == "" {
  queue.Address = msg.Creator
} else if msg.Creator == queue.Address {
  return nil, types.ErrAlreadyInQueue
} else {
  var match = types.Match {
    Player1:	queue.Address,
    Player2:	msg.Creator,
  }
  k.AppendMatch(ctx, match)
  queue.Address = ""
}
k.Keeper.SetQueue(ctx, queue)

Additionally replace the sample error in types/errors.go with:

ErrAlreadyInQueue = sdkerrors.Register(ModuleName, 1100, "Address already in queue")

Now if you start the chain and run rpsd tx rps join-queue --from alice -y in a separate terminal followed by rpsd q rps show-queue you should see the address field populated. Lets rerun the tx command with the from flag changed to bob, then we can confirm a match has begun with rpsd q rps list-match

If you like you can also implement a message to leave the queue, learning is much more efficient when not simply copy pasting code.

Before we can submit moves to our matches we must define the rules of the game. Make a new folder with mkdir x/rps/rules and create a .go file there with the following contents:

package rules

func DetermineWinner(player1move, player2move uint64) uint64 {
  if player1move == 0 || player2move == 0 {
    return 0
  }
  if player1move == player2move {
    return 3
  }
  if player1move == player2move + 1 || player1move == player2move - 2 {
    return 1
  }
  if player2move == player1move + 1 || player2move == player1move - 2 {
    return 2
  }
  return 4
}

Now we are ready to scaffold the submit-move message: starport scaffold message submit-move matchId:uint moveId:uint

To prevent people submitting arbitrary hand gestures we add the following code to ValidateBasic in types/message_submit_move.go:

if msg.MoveId != 1 && msg.MoveId != 2 && msg.MoveId != 3 {
  return ErrInvalidMove
}

Don't forget to register this new error in types/errors.go:

ErrInvalidMove = sdkerrors.Register(ModuleName, 1110, "Move not rock (1), paper (2) or scissors (3)")

Next open up keeper/msg_server_submit_move.go and add your version of "gitlab.com/marcus.appelros/rps/x/rps/rules" to the list of imports. Then replace the TODO with the following code:

match, found := k.GetMatch(ctx, msg.MatchId)
if !found {
  return nil, types.ErrMatchNotFound
}
if match.Winner > 0 {
  return nil, types.ErrMatchCompleted
}
if msg.Creator == match.Player1 {
  match.Player1Move = msg.MoveId
} else if msg.Creator == match.Player2 {
  match.Player2Move = msg.MoveId
} else {
  return nil, types.ErrNotInMatch
}
if match.Player1Move > 0 && match.Player2Move > 0 {
  match.Winner = rules.DetermineWinner(match.Player1Move, match.Player2Move)
  player1acc, err := sdk.AccAddressFromBech32(match.Player1)
  if err != nil {
    panic(err)
  }
  player2acc, err := sdk.AccAddressFromBech32(match.Player2)
  if err != nil {
    panic(err)
  }
  if match.Winner == 3 {
    err = k.bankKeeper.MintCoins(ctx, types.ModuleName, sdk.NewCoins(sdk.NewCoin("draw", sdk.NewInt(2))))
    if err != nil {
      panic(err)
    }
    err = k.bankKeeper.SendCoinsFromModuleToAccount(ctx, types.ModuleName, player1acc, sdk.NewCoins(sdk.NewCoin("draw", sdk.NewInt(1))))
    if err != nil {
      panic(err)
    }
    err = k.bankKeeper.SendCoinsFromModuleToAccount(ctx, types.ModuleName, player2acc, sdk.NewCoins(sdk.NewCoin("draw", sdk.NewInt(1))))
    if err != nil {
      panic(err)
    }
  } else {
    err = k.bankKeeper.MintCoins(ctx, types.ModuleName, sdk.NewCoins(sdk.NewCoin("win", sdk.NewInt(1))))
    if err != nil {
      panic(err)
    }
    if match.Winner == 1 {
      err = k.bankKeeper.SendCoinsFromModuleToAccount(ctx, types.ModuleName, player1acc, sdk.NewCoins(sdk.NewCoin("win", sdk.NewInt(1))))
      if err != nil {
        panic(err)
      }
    } else if match.Winner == 2 {
      err = k.bankKeeper.SendCoinsFromModuleToAccount(ctx, types.ModuleName, player2acc, sdk.NewCoins(sdk.NewCoin("win", sdk.NewInt(1))))
      if err != nil {
        panic(err)
      }
    }
  }
}
k.SetMatch(ctx, match)

To use the functions from the bank module we have to register them in types/expected_keepers.go under the appropriate interface:

MintCoins(ctx sdk.Context, moduleName string, amt sdk.Coins) error
SendCoinsFromModuleToAccount(ctx sdk.Context, senderModule string, recipientAddr sdk.AccAddress, amt sdk.Coins) error

Also register the new errors in types/errors.go:

ErrMatchNotFound = sdkerrors.Register(ModuleName, 1120, "Match not found")
ErrMatchCompleted = sdkerrors.Register(ModuleName, 1130, "Match already completed")
ErrNotInMatch = sdkerrors.Register(ModuleName, 1140, "You are not in this match!")

Alright, the logic is complete and we are ready to give it a whirl! Start the blockchain and run some variation of the following commands:

rpsd tx rps join-queue --from alice -y
rpsd tx rps join-queue --from bob -y
rpsd q rps list-match
rpsd tx rps submit-move 0 3 --from alice -y
rpsd tx rps submit-move 0 3 --from bob -y

Verify that some coins were sent out with rpsd q bank balances $(rpsd keys show alice -a)

Where to go from here? The list is endless, obviously the state of the rock paper scissors game is flawed since one can simply query the list of matches to see what the opponent picked, to implement a game with hidden variables simply removing them from the output of a query is not enough though as the values can still be extracted from the database files. One also needs to make it harder to obtain the minted tokens, so that anyone with scripting skills doesn't have access to an unlimited amount. If you want to implement the time limit from the checkers tutorial you can do this by changing NoFifoIdKey to the maximum value storable in a uint.

After a while it becomes unwieldy to look up the match ids directly from the list of matches, one could solve this by adding a map that resolves addresses to associated games and then implementing a migration, however that deserves a dedicated article.