FelipeRibeiroLabs / sc-eng-gaming

Showcasing how a Rock Paper Scissors game could be made #onFlow

Home Page:https://onflow.org

Geek Repo:Geek Repo

Github PK Tool:Github PK Tool

⚠️ This repo is a WIP aiming to showcase how Auth Account capabilities from FLIP 53 could be use to achieve hydrid custody

Rock Paper Scissors (Mostly) On-Chain

We’re building an on-chain Rock Paper Scissors game as a proof of concept exploration into the world of blockchain gaming powered by Cadence on Flow.

Overview

As gaming makes its way into Web 3.0, bringing with it the next swath of mainstream users, we created this repo as a playground to develop proof of concept implementations that showcase the power of on-chain games built with the Cadence resource-oriented programming language. It's our hope that the work and exploration here uncovers unique design patterns that are useful towards composable game designs, helping to pave the way for a thriving community of game developers on Flow.

For our first proof of concept game, we've created the RockPaperScissorsGame and supporting contracts GamePieceNFT and GamingMetadataViews. Taken together, these contracts define an entirely on-chain game with a dynamic NFT that accesses an ongoing record of its win/loss data via attachments added to the NFT upon escrow.

As this proof of concept is iteratively improved, we hope to create a host of reference examples demonstrating how game developers could build games on Flow - some entirely on-chain while others blend on and off-chain architectures along with considerations for each design.

We believe that smart contract-powered gaming is not only possible, but that it will add to the gaming experience and unlock totally new mechanisms of gameplay. Imagine a world where games don't require a backend - just a player interfacing with an open-sourced local client making calls to a smart contract. Player's get maximum transparency, trustlessness, verifiability, and total ownership of their game assets.

With a community of open-source developers building on a shared blockchain, creativity could be poured into in-game experiences via community supported game clients while all players rest assured that their game assets are secured and core game logic remains unchanged. Game leaderboards emerge as inherent to the architecture of a publicly queryable blockchain. Game assets and logic designed for use in one game can be used as building blocks in another, while matches and tournaments could be defined to have real stakes and rewards.

The entirety of that composable gaming future is possible on Flow, and starts with the simple proof of concept defined in this repo. We hope you dive in and are inspired to build more fun and complex games using the learnings, patterns, and maybe even resources in these contracts!

Components

Summary

As mentioned above, the supporting contracts for this game have been compartmentalized to four primary contracts. At a high level, those are:

  • GamingMetadataViews - Defining the metadata structs relevant to an NFT's win/loss data and assigned moves as well as interfaces designed to be implemented as attachments for NFTs.

  • GamePieceNFT - This contract contains definitions for the gaming NFT and its collection. You'll note that the types of resources that can be attached to an NFT are generic, but must at minimum be designed for the NFT or interfaces types it implements. See more about resource & struct attachments here

  • RockPaperScissorsGame - As you might imagine, this contract contains the game's moves, logic as well as resources and interfaces defining the rules of engagement in the course of a match. Additionally, receivers for Capabilities to matches are defined in GamePlayer resource and interfaces that allow players to create matches, be added and add others to matches, and engage with the matches they're in. The Match resource is defined as a single round of Rock, Paper, Scissors that can be played in either single or two player modes, with single-player modes randomizing the second player's move on a contract function call.

GamingMetadataViews

This contract proposes a new set of NFT metadata views for Gaming. Gaming is a subdomain of NFTs, and it's' possible to imagine many different ways that gaming-specific metadata can be generalized into shared metadata views. There are countless types of gaming-related metadata that could be shared this way, allowing third party apps or even other games to create unique experiences or metrics using these interoperable pieces of data. This is possible because they are all accessible via the NFT itself, and in many cases via the contract also!

GameContractMetadata

For game-related contracts and resources, GameContractMetadata defines information identifying the originating contract and allows a developer to attach external URLs and media that would be helpful on the frontend.

