How to build an onchain game in Unity
This tutorial will teach you how to build an onchain Rock, Paper, Scissors game in Unity using ChainSafe Gaming's web3.unity SDK.
Our onchain gaming expert Jay Albert live streamed this little how-to in December, 2024. Watch it here and follow the guide below to make a simple onchain game in Unity!
This tutorial will teach you how to build an onchain Rock, Paper, Scissors game.
Before starting
Make sure you have:
Setup web3.unity:
- Navigate to our docs and follow the getting started instructions to download the web3.unity SDK.
- Navigate to the dashboard to create your project and grab your project ID.
- Paste your project ID into your network settings.
Let's build an onchain Unity game
- Choose your images for rock, paper, and scissors.
- Add your images into an images folder in Unity and then convert your images to 2D sprites.
- Add a canvas, and image and then add your images for rock, paper, scissors, and opponents.
- Add a button component to each of your images.
- Add a text to the top of the canvas to display the result of the game.
- Create an empty object in the highest level of your hierarchy and label it GameManager. In the GameManager object, create a manager script.
- In the manager script, paste in the following script:
using System.Collections;
using System.Collections.Generic;
using UnityEngine;
using UnityEngine.UI;
using TMPro;
public class Manager : MonoBehaviour
{
// Text element to display the result of the game.
public TMP_Text Result;
// Image to display the opponent's choice visually.
public Image OpChoice;
// Array to store the choices: Rock, Paper, Scissors.
public string[] Choices;
// Sprites for Rock, Paper, and Scissors to visually represent choices.
public Sprite Rock, Paper, Scissors;
// Called when a player clicks a button corresponding to their choice.
public void Play(string myChoice)
{
// Set the opponent's choice to always defeat the user's choice.
string opponentChoice = GetWinningChoice(myChoice);
// Update the opponent's choice sprite and the result text.
UpdateOpponentChoice(opponentChoice);
UpdateResult(myChoice, opponentChoice);
}
// Returns the choice that will defeat the user's choice.
private string GetWinningChoice(string myChoice)
{
switch (myChoice)
{
case "Rock":
return "Paper"; // Paper beats Rock
case "Paper":
return "Scissors"; // Scissors beats Paper
case "Scissors":
return "Rock"; // Rock beats Scissors
default:
return "Rock"; // Default choice
}
}
// Updates the opponent's displayed choice.
private void UpdateOpponentChoice(string choice)
{
switch (choice)
{
case "Rock":
OpChoice.sprite = Rock;
break;
case "Paper":
OpChoice.sprite = Paper;
break;
case "Scissors":
OpChoice.sprite = Scissors;
break;
}
}
// Updates the result to always display "You Lose!"
private void UpdateResult(string myChoice, string opponentChoice)
{
Result.text = "You Lose!";
}
}
- Click on the GameManager and drag each of the objects in your hierarchy into their matching spots in the inspector, and do the same with your images. In the choices section, add 3 choices and name them Rock, Paper, and Scissors.
- Click on each of the buttons, and drag and drop the game manager object to the onclick function. Choose the play function, and enter in the selection for each button.
- Go into build settings, switch to WebGL and run your game.
- Now you have a working Rock, Paper, Scissors game!
Wait a minute!
If you followed the steps above you’ll notice that you lose the game every time. There are many games where users spend money to try to obtain items in lootboxes, to gain a competitive advantage, or to bet against other players and there’s no way of knowing if the game is rigged!
Let’s rework our game to ensure the game does not cheat. We'll also make sure players can verify the outcomes.
- Update your manage script with the following:
using System.Collections;
using System.Collections.Generic;
using UnityEngine;
using UnityEngine.UI;
using TMPro;
public class Manager : MonoBehaviour
{
// Text element to display the result of the game.
public TMP_Text Result;
// Image to display the opponent's choice visually.
public Image OpChoice;
// Array to store the choices: Rock, Paper, Scissors.
public string[] Choices;
// Sprites for Rock, Paper, and Scissors to visually represent choices.
public Sprite Rock, Paper, Scissors;
// Called when a player clicks a button corresponding to their choice.
public void Play(string myChoice)
{
// Randomly choose the opponent's choice from the array of choices.
string randomChoice = Choices[Random.Range(0, Choices.Length)];
// Update the opponent's choice sprite and the result text based on the game logic.
UpdateOpponentChoice(randomChoice);
UpdateResult(myChoice, randomChoice);
}
// Updates the opponent's displayed choice.
private void UpdateOpponentChoice(string choice)
{
switch (choice)
{
case "Rock":
OpChoice.sprite = Rock;
break;
case "Paper":
OpChoice.sprite = Paper;
break;
case "Scissors":
OpChoice.sprite = Scissors;
break;
}
}
// Determines and updates the result of the game based on the player's and opponent's choices.
private void UpdateResult(string myChoice, string randomChoice)
{
if (myChoice == randomChoice)
{
Result.text = "It's a Tie!";
}
else if ((myChoice == "Rock" && randomChoice == "Scissors") ||
(myChoice == "Paper" && randomChoice == "Rock") ||
(myChoice == "Scissors" && randomChoice == "Paper"))
{
Result.text = "You Win!";
}
else
{
Result.text = "You Lose!";
}
}
}
- Build your scene again to verify that the outcomes are random.
- Now let’s working on proving it’s random to the player.
Chainlink VRF
Chainlink’s Verifiable Random Function (VRF) is a tool that generates random numbers along with proof that the numbers are truly random and tamper-proof. This proof can be verified by anyone to ensure fairness, making VRF ideal for applications like lotteries, gaming, and secure decision-making in blockchain systems. Let’s use VRF to make the opponent’s decision of Rock, Paper or Scissors.
- Visit the Chainlink dashboard to create a subscription. The subscription is the account that will be used to fund your Chainlink calls.
- Fund your subscription with 0.1 AVAX. Once the subscription is funded, click on I’ll do it later to see your subscription ID. We’ll need this ID to deploy your smart contract.
- Visit Remix to deploy your smart contract.
- The following code is written to request a number between 0 and 2 from VRF. We will use the output to determine the opponent’s choice.
// SPDX-License-Identifier: MIT
pragma solidity 0.8.19;
import {VRFConsumerBaseV2Plus} from "@chainlink/contracts@1.2.0/src/v0.8/vrf/dev/VRFConsumerBaseV2Plus.sol";
import {VRFV2PlusClient} from "@chainlink/contracts@1.2.0/src/v0.8/vrf/dev/libraries/VRFV2PlusClient.sol";
contract SimpleRandomNumber is VRFConsumerBaseV2Plus {
event RequestSent(uint256 requestId);
event RandomNumberGenerated(uint256 requestId, uint256 randomNumber);
struct RequestStatus {
bool fulfilled; // Whether the request has been fulfilled
uint256 randomResult; // Random number between 0 and 2
}
mapping(uint256 => RequestStatus) public s_requests; // Mapping requestId to request status
uint256 public s_subscriptionId;
uint256 public lastRequestId;
bytes32 public keyHash =
0xc799bd1e3bd4d1a41cd4968997a4e03dfd2a3c7c04b695881138580163f42887;
uint32 public callbackGasLimit = 100000;
uint16 public requestConfirmations = 3;
uint32 public numWords = 1; // Request one random number
/**
* HARDCODED FOR FUJI AVALANCHE
* COORDINATOR: 0x5C210eF41CD1a72de73bF76eC39637bB0d3d7BEE
*/
constructor(
uint256 subscriptionId
) VRFConsumerBaseV2Plus(0x5C210eF41CD1a72de73bF76eC39637bB0d3d7BEE) {
s_subscriptionId = subscriptionId;
}
/**
* @notice Request a random number using Sepolia ETH as payment.
*/
function requestRandomNumber() external returns (uint256 requestId) {
// Always use Sepolia ETH for payment
requestId = s_vrfCoordinator.requestRandomWords(
VRFV2PlusClient.RandomWordsRequest({
keyHash: keyHash,
subId: s_subscriptionId,
requestConfirmations: requestConfirmations,
callbackGasLimit: callbackGasLimit,
numWords: numWords,
extraArgs: VRFV2PlusClient._argsToBytes(
VRFV2PlusClient.ExtraArgsV1({
nativePayment: true // Always use native token (Sepolia ETH)
})
)
})
);
s_requests[requestId] = RequestStatus({
fulfilled: false,
randomResult: 0 // Initialize to 0 until fulfilled
});
lastRequestId = requestId;
emit RequestSent(requestId);
return requestId;
}
/**
* @notice Callback function called by Chainlink VRF to fulfill the random number request.
* @param _requestId The ID of the randomness request
* @param _randomWords The array of random words generated
*/
function fulfillRandomWords(
uint256 _requestId,
uint256[] calldata _randomWords
) internal override {
require(s_requests[_requestId].fulfilled == false, "Request already fulfilled");
// Compute random number between 0 and 2
uint256 randomResult = _randomWords[0] % 3;
// Update the request status
s_requests[_requestId].fulfilled = true;
s_requests[_requestId].randomResult = randomResult;
emit RandomNumberGenerated(_requestId, randomResult);
}
/**
* @notice Get the status and result of the last random number request.
* @param _requestId The ID of the randomness request
*/
function getRandomNumber(
uint256 _requestId
) external view returns (bool fulfilled, uint256 randomNumber) {
RequestStatus memory request = s_requests[_requestId];
require(request.fulfilled, "Request not yet fulfilled");
return (request.fulfilled, request.randomResult);
}
}
- Paste the code into your remix editor and compile the code.
- Visit the deploy tab, select injected provider for your environment, paste your Chainlink subscription ID into the Deploy field, and press Deploy.
- Copy your contract address from Remix, and add it as a consumer in the Chainlink dashboard.
- Now you are all set to request random numbers from VRF. Click requestRand… to generate a number. If the transaction processes successfully then congrats! You successfully deployed a VRF contract.
Player verification (optional)
When a player interacts with your contract they processes a transaction and the transaction history becomes available in their wallet. Here’s how you can check your history and how players can verify that the game displayed the number returned by VRF.
- In your Metamask wallet, click Activity, click on the most recent activity, and click view on block explorer.
- In the transaction details, click on the second address in the transaction action section.
- Click on the events tab of the contract, review the first transaction, and click on the last hex in the logs. Switch from hex to number, and that will reveal the number VRF returned.
- Share the link to the events page, and Now your players can trust you’re not manipulating your game outcomes.
Verifying your smart contract (optional)
You can share the details of your smart contracts and allow others to read your code by verifying your contract. Visit the contract address on the SnowScan block explorer and paste in your contract address:
TESTNET Avalanche C-Chain (AVAX) Blockchain Explorer
Verifying and publishing your contract establishes trust with your players.
- In the verify and publish menu in the block explorer, choose Solidity single file, your compiler version, and click MIT for your license. Click continue.
- Paste in your smart contract code, scroll to the bottom, and press verify and publish.
- Now you can share the link to the contract with your users, so they can verify that you are not cheating in your game. Let’s work on connecting it to Unity.
Converting solidity calls to C#
- Navigate to Remix, click on the compile button, and copy the contract ABI.
- Navigate to Unity, click on ChainSafe SDK, and then contract ABI to C# converter.
- Name your file vrf, create a folder called scripts, drag and drop the folder into the folder section, and paste in your ABI. Click convert.
- Now you have all the scripts you need to interact with your smart contracts!
Updating your game to use Chainlink
- Add the following namespaces into your project
using ChainSafe.Gaming.UnityPackage;
using ChainSafe.Gaming.UnityPackage.Connection;
using ChainSafe.Gaming.Web3;
using ChainSafe.Gaming.Evm.Contracts.Custom;
using System.Numerics;
- Add the following after your last public declarations.
[SerializeField] private string ContractAddress;
private vrf _vrf;
private bool _randomNumberReady = false;
private BigInteger _randomNumber;
private void Awake()
{
Web3Unity.Web3Initialized += Web3UnityOnWeb3Initialized;
}
private async void Web3UnityOnWeb3Initialized((Web3 web3, bool isLightWeight) obj)
{
// Initialize the VRF contract.
_vrf = await obj.web3.ContractBuilder.Build<vrf>(ContractAddress);
// Subscribe to the random number generated event.
_vrf.OnRandomNumberGenerated += OnRandomNumberGenerated;
}
private void OnDestroy()
{
Web3Unity.Web3Initialized -= Web3UnityOnWeb3Initialized;
// Unsubscribe from the event.
if (_vrf != null)
{
_vrf.OnRandomNumberGenerated -= OnRandomNumberGenerated;
}
}
- Swap out the local number generation with the VRF number generation.
_randomNumberReady = false;
BigInteger requestId = await _vrf.RequestRandomNumber();
Debug.Log($"Random number request sent. Request ID: {requestId}");
// Wait until the random number is ready.
while (!_randomNumberReady)
{
await System.Threading.Tasks.Task.Delay(100); // Wait 100ms between checks.
}
// Use the random number to determine the opponent's choice.
int opponentIndex = (int)(_randomNumber % 3); // Mod by 3 to ensure it's between 0 and 2.
Debug.Log(opponentIndex);
string randomChoice = Choices[opponentIndex];
- Your final script should look like this:
using System.Collections;
using System.Collections.Generic;
using UnityEngine;
using UnityEngine.UI;
using TMPro;
using ChainSafe.Gaming.UnityPackage;
using ChainSafe.Gaming.UnityPackage.Connection;
using ChainSafe.Gaming.Web3;
using ChainSafe.Gaming.Evm.Contracts.Custom;
using System.Numerics;
public class Manager : MonoBehaviour
{
// Text element to display the result of the game.
public TMP_Text Result;
// Image to display the opponent's choice visually.
public Image OpChoice;
// Array to store the choices: Rock, Paper, Scissors.
public string[] Choices;
// Sprites for Rock, Paper, and Scissors to visually represent choices.
public Sprite Rock, Paper, Scissors;
[SerializeField] private string ContractAddress;
private vrf _vrf;
private bool _randomNumberReady = false;
private BigInteger _randomNumber;
private void Awake()
{
Web3Unity.Web3Initialized += Web3UnityOnWeb3Initialized;
}
private async void Web3UnityOnWeb3Initialized((Web3 web3, bool isLightWeight) obj)
{
// Initialize the VRF contract.
_vrf = await obj.web3.ContractBuilder.Build<vrf>(ContractAddress);
// Subscribe to the random number generated event.
_vrf.OnRandomNumberGenerated += OnRandomNumberGenerated;
}
private void OnDestroy()
{
Web3Unity.Web3Initialized -= Web3UnityOnWeb3Initialized;
// Unsubscribe from the event.
if (_vrf != null)
{
_vrf.OnRandomNumberGenerated -= OnRandomNumberGenerated;
}
}
// Called when a player clicks a button corresponding to their choice.
public async void Play(string myChoice)
{
// Request a random number from the VRF contract.
try
{
_randomNumberReady = false;
BigInteger requestId = await _vrf.RequestRandomNumber();
Debug.Log($"Random number request sent. Request ID: {requestId}");
// Wait until the random number is ready.
while (!_randomNumberReady)
{
await System.Threading.Tasks.Task.Delay(100); // Wait 100ms between checks.
}
// Use the random number to determine the opponent's choice.
int opponentIndex = (int)(_randomNumber % 3); // Mod by 3 to ensure it's between 0 and 2.
Debug.Log(opponentIndex);
string randomChoice = Choices[opponentIndex];
// Update the opponent's choice sprite and the result text based on the game logic.
UpdateOpponentChoice(randomChoice);
UpdateResult(myChoice, randomChoice);
}
catch (System.Exception ex)
{
Debug.LogError($"Error requesting random number: {ex.Message}");
}
}
// Updates the opponent's displayed choice.
private void UpdateOpponentChoice(string choice)
{
switch (choice)
{
case "Rock":
OpChoice.sprite = Rock;
break;
case "Paper":
OpChoice.sprite = Paper;
break;
case "Scissors":
OpChoice.sprite = Scissors;
break;
}
}
// Determines and updates the result of the game based on the player's and opponent's choices.
private void UpdateResult(string myChoice, string randomChoice)
{
if (myChoice == randomChoice)
{
Result.text = "It's a Tie!";
}
else if ((myChoice == "Rock" && randomChoice == "Scissors") ||
(myChoice == "Paper" && randomChoice == "Rock") ||
(myChoice == "Scissors" && randomChoice == "Paper"))
{
Result.text = "You Win!";
}
else
{
Result.text = "You Lose!";
}
}
// Event handler for when a random number is generated.
private void OnRandomNumberGenerated(vrf.RandomNumberGeneratedEventDTO eventDTO)
{
Debug.Log($"Random number generated: {eventDTO.RandomNumber}");
_randomNumber = eventDTO.RandomNumber;
_randomNumberReady = true;
}
}
- Now when a user chooses their option, the script will send a request using VRF and the response will be displayed as the opponent’s choice.
- To interact with the contract, paste your contract address into the contract address component in unity.
- To subscribe to events we need to add the event service adapter component to the Web3Unity object. Click on Force Event Polling.
- Congrats! Your players can now trust that they are not being cheated but they need a way to process the transactions, and we’ll need wallets for that.
Get users to connect their wallets
Wallets are the gateways to blockchains so let’s start working on getting users to connect their wallets.
- Click on Web3Unity in the hierarchy, and click on Connection Handler in the Inspector. In the dropdown, click on “Add Provider” under Metamask.
- Delete the SDKCallSamples, and the Scroller.
See the final product
- Run the game and connect your wallet.
- Choose your option and confirm the transaction.
- Open the developer console in your browser to confirm the request was sent to Chainlink.
- Wait 20 - 30 seconds for the transaction to be approved and watch as the opponent chooses their option.
- Visit the contract in the block explorer to confirm it was your transaction.
You can choose to build an interface that actively displays this information to your users, or you can make links to your contracts accessible on a website.
If you made it through this tutorial than congrats! You built your first game that interacts with a blockchain!
About ChainSafe
ChainSafe is a leading blockchain research and development firm specializing in protocol engineering, cross-chain interoperability, and web3 gaming.
Alongside its contributions to major ecosystems such as Ethereum, Polkadot, and Filecoin, ChainSafe creates solutions for developers across the web3 space utilizing expertise in gaming, interoperability, and decentralized storage.
As part of its mission to build innovative products for users and improved tooling for developers, ChainSafe embodies an open-source and community-oriented ethos to advance the future of the internet.
Website | Twitter | Linkedin | GitHub | Discord | YouTube | Newsletter