Odd Chances Game
This is a tutorial on how to create the Odd Chances game with WeaveDB and Next.js.
It is simply a game of chance. A user guesses whether the number produced by the block timestamp is odd or even. Players are limited to 1 guess per block.
Deploy WeaveDB contract
git clone https://github.com/weavedb/weavedb.git
cd weavedb
yarn
node scripts/generate-wallet.js mainnet
yarn deploy
A new wallet is stored at /scripts/.wallets/wallet-mainnet.json
yarn deploy
returns contractTxId
and srcTxId
Configure DB Instance
We will only use one collection game_results
We will show you a single command script to set up everything in the end, but these are what needs to be set up.
Set up Data Schema
const schema_game_results = {
type: "object",
required: ["is_even", "user_address", "date", "last_guess_date", "has_won"],
properties: {
is_even: {
type: "boolean",
},
user_address: {
type: "string",
},
date: {
type: "number",
},
last_guess_date: {
type: "number",
},
has_won: {
type: "boolean",
},
},
};
await db.setSchema(schema_game_results, "game_results")
game_results
collection must have 5 fields (is_even
,user_address
,date
,last_guess_date
,has_won
).
Set up Access Control Rules
const rules_game_results = {
"let create": {
"resource.newData.has_won": [
"equals",
["equals", ["modulo", { var: "resource.newData.date" }, 2], 0],
{ var: "resource.newData.is_even" },
],
},
"allow create": {
and: [
{
"!=": [
{ var: "request.block.timestamp" },
{ var: "resource.newData.last_guess_date" },
],
},
{
"==": [
{ var: "request.auth.signer" },
{ var: "resource.newData.user_address" },
],
},
{
"==": [
{ var: "request.block.timestamp" },
{ var: "resource.newData.date" },
],
},
],
},
};
await db.setRules(schema_game_results, "game_results")
let create
forces the fieldhas_won
to store the result of the defined JsonLogic every time a document is created to thegame_results
collection.- The FPJSON code in
let create
checks if thedate
(block timestamp) is an even number, returning a boolean value that is compared to the player's guess stored in theis_even
boolean field. has_won
field will be set totrue
if both comparisons are equal, else set tofalse
.allow create
defines the rules when a document is created to thegame_results
collection.last_guess_date
must not be the same as theblock.timestamp
user_address
must besigner
of the transaction.date
must be theblock.timestamp
Set up Everything with Script
Run the following script to set up the schema and the rules with a single command.
Replace CONTRACT_TX_ID
with the contractTxId
returned when deploying the WeaveDB contract.
node scripts/oddchances-setup.js mainnet CONTRACT_TX_ID
Now the database setup is all done!
NextJS Frontend Dapp
We are going to build the front end dapp using NextJS
Create NextJS Project
Set up a NextJS project with the app name odd-chances
.
yarn create next-app odd-chances
cd odd-chances
yarn dev
Now your dapp should be running at localhost:3000.
To keep things simple, we'll put everything in one file at /page/index.js
Install Dependencies
Open a new terminal and move to the project root directory.
cd odd-chances
yarn add ramda localforage weavedb-sdk buffer ethers chart.js react-toastify @chakra-ui/react @emotion/react @emotion/styled framer-motion @chakra-ui/icons
We use these minimum dependencies.
- WeaveDB SDK - to connect with WeaveDB
- Buffer - a dependency for WeaveDB
- Ramda.js - functional programming utilities
- Chakra UI - UI library
- Ethers.js - to connect with Metamask
- localForage - IndexedDB wrapper to store a disposal wallet
- Chart.js - JavaScript charting library
Import Dependencies
Open /page/index.js
and replace everything.
import {
Box,
ChakraProvider,
Flex,
Text,
useColorMode,
Stat,
StatLabel,
StatNumber,
} from "@chakra-ui/react"
import { MoonIcon, SunIcon } from "@chakra-ui/icons"
import { isNil, map } from "ramda"
import { useEffect, useRef, useState } from "react"
import { ethers } from "ethers"
import lf from "localforage"
import SDK from "weavedb-sdk"
import { ToastContainer, toast } from "react-toastify"
import "react-toastify/dist/ReactToastify.css"
import { Chart } from "chart.js/auto"
import { nanoid } from "nanoid"
Define Variables
Replace CONTRACT_TX_ID
with the contractTxId
returned when deploying the WeaveDB contract.
let db
const contractTxId = "I-4lx8uOt-tOR6ebAEDw4jw3X4ntokdIBE7l4bXIHnM"
const COLLECTION_NAME = "game_results"
const LAST_GUESS_DATE_DEFAULT = 0
db
- to assign the WeaveDB instance latercontractTxID
- WeaveDB contract tx id
Define React States
export default function Home() {
const [user, setUser] = useState(null)
const [initDB, setInitDB] = useState(false)
const [winCount, setWinCount] = useState(0)
const [lossCount, setLossCount] = useState(0)
const [totalCount, setTotalCount] = useState(-1)
const [lastGuessDate, setLastGuessDate] = useState(LAST_GUESS_DATE_DEFAULT)
const [chart, setChart] = useState(null)
const chartRef = useRef(null)
return (...)
}
user
- logged in userinitDB
- to determine if the WeaveDB instance is ready to usewinCount
- number of wins for the logged-in userlossCount
- number of losses for the logged-in usertotalCount
- total number of guesses made by the logged in userlastGuessDate
- date when the logged-in user last playedchart
- instance of Chart.jschartRef
- reference to Chart.js
Define Functions
setupWeaveDB
const setupWeaveDB = async () => {
db = await new SDK({
contractTxId: contractTxId,
})
await db.init()
setInitDB(true)
}
checkUser
When the page is loaded, check if the user is logged in.
const checkUser = async () => {
const wallet_address = await lf.getItem(`temp_address:current`)
if (!isNil(wallet_address)) {
const identity = await lf.getItem(
`temp_address:${contractTxId}:${wallet_address}`
)
if (!isNil(identity)) {
setUser({
wallet: wallet_address,
privateKey: identity.privateKey,
})
}
}
console.log("<<checkUser()")
}
login
We will generate a disposal account the first time a user logs in, link it with the Metamask address within WeaveDB, and save it locally in the browser's IndexedDB.
{ wallet, privateKey }
is how we need to pass the user object to the SDK when making transactions, so we will save it like so.
const login = async () => {
console.log(">>login()")
const provider = new ethers.BrowserProvider(window.ethereum, "any")
const signer = await provider.getSigner()
await provider.send("eth_requestAccounts", [])
const wallet_address = await signer.getAddress()
let identity = await lf.getItem(
`temp_address:${contractTxId}:${wallet_address}`
)
let tx
let err
if (isNil(identity)) {
;({ tx, identity, err } = await db.createTempAddress(wallet_address))
const linked = await db.getAddressLink(identity.address)
if (isNil(linked)) {
alert("something went wrong")
return
}
} else {
await lf.setItem("temp_address:current", wallet_address)
setUser({
wallet: wallet_address,
privateKey: identity.privateKey,
})
return
}
if (!isNil(tx) && isNil(tx.err)) {
identity.tx = tx
identity.linked_address = wallet_address
await lf.setItem("temp_address:current", wallet_address)
await lf.setItem(
`temp_address:${contractTxId}:${wallet_address}`,
JSON.parse(JSON.stringify(identity))
)
setUser({
wallet: wallet_address,
privateKey: identity.privateKey,
})
}
console.log("<<login()")
}
logout
We will simply remove the current logged in state. The disposal address will be reused the next time the user logs in.
const logout = async () => {
if (confirm("Would you like to sign out?")) {
await lf.removeItem("temp_address:current")
setUser(null, "temp_current")
}
console.log("<<logout()")
}
addGuess
const onClickOdd = async () => {
addGuess(false)
}
const onClickEven = async () => {
addGuess(true)
}
const addGuess = async (isEven) => {
const docId = nanoid()
console.log("docId", docId)
console.log("addGuess() : isEven", isEven)
console.log("addGuess() : lastGuessDate", lastGuessDate)
try {
const tx = await db.set(
{
is_even: isEven,
date: db.ts(),
user_address: db.signer(),
last_guess_date: lastGuessDate,
},
COLLECTION_NAME,
docId,
user
)
console.log("tx", tx)
const newDocument = await db.get(COLLECTION_NAME, docId)
console.log("addGuess newDocument", newDocument)
if (isNil(newDocument)) {
const txResult = await db.getResult()
console.log("addGuess txResult", txResult)
toast(txResult.error)
} else {
const str = isEven ? "EVEN" : "ODD"
newDocument.has_won
? toast("You won! Your guess is " + str + "==" + newDocument.date)
: toast("You lost! Your guess is " + str + "==" + newDocument.date)
}
getMyGameResults()
} catch (e) {
toast(e.message)
console.log(e)
}
}
getMyGameResults
const getMyGameResults = async () => {
try {
if (!isNil(user)) {
const result = await db.cget(
COLLECTION_NAME,
["user_address", "==", user.wallet.toLowerCase()],
["date", "desc"]
)
console.log("getMyGameResults result", result)
setLastGuessDate(result[0]?.data?.date ?? LAST_GUESS_DATE_DEFAULT)
let winCount = 0
let lossCount = 0
map((v) => {
v.data.has_won ? winCount++ : lossCount++
})(result)
setWinCount(winCount)
setLossCount(lossCount)
setTotalCount(winCount + lossCount)
}
} catch (e) {
toast(e.message)
console.log(e)
}
console.log("<<getMyGameResults()")
setupPieChart()
}
setupPieChart
const chart_data = {
labels: ["Win", "Loss"],
datasets: [
{
data: [winCount, lossCount],
backgroundColor: ["#0080ff", "#ff0080"],
},
],
}
const chart_config = {
type: "doughnut",
data: chart_data,
}
const setupPieChart = async () => {
if (!isNil(chart)) {
chart.destroy()
}
const context = chartRef.current
const chartInstance = new Chart(context, chart_config)
setChart(chartInstance)
console.log("<<setupPieChart()")
}
Define React Components
NavBar
const NavBar = () => {
const { colorMode, toggleColorMode } = useColorMode()
return (
<Flex p={3} position="fixed" top={0} w="100%" bg="#7928ca" color="white">
<Box flex={1} />
<Box
py={2}
mx={8}
cursor="pointer"
_hover={{ opacity: 0.75 }}
onClick={() => toggleColorMode()}
>
{colorMode === "light" ? <MoonIcon /> : <SunIcon />}
</Box>
<Box
bg="#ff0080"
py={2}
px={6}
borderRadius="lg"
cursor="pointer"
_hover={{ opacity: 0.75 }}
>
{!isNil(user) ? (
<Box onClick={() => logout()}>{user.wallet.slice(0, 8)}</Box>
) : (
<Box onClick={() => login()}>Connect Wallet</Box>
)}
</Box>
</Flex>
)
}
AnswerButton
const AnswerButton = (props) => {
const { btnText, btnClick } = props
return (
<Box
borderRadius="lg"
p={4}
background="linear-gradient(to bottom, #ff0080, #7928ca)"
minW="200px"
cursor="pointer"
_hover={{ opacity: 0.75 }}
onClick={btnClick}
>
<Text
fontSize="5xl"
fontWeight="bold"
textAlign="center"
color="white"
fontFamily="monospace"
>
{btnText}
</Text>
</Box>
)
}
const AnswerButtons = () => {
return (
<Flex justifyContent="space-between" my={8}>
<AnswerButton btnText="ODD" btnClick={onClickOdd} />
<AnswerButton btnText="EVEN" btnClick={onClickEven} />
</Flex>
)
}
GameResultStats
const GameResultStats = () => {
return (
<>
<Flex borderWidth="1px" borderRadius="lg" p={3} mb={8}>
<Stat>
<StatLabel>Win</StatLabel>
<StatNumber>{winCount}</StatNumber>
</Stat>
<Stat>
<StatLabel>Loss</StatLabel>
<StatNumber>{lossCount}</StatNumber>
</Stat>
</Flex>
</>
)
}
Transactions
const Transactions = () => {
return (
<Flex justify="center" my={18}>
<Box
as="a"
target="_blank"
href={`https://sonar.warp.cc/?#/app/contract/${contractTxId}`}
textDecoration="underline"
>
view transactions
</Box>
</Flex>
)
}
Define Reactive State Changes
useEffect(() => {
checkUser()
setupWeaveDB()
}, [])
useEffect(() => {
console.log("useEffect() initDB", initDB)
if (initDB) {
if (!isNil(user)) getMyGameResults()
}
}, [initDB, totalCount, winCount, lossCount, user])
- When the page is loaded, check if the user is logged in and set up WeaveDB.
- Get user game results, when a user is logged in.
Return Components
return (
<>
<ToastContainer />
<ChakraProvider>
<NavBar />
<Flex mt="60px" justify="center" p={3}>
<Box w="100%" maxW="600px">
<AnswerButtons />
{!isNil(user) ? (
<>
<GameResultStats /> <canvas ref={chartRef} />
</>
) : null}
</Box>
</Flex>
<Transactions />
</ChakraProvider>
</>
)
The Complete Code
import {
Box,
ChakraProvider,
Flex,
Text,
useColorMode,
Stat,
StatLabel,
StatNumber,
} from "@chakra-ui/react"
import { MoonIcon, SunIcon } from "@chakra-ui/icons"
import { isNil, map } from "ramda"
import { useEffect, useRef, useState } from "react"
import { ethers } from "ethers"
import lf from "localforage"
import SDK from "weavedb-sdk"
import { ToastContainer, toast } from "react-toastify"
import "react-toastify/dist/ReactToastify.css"
import { Chart } from "chart.js/auto"
import { nanoid } from "nanoid"
let db
const contractTxId = "I-4lx8uOt-tOR6ebAEDw4jw3X4ntokdIBE7l4bXIHnM"
const COLLECTION_NAME = "game_results"
const LAST_GUESS_DATE_DEFAULT = 0
export default function Home() {
const [user, setUser] = useState(null)
const [initDB, setInitDB] = useState(false)
const [winCount, setWinCount] = useState(0)
const [lossCount, setLossCount] = useState(0)
const [totalCount, setTotalCount] = useState(-1)
const [lastGuessDate, setLastGuessDate] = useState(LAST_GUESS_DATE_DEFAULT)
const [chart, setChart] = useState(null)
const chartRef = useRef(null)
const setupWeaveDB = async () => {
db = await new SDK({
contractTxId: contractTxId,
})
await db.init()
setInitDB(true)
}
const checkUser = async () => {
const wallet_address = await lf.getItem(`temp_address:current`)
if (!isNil(wallet_address)) {
const identity = await lf.getItem(
`temp_address:${contractTxId}:${wallet_address}`
)
if (!isNil(identity)) {
setUser({
wallet: wallet_address,
privateKey: identity.privateKey,
})
}
}
console.log("<<checkUser()")
}
const login = async () => {
console.log(">>login()")
const provider = new ethers.BrowserProvider(window.ethereum, "any")
const signer = await provider.getSigner()
await provider.send("eth_requestAccounts", [])
const wallet_address = await signer.getAddress()
let identity = await lf.getItem(
`temp_address:${contractTxId}:${wallet_address}`
)
let tx
let err
if (isNil(identity)) {
;({ tx, identity, err } = await db.createTempAddress(wallet_address))
const linked = await db.getAddressLink(identity.address)
if (isNil(linked)) {
alert("something went wrong")
return
}
} else {
await lf.setItem("temp_address:current", wallet_address)
setUser({
wallet: wallet_address,
privateKey: identity.privateKey,
})
return
}
if (!isNil(tx) && isNil(tx.err)) {
identity.tx = tx
identity.linked_address = wallet_address
await lf.setItem("temp_address:current", wallet_address)
await lf.setItem(
`temp_address:${contractTxId}:${wallet_address}`,
JSON.parse(JSON.stringify(identity))
)
setUser({
wallet: wallet_address,
privateKey: identity.privateKey,
})
}
console.log("<<login()")
}
const logout = async () => {
if (confirm("Would you like to sign out?")) {
await lf.removeItem("temp_address:current")
setUser(null, "temp_current")
}
console.log("<<logout()")
}
const onClickOdd = async () => {
addGuess(false)
}
const onClickEven = async () => {
addGuess(true)
}
const addGuess = async (isEven) => {
const docId = nanoid()
console.log("docId", docId)
console.log("addGuess() : isEven", isEven)
console.log("addGuess() : lastGuessDate", lastGuessDate)
try {
const tx = await db.set(
{
is_even: isEven,
date: db.ts(),
user_address: db.signer(),
last_guess_date: lastGuessDate,
},
COLLECTION_NAME,
docId,
user
)
console.log("tx", tx)
const newDocument = await db.get(COLLECTION_NAME, docId)
console.log("addGuess newDocument", newDocument)
if (isNil(newDocument)) {
const txResult = await db.getResult()
console.log("addGuess txResult", txResult)
toast(txResult.error)
} else {
const str = isEven ? "EVEN" : "ODD"
newDocument.has_won
? toast("You won! Your guess is " + str + "==" + newDocument.date)
: toast("You lost! Your guess is " + str + "==" + newDocument.date)
}
getMyGameResults()
} catch (e) {
toast(e.message)
console.log(e)
}
}
const getMyGameResults = async () => {
try {
if (!isNil(user)) {
const result = await db.cget(
COLLECTION_NAME,
["user_address", "==", user.wallet.toLowerCase()],
["date", "desc"]
)
console.log("getMyGameResults result", result)
setLastGuessDate(result[0]?.data?.date ?? LAST_GUESS_DATE_DEFAULT)
let winCount = 0
let lossCount = 0
map((v) => {
v.data.has_won ? winCount++ : lossCount++
})(result)
setWinCount(winCount)
setLossCount(lossCount)
setTotalCount(winCount + lossCount)
}
} catch (e) {
toast(e.message)
console.log(e)
}
console.log("<<getMyGameResults()")
setupPieChart()
}
const chart_data = {
labels: ["Win", "Loss"],
datasets: [
{
data: [winCount, lossCount],
backgroundColor: ["#0080ff", "#ff0080"],
},
],
}
const chart_config = {
type: "doughnut",
data: chart_data,
}
const setupPieChart = async () => {
if (!isNil(chart)) {
chart.destroy()
}
const context = chartRef.current
const chartInstance = new Chart(context, chart_config)
setChart(chartInstance)
console.log("<<setupPieChart()")
}
const NavBar = () => {
const { colorMode, toggleColorMode } = useColorMode()
return (
<Flex p={3} position="fixed" top={0} w="100%" bg="#7928ca" color="white">
<Box flex={1} />
<Box
py={2}
mx={8}
cursor="pointer"
_hover={{ opacity: 0.75 }}
onClick={() => toggleColorMode()}
>
{colorMode === "light" ? <MoonIcon /> : <SunIcon />}
</Box>
<Box
bg="#ff0080"
py={2}
px={6}
borderRadius="lg"
cursor="pointer"
_hover={{ opacity: 0.75 }}
>
{!isNil(user) ? (
<Box onClick={() => logout()}>{user.wallet.slice(0, 8)}</Box>
) : (
<Box onClick={() => login()}>Connect Wallet</Box>
)}
</Box>
</Flex>
)
}
const AnswerButton = (props) => {
const { btnText, btnClick } = props
return (
<Box
borderRadius="lg"
p={4}
background="linear-gradient(to bottom, #ff0080, #7928ca)"
minW="200px"
cursor="pointer"
_hover={{ opacity: 0.75 }}
onClick={btnClick}
>
<Text
fontSize="5xl"
fontWeight="bold"
textAlign="center"
color="white"
fontFamily="monospace"
>
{btnText}
</Text>
</Box>
)
}
const AnswerButtons = () => {
return (
<Flex justifyContent="space-between" my={8}>
<AnswerButton btnText="ODD" btnClick={onClickOdd} />
<AnswerButton btnText="EVEN" btnClick={onClickEven} />
</Flex>
)
}
const GameResultStats = () => {
return (
<>
<Flex borderWidth="1px" borderRadius="lg" p={3} mb={8}>
<Stat>
<StatLabel>Win</StatLabel>
<StatNumber>{winCount}</StatNumber>
</Stat>
<Stat>
<StatLabel>Loss</StatLabel>
<StatNumber>{lossCount}</StatNumber>
</Stat>
</Flex>
</>
)
}
const Transactions = () => {
return (
<Flex justify="center" my={18}>
<Box
as="a"
target="_blank"
href={`https://sonar.warp.cc/?#/app/contract/${contractTxId}`}
textDecoration="underline"
>
view transactions
</Box>
</Flex>
)
}
useEffect(() => {
checkUser()
setupWeaveDB()
}, [])
useEffect(() => {
console.log("useEffect() initDB", initDB)
if (initDB) {
if (!isNil(user)) getMyGameResults()
}
}, [initDB, totalCount, winCount, lossCount, user])
return (
<>
<ToastContainer />
<ChakraProvider>
<NavBar />
<Flex mt="60px" justify="center" p={3}>
<Box w="100%" maxW="600px">
<AnswerButtons />
{!isNil(user) ? (
<>
<GameResultStats /> <canvas ref={chartRef} />
</>
) : null}
</Box>
</Flex>
<Transactions />
</ChakraProvider>
</>
)
}