BasicWinLoss & BasicWinLossRetriever

As a proof of concept, we have defined a basic metadata struct to show the win/loss record (BasicWinLoss) for an NFT for any game it participates in. It tracks wins, losses, and ties and exposes the ability to retrieve those values (stored in the game contract in our construction) directly from the NFT resource. While the implementation defined in this repo is very simple, you can imagine a more complex set of gaming metadata containing an NFT's health and defense attributes, evolution characteristics, etc., making this pattern useful for any sort of game you might be designing.

In our construction, the game contract stored win/loss data, maintaining their own histories of NFT's BasicWinLoss so that they can create interesting metrics and records based on the data, allow anyone to retrieve any of the data easily from a central place, and also enable anyone with the NFT object itself or a reference to it to easily retrieve the data stored on it without directly relying on a central contract.

The BasicWinLossRetriever interface defines an interface for a resource that can retrieve this BasicWinLoss record. This retriever is implemented along with GameResource and MetadataViews.Resolver as an Attachment for any NonFungibleToken.INFT. It is then added within a Match when an NFT is escrowed so the win/loss record of that NFT can be retrieved..

AssignedMovesView & AssignedMoves

The AssignedMovesView is defined to provide a metadata struct containing info relating to the associated game, NFT and the moves assiged to that NFT.

In order to maintain, add and remove moves, the AssignedMoves interface defines a generic resource with an array of moves represented as AnyStruct. Addition and removal of moves is limited by access(contract) so that only the contract in which the resource is implemented can add and remove moves - even the owner of the base resource cannot alter the assigned moves.

While everyone gets the same moves in Rock, Paper, Scissors, this setup can be helpful in a game where players have to earn moves, moves are single use (e.g. power-up move, etc.), deck-based games where moves are expended, etc.

GameResource

This is a very simple interface allowing for the addition of GameContractMetadata to an implementing resource.

Considerations

A consideration to note here on the side of the game developer is that the storage costs for this game data will be incurred on the account to which the game contract is deployed. For this, you get a public and central location which is very useful for building a leaderboard of NFT's win/loss performance.

Alternatively, you could construct an NFT so that the metadata would be stored on the NFT itself, but you would lose that in-built on-chain leaderboard and will need to consider if and how you'll want to enable that functionality. Some solutions involve maintaining off-chain (but verifiable) stats based on indexed events or simply requiring a user to pay for the storage themselves while maintaining a Capability to the NFTs that allows you to query their stats on a time interval.

GamePieceNFT

As mentioned above, there can be many implementations of an NFT that would make it relevant for use in a game. Our GamePieceNFT is as minimal and generic as possible so that it can be used in a number of simple games. Fundamentally, the NFT defined here serves as a receiver for attachments added to it throughout gameplay.

Games can implement their own Attachments and add them to these NFTs. This makes the NFT maximally composable! Future Attachment features like iteration will enable the base NFT to provide more context about its attachments, so this feature will only get more powerful & composable.

There was much discussion about whether an NFT's win/loss records should be stored directly on the NFT as an attachment, or on that game contract and attach a retriever for the NFT to recall its record. This is ultimately a design decision, with each approach having its pros/cons. Because we wanted an emergent on-chain leaderboard, we decided to store all records on the game contract. However, had we found an acceptable event indexing service or wanted to build one ourselves, we could have relied on off-chain indexers to maintain win/loss history for a leaderboard & stored the data directly on the NFT.

The usual components of a standard NFT contract such as Collection, Minter, and associated interface implementations are present as well.

Considerations

For this proof of concept, we did not find gating minting necessary, but anyone referring to this design should consider if doing so for things like rate-limiting, rarity, etc. Additionally, each GamePieceNFT is relatively similar in that the metadata between each is largely the same with the exception of the NFT's ID. This is because we believe that the game attachments added to each NFT will differentiate them, and we wanted that to shine through. This NFT can be thought of like a semi-fungible token that gets more fungible the more you use it!

