Getting Started with Web3: build a simple game with scaffold-eth


I became more interested in Web3 after realizing its potential to revolutionize the world. I wanted to go beyond a simple “hello world”, so I’m documenting my adventure into Web3 as an engineer and founder. I will share my experiences, observations, and lessons learned through this documentation, and I want to encourage other engineers, entrepreneurs, and fans to explore Web3’s potential.

Here we will explore some basics of blockchain and a developer’s framework called scaffold-eth. We will attempt to build a simple game involving a Solidity contract with React frontend, which is included in scaffold-eth.

What’s in this article:

Immutability and Cost

Traditional programming and Web3 programming don’t differ much, except that you’d need to understand a few key concepts: immutability and cost. Immutability is not a difficult concept to understand, but you’d need to be careful when rolling out the app – there is no going back once it’s out; every record made on the chain stays there forever (in most cases). This immutability connects to the second point: Cost.

Computers have become so efficient that local consumption of electricity is negligible. However, this is not the case for blockchains, as this computation propagates to the immutable ledger and the cost of making an entry into this permanence is somewhat costly.

To some extent, this immutability and cost remind me of programming in the 80s on instruction cards, or maybe GameBoys, where you’d need to get creative to make your code efficiently performant and small.

The Stack

Picking your stack is not easy. Different blockchains, different tooling – the entire thing is worse than javascript fatigue, but fret not. Let’s start with Ethereum since it’s one of the first programmable blockchains, and many have evolved from it. A simple visit to Ethereum Developer’s page will give you an idea of what tooling is available to you.

The biggest hurdle to clear is understanding what goes into the stack.

  • Frontend - what the user sees and what they usually interact with
  • Backend server - where you are hosting your frontend app
  • Solidity Backend - your smart contracts
  • Blockchain - the chain where you would deploy your contracts to.
  • Blockchain proxy - how your wallets communicate with the chain

As you can see as a newcomer, you can have extreme difficulty setting up your development stack just to even get started. Forget about deploying to production by yourself.

scaffold-eth

scaffold-eth has been kind to me. Once you clone this repository, a set of tools will facilitate you from the development of your app to deploying it to production. It’s very well put together by @austintgriffith, who is also on the Ethereum Foundation. I would recommend anyone who is getting into Solidity development to try this tool and master the basics, then go further to find their flavor of tooling choices.

Guided Learning

There are several guided tutorials and challenges available online. The easiest has been Speedrun Ethereum. It takes you through a set of challenges, but most importantly, it lets you create a public web3 portfolio, which you would be able to showcase later on. It has a set of puzzles that you have to figure out, rather than mindlessly copy-pasting answers from the tutorial, which is quite challenging if you’re not a programmer. You do have to figure out the solutions yourself.

If you’re more of a zombie learner, perhaps it’s worth checking out Crypto Zombie, but I’ve had my share of zombies in my life. This tutorial teaches the mechanics of Solidity, without getting into details about setting up your local stack and deploying to the blockchain. (this might have changed)

Let’s dive into scaffold-eth and create a game.

King’s Pot, The Game

The initial steps of Speedrun Ethereum helped me get started with the setup of the stack, but now I’ve decided to venture off and test out the concepts in real life.

We will be building an easy gambling game. The rules are simple: you have a pot, into which participants would throw their money. A counter counts down to a set time limit, after which, the last person to put the money into the pot can claim the winnings. The catch is that every time someone puts the money into the pot, the timer gets extended and gives more chances for others to become the winner.

Getting Started

Let’s get the simple things out of the way. We need to clone scaffold-eth and get the services running for our development.

git clone https://github.com/scaffold-eth/scaffold-eth.git kings-pot
cd kings-pot
yarn install
yarn chain # starts the local blockchain for development

# then open a new window and start the web server
yarn start

The Contract

scaffold-eth gives you a very nice interface to play around with your contract once it’s deployed, so let’s work on the contract before diving into the front end. The crux is to get the contract working without any bugs.

The scaffolding comes with a default contract in packages/hardhat/contracts/YourContract.sol, so we can reuse that or create our own. I’ve decided to create my own contract called TimedLottery, so I’ve replaced all instances of YourContract with TimedLottery in the project.

Let’s insert a blank contract:

pragma solidity ^0.8.0;
//SPDX-License-Identifier: MIT

contract TimedLottery {

  constructor() {}

  receive() external payable {}
  fallback() external payable {}
}

Constants

Let’s add all the necessary constants and variables based on our requirements. Your contract should look something like this:

pragma solidity ^0.8.0;
//SPDX-License-Identifier: MIT