RockPaperScissorsGame

All the of above components are put together in this smart contract implementation of single-round match Rock, Paper, Scissors. Again, this is a simple proof of concept that will hopefully illuminate the power of Cadence and Flow for the purpose of game development on-chain.

Before getting into the contract level details, let's first cover the basic gameplay setup defined here. The idea is that two players engage in a single round of Rock, Paper, Scissors where Rock > Scissors > Paper > Rock > ... and so on.

A Match is mediated only by the contract logic, Capabilities, and conditions. While Match resources & win/loss records are stored in the contract account, the game is otherwise peer-to-peer. Once a Match has been created, the players submit their NFTs so that the game can record the match win/loss history of that NFT. After both moves have been submitted, a winner is decided, win/loss results are recorded, and the NFTs are returned to their owners.

Now let's go over what that looks like in the contract. In broad strokes for a two-player Match, each GamePlayer maintains a mapping of Match.id to MatchLobbyActions and another of Match.id to MatchPlayerActions.

MatchLobbyActions allow the player to escrowNFTToMatch() (which must occur by both players before a match can be played) along with getters for Match winner information.

The pattern outlined above allows a GamePlayer to create a Match via GamePlayer.createMatch(), saving the new Match to the contract's account storage, and linking MatchLobbyActions and MatchPlayerActions to the contracts account's private storage. When creating a Match; however, a player must also escrow their NFT providing their NonFungibleToken.Receiver along with it so the NFT can be returned. Requiring "skin in the game", so to speak, helps to minimize the spam vector where an attacker can simply create an arbitrary number of Matches to take up account storage. Once the player's NFT has been escrowed to the Match, a MatchPlayerActions Capability is returned and is added to the GamePlayer's matchPlayerCapabilities.

To add a GamePlayer to a match, the player could call signUpForMatch() with the desired matchID which would add the MatchLobbyActions to the GamePlayer's matchLobbyCapabilities. Alternatively, the GamePlayerPublic interface exposes the ability for a GamePlayer to be added to a Match by anyone, which you can see in the setup_new_multiplayer_match.cdc transaction. The latter method is the on-chain version of inviting your friend to a Match as there is no obligation for them to participate.

Once a match has been set up, two NFTs must be escrowed. Then each player can submit moves via MatchPlayerActions.submitMoves(), requiring both the move and a reference to the player's GamePlayerID Capability. We require this reference since both players have access to the same Capability, exposing a cheating vector whereby one player could submit the other player's move if the contract lacked a mechanism for identity verification. Since access control is a matter of what you have (not who you are) in Cadence, we take a reference to this GamePlayerID Capability and pull the submitting player's id from the reference (which should be kept private by the player).

Once both players' moves have been submitted, resolveMatch() can be called on the Match which does the following:

  1. determines the winner
  2. alters the BasicWinLoss metadata of the NFT in winLossRecords based on the outcome

To return escrowed NFTs, returnPlayerNFTs() can be called by either player after resolution (or timeout is reached), with the backup method retrieveUnclaimedNFT() allowing players to retrieve their individual NFTs should a problem with the one of the players' Receivers prevent return of both NFTs.

Also know that a Match can be played in single-player mode. In this case, a player escrows their NFT and submits their move as usual. Once they submit their move, they must then call the submitAutomatedPlayerMove() contract method which generates & submits a move as the second automated player. After the moves have been submitted, resolveMatch() is then called to determine the outcome, but must be called in a separate transaction than move submission. This is enforced by block height difference between move submissions - not an ideal solution.

We enforce separate transaction call because the automated player's move is generated using unsafeRandom() which can be gamed. For example, I could submit my move as rock and set a post-condition that that the outcome from the generated move results in a win, allowing me to game the system. An oracle or a safer randomness API implemented into Flow and Cadence can and will at some point solve this problem, removing the need for these workarounds. Until then, we compartmentalized the Match into these commit-resolve stages.