contract TimedLottery {
  address payable public lastDonor; // the last person to put into the pot; becomes the winner of the pot
  uint256 public potSize; // track the size of the pot
  uint256 public timer; // track the timestamp when timer runs out
  uint256 public constant timerReset = (24 hours) * 6 * 30; // initial time when the timer reset => timer = now() + timerReset
  uint256 public constant timerIncrement = 15 minutes; // how much time to add when someone transfers eth into the pot

  constructor() {}

  receive() external payable {}
  fallback() external payable {}
}

Let’s break this down further:

  • lastDonor: We will use this to track the last sender of eth to the contract. We don’t need to track all the transfers since we only need one address to determine the winner.
  • potSize: uint256 type is BigNumber type which will track the size of the winnings. This is an unsigned type, so we can only have zero or positives.
  • timer: we will set the default time for this further, but essentially this variable stores the timestamp representing the future block time, which will be used to track when the lottery ends.
  • timerReset and timerIncrement are both used to store fixed seconds which will be used for contract manipulation so we don’t hardcode anything inside the functions.

Deciding when the lottery ends

Let’s set the default values in the constructor. This method is called when the contract is deployed by you. The only variable that we need to set is timer to specify when the lottery expires.

  constructor() {
    timer = block.timestamp + timerReset;
  }

Here we get the current block’s timestamp and append our pre-determined expiration time. (6 months). You can change this length to something shorter for development.

Receiving funds

Now that we are tracking when the lottery ends, let’s create a function so that our contract can receive funds. Let’s create a fund function, which will handle all the logic for receiving the funds.

Here, we need to do the following:

  1. Store the address where the funds are coming from in the lastDonor
  2. Increment potSize with the amount being sent.
  3. Increment the timer.

We need to make sure to specify external and payable for this function, which will allow for this to be run from the outside and attach a value to it.

  function fund() external payable {
    lastDonor = payable(msg.sender);
    potSize += msg.value;
    timer += timerIncrement;
  }

The above looks good, but there are a few edge cases. What if the sender forgets to attach an amount. It’s possible to run this function without attaching any value and become the winner of the pot. To counter this, let’s add some validations:

  require(msg.value > 0, "funding amount must be greater than 0.");

Additionally, let’s make sure we stop accepting funds once the lottery is over:

  require(block.timestamp < timer, "Funding window is closed.");

This essentially checks that the current timestamp of the block is smaller than the timer that we’ve set. Your fund function will look something like this:

  function fund() external payable {
    require(msg.value > 0, "funding amount must be greater than 0.");
    require(block.timestamp < timer, "Funding window is closed.");

    lastDonor = payable(msg.sender);
    potSize += msg.value;
    timer += timerIncrement;
  }

Claiming the Winnings

Now that we can receive funds into the contract and track the last sender, let’s see how we claim the funds. Let’s create a claimPot function, which can be called when the timer runs out. Similar to fund, we need to make this function external so outsiders can run it.

In this function we need to do the following:

  1. Make sure only the winner is calling it.
  2. Transfer the amount in the pot to the winner.
  3. Clear the pot and set it to 0.
  4. Reset the timer to restart the game.

The function will look something like this:

  function claimPot() external {
    require(block.timestamp > timer, "Claim window is closed.");
    require(potSize > 0, "Pot is empty.");
    require(msg.sender == lastDonor, "Only the winner can claim the potSize.");

    uint256 amount = potSize;
    potSize = 0;
    lastDonor.transfer(amount);

    // reset timer
    timer = block.timestamp + timerReset;
}

That’s it! The ready contract should look something like this:

pragma solidity ^0.8.0;
//SPDX-License-Identifier: MIT

contract TimedLottery {
  address payable public lastDonor; // the last person to put into the pot; becomes the winner of the pot
  uint256 public potSize; // track the size of the pot
  uint256 public timer; // track the timestamp when timer runs out
  uint256 public constant timerReset = (24 hours) * 6 * 30; // initial time when the timer reset => timer = now() + timerReset
  uint256 public constant timerIncrement = 15 minutes; // how much time to add when someone transfers eth into the pot

  constructor() {
    timer = block.timestamp + timerReset;
  }

  function fund() external payable {
    require(msg.value > 0, "funding amount must be greater than 0.");
    require(block.timestamp < timer, "Funding window is closed.");

    lastDonor = payable(msg.sender);
    potSize += msg.value;
    timer += timerIncrement;
  }

  function claimPot() external {
    require(block.timestamp > timer, "Claim window is closed.");
    require(potSize > 0, "Pot is empty.");
    require(msg.sender == lastDonor, "Only the winner can claim the potSize.");

    uint256 amount = potSize;
    potSize = 0;
    lastDonor.transfer(amount);

    // reset timer
    timer = block.timestamp + timerReset;
  }

  receive() external payable {}
  fallback() external payable {}
}

Deploy the contract

Let’s deploy the contract with yarn deploy