Note that a Match can only be utilized once.

Taking a look at the contract, you'll see that the core logic of Rock, Paper, Scissors is exposed in the contract function determineRockPaperScissorsWinner(). This was done in hopes that the core logic could be used in other variations. You could imagine another contract that defines a Match resource using other NFTs or that combines logic of a hypothetical tic-tac-toe game or that runs for multiple rounds and requires a buy-in from players which goes to the winner. Again, this is designed to be built on by the Flow community, so have fun with it and make building the game part of the fun!

RPSBasicWinLossRetriever

The BasicWinLossRetriever attachment effectively serves as a pointer to the game's BasicWinLoss data, allowing the game to define the access to and conditions under which the metadata could be altered while still retrieving the data from the NFT it refers to.

The simplest way to explain the storage patern of an NFT's win/loss data is that the NFT's BasicWinLoss is stored on the relevant game contract while the NFT stores an attached getter (the retriever) that can access and return its BasicWinLoss record within that game.

RPSAssignedMoves

To model how a game might provide moves for gameplay, we've created the RPSAssignedMoves resource. This resource is attached to escrowed NFTs and seeded with your standard moves for Rock, Paper, Scissors. Other games might add and remove available moves throughout gameplay and validate whether a submitted move is valid given the player's escrowed NFT. For example, maybe my AssignedMoves resource represents a deck of single use cards from which a card is removed when it's played. Alternatively, I might have a fighter that loses the ability to play moves as their health declines. Again, the focus here is demonstrating the definition of a resource that is given to a player, but containing attributes that only contract game logic can alter.

Considerations

A primary concern for us in the construction of this game is improving the UX such that a player wouldn't have to submit transactions for each move. This is a core problem for smart contract powered gaming, and likely something that requires changes to the protocol's on-chain account representation and/or higher levels of abstraction around account associations and identity.

A potential workaround in Cadence at present is a Capabilities-based approach, where I create a Capability that exposes restricted access to my GamePlayer resource and give that to some trusted agent - say a game client. Then, I tell that game client what transaction to submit for me using that Capability. For a number of reasons, we've decided against this approach, but primarily due to Capabilities' present lack of auditability.

That's all to say that we recognize this problem, many minds are working on it, and the UX will vastly improve in coming months. For the purpose of this proof of concept, we've chosen to move forward with the base contract components upon which we can soon build that seamless UX (which is soon to come).

One more consideration comes from the contract's acceptance of any NFT. While this maximizes openness, it also means that NFTs with the same ID cause collisions in the win/loss record mapping indexed on escrowed NFT IDs. This shouldn't be an issue for NFTs that assign ids on UUIDs, but users could experience a case where they effectively share a win/loss record with another NFT of the same ID. This could be handled by indexing on the hash of an NFT's ID along with its Type which should yield a unique value or alternatively, the NFTs UUID. The latter would be a harder ask as it's unlikely a requestor would have the NFT's UUID on hand if it's not already the equivalent to its ID.

A bit of a note on best practices...it's evident that defining on-chain game logic must involve some degree of adversarial thinking. For example, we could have (and did at one point) include returnPlayerNFTs() in resolveMatch() to remove the need for an additional call. However, we discovered that a malicious Receiver could panic on deposit() which would prevent Match resolution. This along with the revelation that I could assure game outcomes with the afforementioned post-condition on results led us to the commit-resolve pattern you see in the contracts & transactions.


Happy Path Walkthrough