Once deployed, we can open up the interface, go to debug tab (http://localhost:3000/debug) and play around with the contract. Make sure to fund your frontend wallet with a local faucet, and try to transfer some funds and verify that potSize grows. Once the timer has run out, you can run the claimPot function. timerReset is set to 6 months, and we don’t have that kind of luxury, so make sure to change that to something reasonable like 3 minutes.

Making it reusable

Hold your horses! or Hold your Mario karts! What happens when the last person doesn’t claim booty? We need to make sure the game restarts. Why don’t we make it so the loot rolls over to the next game, and we keep some for ourselves?

Let’s add a rolloverPot function and update our fund function. Here, we take 20% off the leftover loot, and take it for ourselves haha!! cha-ching!

Make sure to track the address of the wallet that deployed the contract and extract the percentage of your cut into a variable.

rolloverPot function:

  uint256 public constant ownerPercentage = 20;

  constructor() {
    owner = payable(msg.sender);
    timer = block.timestamp + timerReset;
  }\

  function rolloverPot() internal {
    uint256 ownerAmount = (potSize * ownerPercentage) / 100;
    uint256 rolloverAmount = potSize - ownerAmount;
    owner.transfer(ownerAmount);
    potSize = rolloverAmount;
    timer = block.timestamp + timerReset;
  }

fund:

  function fund() external payable {
    require(!(block.timestamp > timer && block.timestamp < timer + claimPeriod), "Funding is closed during claim period");
    require(msg.value > 0, "funding amount must be greater than 0.");

    // rollover
    if (potSize > 0 && block.timestamp > timer + claimPeriod) { rolloverPot(); }

    lastDonor = payable(msg.sender);
    potSize += msg.value;

    if (timer < block.timestamp) {
        // restart the game
        timer = block.timestamp + timerReset;
    } else {
        // regular increment
        timer += timerIncrement;
    }
  }

Frontend

We’ve verified that our contract is working, let’s add some custom frontend.

scaffold-eth provides us with a template that we can reuse, so let’s repurpose packages/react-app/src/views/Home.jsx and remove any boilerplate code. Should look something like this:

import React from "react";

function Home() {

  return (
    <div>King's Pot</div>
  );
}

export default Home;

Display Pot Size

We need to read the contract values to display how much ETH has been transferred into the contract. This can be done by using useContractReader from eth-hooks, which is part of the scaffold-eth framework.

Use import { useContractReader } from "eth-hooks"; and make sure to pass in readContracts into this view in packages/react-app/src/App.jsx. We can read the potSize from our contract using const potSize = useContractReader(readContracts, "TimedLottery", "potSize");.

However, our contract does not return an integer, but rather a BigNumber. They are used here to prevent the values from overflowing in javascript. We don’t have much use for this in BigNumber form, so we’d need to convert this to something more meaningful, and ethers has a utility function to format BigNumber into Ethereum values: const potSize = !potSizeBigNumber ? 0 : ethers.utils.formatEther(potSizeBigNumber);.

App.jsx should have something like the following:

  <Route exact path="/">
    <Home readContracts={readContracts} />
  </Route>

and Home.jsx should be:

import React from "react";
import { ethers } from "ethers";
import { useContractReader } from "eth-hooks";

function Home({ readContracts }) {
  const potSizeBigNumber = useContractReader(readContracts, "TimedLottery", "potSize");
  const potSize = !potSizeBigNumber ? 0 : ethers.utils.formatEther(potSizeBigNumber);

  return (
    <div>King's Pot: {potSize} ETH</div>
  );
}

export default Home;

Funding the pot

Let’s figure out a way to call the fund function from our front end. Similar to useContractReader, there is a writeContracts, which provides us access to executing Solidity functions from the web. Also, we need to make sure to use tx helper, which will provide meaningful user feedback based on the result of the transaction.

Pass in tx and writeContracts to our Home in App.jsx

  <Route exact path="/">
    <Home
      readContracts={readContracts}
      tx={tx}
      writeContracts={writeContracts}
    />
  </Route>

Now we can update our Home.jsx. We would need to add 2 components to our UI – an input field for ETH, and a button that will call the fund function. We can use EtherInput from our component library, and Button from antd library.

When a value is entered into EtherInput, we use React’s useState helper to update the state for fundAmount.

When the button is pressed, we get our contract via writeContracts and call the fund function with values set to fundAmount. Then we wrap everything in a tx (Transactor) and attach an update callback to handle notifications after the function is executed.

There are a lot of things that are going on under the hood of this template, and I encourage you to trace the code and see how scaffold-eth is built. Your Home.jsx should look something like this:

import React, { useState } from "react";
import { ethers } from "ethers";
import { useContractReader } from "eth-hooks";
import { Button } from "antd";
import { EtherInput } from "../components";

function Home({ readContracts }) {
  const potSizeBigNumber = useContractReader(readContracts, "TimedLottery", "potSize");
  const potSize = !potSizeBigNumber ? 0 : ethers.utils.formatEther(potSizeBigNumber);

  const [fundAmount, setFundAmount] = useState(ethers.utils.parseEther("0"));

  return (
    <div>
      <div>King's Pot: {potSize} ETH</div>
      <EtherInput
        onChange={amt => {
          try {
            const newValue = amt.startsWith(".") ? `0${amt}` : amt;
            setFundAmount(ethers.utils.parseEther("" + newValue));
          } catch (e) {
            console.error(e);
            setFundAmount(ethers.utils.parseEther("0"));
          }
        }}
      />
      <Button
        onClick={async () => {
          /* notice how you pass a call back for tx updates too */
          const result = tx(writeContracts.TimedLottery.fund({ value: fundAmount }), update => {
            console.log("📡 Transaction Update:", update);
            if (update && (update.status === "confirmed" || update.status === 1)) {
              console.log(" 🍾 Transaction " + update.hash + " finished!");
              console.log(
                `⛽️ ${update.gasUsed}/${update.gasLimit || update.gas} @ ${
                  parseFloat(update.gasPrice) / 1000000000
                } gwei`,
              );
            }
          });
          console.log("awaiting metamask/web3 confirm result...", result);
          console.log(await result);
        }}
      >
        Add to Pot!
      </Button>
    </div>
  );
}

export default Home;

When you fund the pot, verify that your ETH balance goes up.

Displaying the Countdown Timer

Let’s try to indicate when the timer will run out, so other players know if they still have a shot at becoming a winner.

Utilizing the same method, by reading contract values with useContractReader, let’s read our timer timestamp:

const timer = useContractReader(readContracts, "TimedLottery", "timer");

We can subtract the timestamp from our current timestamp to get the number of seconds left until the timer expires.

  const now = new Date();
  const date = new Date(timer.toNumber() * 1000);
  const secs = (now.getTime() - date.getTime()) / 1000;

And we can display the time using something like this:

<span>{secs} seconds left!</span>

Refreshing the timer

You’ll notice that your seconds only get updated every so often. This is because React’s state is not being refreshed since nothing is actually being triggered. Let’s rewrite this using useEffect and use setInterval to refresh our clock. For this to be performant, let’s extract it into its own component, name it PotTimer and put it in the components folder. Don’t forget to add an import to components/index.js.

In this component, we want to do the following:

  • Read the timer value of TimedLottery and store it in a state variable
  • Get the difference between the timer timestamp and the current time
  • Format the seconds into a readable HH:MM:SS format
  • Wrap and refresh this function every 1 second to render up-to-date values

Should look something like this:

import { useContractReader } from "eth-hooks";
import React, { useCallback, useState, useEffect } from "react";
import { POT } from "../constants";

export default function PotTimer({ readContracts }) {
  const [secsLapsed, setSecsLapsed] = useState(-999);
  const timer = useContractReader(readContracts, "TimedLottery", "timer");
  const claimPeriod = 5 * 60;

  function secsToHMS(seconds) {
    const hours = Math.floor(seconds / 3600);
    const minutes = Math.floor((seconds % 3600) / 60);
    const remainingSeconds = Math.floor(seconds % 60);

    const pad = num => (num < 10 ? "0" + num : num);

    return `${pad(hours)}:${pad(minutes)}:${pad(remainingSeconds)}`;
  }

  const refreshClock = useCallback(() => {
    if (timer) {
      const nowDate = new Date();
      const date = new Date(timer.toNumber() * 1000);
      const secs = (nowDate.getTime() - date.getTime()) / 1000;
      setSecsLapsed(secs);
    }
  }, [timer]);

  useEffect(() => {
    const timerId = setInterval(refreshClock, 1000);
    return function cleanup() {
      clearInterval(timerId);
    };
  }, [refreshClock]);

  if (secsLapsed < 0) {
    return <span>{secsToHMS(secsLapsed * -1)} seconds left!</span>;
  }

  if (secsLapsed - claimPeriod < 0) {
    return <span>{secsToHMS((secsLapsed - POT.claimPeriod) * -1)} seconds left to claim!</span>;
  }

  return <div>You can start your journey</div>;
}

Now we can use this component in our Home.jsx like so:

<PotTimer readContracts={readContracts} />

Testing

We can test this game by opening two windows pointed to http://localhost:3000/. Make sure they are using different wallets. You can do this by opening one in incognito mode.

You can check out a working version here running on Sepolia testnet. The source code for the above is available here: https://github.com/yurikoval/kings-pot/tree/v1.0.0.