With the context and components explained, we can more closely examine how they interact in a full user interaction. For simplicity, we'll assume everything goes as it's designed and walk the happy path.

  1. User onboarding in a single transaction - onboard_player.cdc
    1. Setup GamePieceNFT.Collection & link Capabilities
    2. Mint GamePieceNFT.NFT to player's Collection
      1. MintedNFT and Deposit events emitted
    3. Setup RockPlayerScissorsGame.GamePlayer & link Capabilities
  2. Single-Player Gameplay
    1. Player creates a new match, escrowing their NFT along with their NFT Receiver, emitting NewMatchCreated and PlayerEscrowedNFTToMatch
      1. RPSAssignedMoves are attached to their escrowed NFT if they are not already attached
      2. RPSWinLossRetriever is attached to the escrowed NFT if they are not already attached
    2. Player submits their move
      1. MoveSubmitted event is emitted with relevant matchID and submittingGamePlayerID
    3. Player calls for automated player's move to be submitted
      1. MoveSubmitted event is emitted with relevant matchID and submittingGamePlayerID (the contract's designated GamePlayer.id in this case)
    4. In a separate transaction (enforced by block height), player calls resolveMatch() to determine the outcome of the Match
      1. The win/loss record is recorded for the player's NFT
      2. The win/loss record is recorded for the designated dummyNFTID
      3. The escrowed NFT is returned to the escrowing player
      4. MatchOver is emitted along with the matchID, winningGamePlayerID, and winningNFTID.
    5. Player calls for escrowed NFT to be returned via returnPlayersNFTs(). Since the Match returns the escrowed NFTs directly via the given Receiver Capability, we made this a separate call to prevent malicious Capabilities from disallowing resolution. In this case, the worst a malicious Capability could do would be
  3. Multi-Player Gameplay
    1. Player one creates a new match, escrowing their NFT
      1. RPSAssignedMoves are attached to their escrowed NFT if they are not already attached
      2. RPSWinLossRetriever is attached to the escrowed NFT if they are not already attached
    2. Player one adds MatchLobbyActions Capability to Player two's GamePlayerPublic
      1. Player one gets GamePlayerPublic Capability from Player two
      2. Player one calls addPlayerToMatch() on their GamePlayer, passing the matchID and the reference to Player two's GamePlayerPublic
      3. PlayerAddedToMatch emitted along with matchID and the id of the GamePlayer added to the Match
    3. Player two escrows their NFT into the match
      1. RPSAssignedMoves are attached to their escrowed NFT if they are not already attached
      2. RPSWinLossRetriever is attached to the escrowed NFT if they are not already attached
    4. Each player submits their move
    5. After both moves have been submitted, any player can then call for match resolution
      1. A winner is determined
      2. The win/loss records are recorded for each NFT
      3. Each NFT is returned to their respective owners
      4. MatchOver is emitted along with the matchID, winningGamePlayerID, winningNFTID and returnedNFTIDs
    6. Any player calls for escrowed NFT to be returned via returnPlayersNFTs(). Since the Match returns the escrowed NFTs directly via the given Receiver Capability, we made this a separate call to prevent malicious Capabilities from disallowing resolution. In this case, the worst a malicious Capability could do would be to require that the other player call retrieveUnclaimedNFT() in a separate transaction to retrieve their singular NFT from escrow.

TODO - Transaction Diagrams

Below you'll find diagrams that visualize the flow between all components for each major game-related transaction.

onboard_player

Onboard player with GamePieceNFT Collection & NFT

setup_new_singleplayer_match

GamePlayer sets up new Match

setup_new_multiplayer_match

TODO

escrow_nft_to_existing_match

TODO

submit_both_singleplayer_moves

TODO

resolve_match

TODO


Edge Case Resolution

NFTs are escrowed, but the moves are never submitted

Since a match timeout is specified upon Match creation and retrieval of NFTs is contingent on either the timeout being reached or the Match no longer being in play, a player can easily retrieve their NFT after timeout by calling returnPlayerNFTs() on their MatchPlayerActions Capability.

Since this Capability is linked on the game contract account which shouldn’t not have active keys, the user can be assured that the Capability will not be unlinked. Additionally, since the method deposits the NFT to the Receiver provided upon escrow, they can be assured that it will not be accessible to anyone else calling returnPlayerNFTs().

NFTs are escrowed, but player unlinks their Receiver Capability before the NFT could be returned

In this edge case, the Receiver Capability provided upon escrowing would no longer be linked to the depositing player’s Collection. In this case, as long as the escrowing player still has their GamePlayer, they could call retrieveUnclaimedNFT(), providing a reference to their GamePlayerID and the Receiver they want their NFT returned to.

Player provides a Receiver Capability that panics in its deposit() method

This wouldn't be encounterd by the Match until returnPlayerNFTs() is called after match resolution. Depending on the order of the Receiver Capabilities in the nftReceivers mapping, this could prevent the other player from retrieving their NFT via that function. At that point, however, the winner & loser have been decided and the game is over (inPlay == false). The other player could then call retrieveUnclaimedNFT() to retrieve the NFT that the trolling Receiver was preventing from being returned.


Demo Using Flow CLI

To demo the functionality of this repo, clone it and follow the steps below by entering each command using Flow CLI from the package root:

Single-player

Let's start with demonstrating single-player gameplay:

  1. Create account - account name: p1
flow accounts create
  1. Onboard user, providing the GamePieceNFT Minter's address
flow transactions send ./transactions/onboarding/onboard_player.cdc 0xf8d6e0586b0a20c7 --signer p1
  1. Init gameplay...

    1. Create new Match
    flow transactions send ./transactions/rock_paper_scissors_game/game_player/setup_new_singleplayer_match.cdc 29 10 --signer p1
    
    1. Submit moves for the player & call for the automated player's move to be generated
    flow transactions send ./transactions/rock_paper_scissors_game/game_player/submit_both_singleplayer_moves.cdc 31 0 --signer p1
    
    1. Resolve the Match & return escrowed NFTs
    flow transactions send ./transactions/rock_paper_scissors_game/game_player/resolve_match_and_return_nfts.cdc 31 --signer p1
    
    1. Get the moves submitted for the Match
    flow scripts execute ./scripts/rock_paper_scissors_game/get_match_move_history.cdc 31
    
    1. Check Win/Loss record for the player's NFT
    flow scripts execute scripts/game_piece_nft/get_rps_win_loss.cdc 0x01cf0e2f2f715450 29
    

Multi-player

Now that we've seen single-player gameplay, let's see what multi-player looks like:

  1. Create two accounts - account name: p2
flow accounts create
  1. Onboard user
flow transactions send ./transactions/onboarding/onboard_player.cdc 0xf8d6e0586b0a20c7 --signer p2
  1. Init gameplay...

    1. Create new Match
    flow transactions send ./transactions/rock_paper_scissors_game/game_player/setup_new_multiplayer_match.cdc 29 0x179b6b1cb6755e31 10 --signer p1
    
    1. Escrow second player's NFT
    flow transactions send ./transactions/rock_paper_scissors_game/game_player/escrow_nft_to_existing_match.cdc 38 36 --signer p2
    
    1. Submit moves
    flow transactions send ./transactions/rock_paper_scissors_game/game_player/submit_move.cdc 38 0 --signer p1
    
    flow transactions send ./transactions/rock_paper_scissors_game/game_player/submit_move.cdc 38 1 --signer p2
    
    1. Resolve the Match & return NFTs
    flow transactions send ./transactions/rock_paper_scissors_game/game_player/resolve_match_and_return_nfts.cdc 38 --signer p1
    
  2. Check Win/Loss record

    flow scripts execute scripts/game_piece_nft/get_rps_win_loss.cdc 0x01cf0e2f2f715450 29
    
    1. Check Win/Loss record
    flow scripts execute scripts/game_piece_nft/get_rps_win_loss.cdc 179b6b1cb6755e31 36
    

About

Showcasing how a Rock Paper Scissors game could be made #onFlow

https://onflow.org

License:The Unlicense


Languages

Language:Cadence 82.4%Language:JavaScript 17.5%Language:Makefile 0.1%