Demo - Rock, Paper & Scissors Game
The Rock, Paper, Scissors (RPS) Game is a HOPR app which can use the HOPR network as a p2p gaming platform. Players can send off-chain moves to a HOPR node which acts as a "referee" using fixed logic (i.e., rock beats scissors, scissors beat paper, and paper beats rock) to produce a winner. After each node has sent its move, the referee displays the winner of the game and the moves made by each player.
In this demo app, we will also show the pros and cons of the HOPR protocol when sending private messages.
Requirements
- a HOPR cluster, and at least a
3
HOPR nodes with their respectiveHTTP_ENDPOINT
,WS_ENDPOINT
andAPI_TOKEN
Tip
If you need instructions on how to run a HOPR cluster, see our "HOPR Cluster Development Setup" section for guidance.
How it works
The game requires at least 3
HOPR nodes within a HOPR cluster. Each node has two possible roles: Player
and Referee
. A Player
plays a game by sending its move (i.e., "rock", "paper", or "scissors") to a Referee
. When 2
Players
have sent a move to a single
Referee
, then the Referee
will send a message back to each Player
telling them whether they have won, lost, or tied.
Move Phase
move: rock
┌───────────────────┐
│ ▼
┌──────┴─────┐ ┌───────────┐ ┌────────────┐
│ │ │ │ │ │
│ Player 1 │ │ Referee │ │ Player 2 │
│ │ │ │ │ │
└────────────┘ └───────────┘ └─────┬──────┘
▲ │
└───────────────────┘
move: paper
result: lost Decision Phase
┌──────────────────┐
▼ │
┌────────────┐ ┌────┴──────┐ ┌────────────┐
│ │ │ │ │ │
│ Player 1 │ │ Referee │ │ Player 2 │
│ │ │ │ │ │
└────────────┘ └─────┬─────┘ └────────────┘
│ ▲
└───────────────────┘
result: won
Source code
Unlike in our "Demo - Boomerang Chat", we'll only go over the most important parts particular to this demo. We recommend reviewing that example first to get a better overview of the relevant components.
Connector
As required by most HOPR Applications, we use a <Connector>
component that handles the input of the HTTP_ENDPOINT
, WS_ENDPOINT
,
and SECURITY_TOKEN
values, required to connect to our HOPR nodes.
import React from 'react'
export default function Connector({
httpEndpoint,
setHTTPEndpoint,
securityToken,
setSecurityToken,
wsEndpoint,
setWsEndpoint
}) {
return (
<>
<div>
<label>WS Endpoint</label>{' '}
<input
name="wsEndpoint"
placeholder={wsEndpoint}
value={wsEndpoint}
onChange={(e) => setWsEndpoint(e.target.value)}
/>
</div>
<div>
<label>HTTP Endpoint</label>{' '}
<input
name="httpEndpoint"
placeholder={httpEndpoint}
value={httpEndpoint}
onChange={(e) => setHTTPEndpoint(e.target.value)}
/>
</div>
<div>
<label>Security Token</label>{' '}
<input
name="securityToken"
placeholder={securityToken}
value={securityToken}
onChange={(e) => setSecurityToken(e.target.value)}
/>
</div>
</>
)
}
WebSocketHandler
Similarly, a HOPR app needs to initialize a WebSocket client and reload upon changes to the WS_ENDPOINT
value. Underneath
we also use a useWebSocket
hook which does the actual binding against the socket via addEventListener
.
import React, { useEffect, useState } from 'react'
import useWebsocket from '../hooks/useWebSockets'
export const WebSocketHandler = ({
wsEndpoint,
securityToken,
multipleMessages = false,
messages = [],
setMessages = () => {}
}) => {
const [message, setMessage] = useState('')
const websocket = useWebsocket({ wsEndpoint, securityToken })
const { socketRef } = websocket
const handleReceivedMessage = async (ev) => {
try {
// we are only interested in messages, not all the other events coming in on the socket
const data = JSON.parse(ev.data)
console.log('WebSocket Data', data)
if (data.type === 'message') {
setMessage(data.msg)
setMessages((prevArray) => [...prevArray, data.msg])
}
} catch (err) {
console.error(err)
}
}
useEffect(() => {
if (!socketRef.current) return
socketRef.current.addEventListener('message', handleReceivedMessage)
return () => {
if (!socketRef.current) return
socketRef.current.removeEventListener('message', handleReceivedMessage)
}
}, [socketRef.current])
return (
<>
{multipleMessages ? (
<div>
{messages.map((message) => (
<p>{message}</p>
))}
</div>
) : (
<span>{message ? message : 'You have no messages.'}</span>
)}
</>
)
}
export default WebSocketHandler
ClusterHelper
When developing locally, it quickly gets annoying to pre-load the HOPR node settings for multiple nodes. <ClusterHelper>
is
a development-only component that quickly loads information about individual nodes in a HOPR cluster to the application by clicking a button.
import React from 'react'
export default function ClusterHelper({
setHTTPEndpoint,
setWsEndpoint,
setSecurityToken,
selectedNode,
setSelectedNode
}) {
const CLUSTER_NODES = 5
const setEndpointsValueUsingIndex = (index) => {
const BASE_HTTP = 'http://localhost:1330'
const BASE_WS = 'ws://localhost:1950'
const DEFAULT_SECURITY_TOKEN = '^^LOCAL-testing-123^^'
setHTTPEndpoint(BASE_HTTP + index)
setWsEndpoint(BASE_WS + index)
setSecurityToken(DEFAULT_SECURITY_TOKEN)
}
return (
<div style={{ display: 'inline-block ' }}>
Preload Cluster Node -
{[...Array(CLUSTER_NODES)].map((_, index) => (
<button
style={{
background: selectedNode == index + 1 && 'blue',
color: selectedNode == index + 1 && 'white'
}}
onClick={() => {
setSelectedNode(index + 1)
setEndpointsValueUsingIndex(index + 1)
}}
>
{index + 1}
</button>
))}
</div>
)
}
RockPaperScissorsGame
The main component wrapping everything so far, including the game logic. In addition to loading everything, it includes all the state managers passed down to the previous components, alongside the event handlers and API calls against the actual HOPR nodes.
Tip
You can see the entire code under the Annex section.
import React, { useEffect, useState } from 'react'
import WebSocketHandler from './WebSocketHandler'
import Connector from './atoms/Connector'
import ClusterHelper from './atoms/ClusterHelper'
import { getHeaders } from './utils'
export default function RPSGame() {
...
useEffect(() => {
const loadAddress = async () => {
...
}
loadAddress()
}, [securityToken, httpEndpoint])
const sendMessage = async (recipient, body) => {
...
}
const sendMove = async (move) => {
...
}
useEffect(() => {
// Game logic goes here, when messages are received.
const gameLogic = async () => {
...
}
isReferee && gameLogic()
}, [messages])
return (
<div>
<ClusterHelper
...
/>
<br />
<span>PeerId: {address}</span>
<Connector
...
/>
<div>
<div style={{ display: 'inline-block', marginRight: '10px' }}>
<label htmlFor="isReferee">Is Referee</label>
<input
onChange={(e) => setIsReferee(e.target.checked)}
id="isReferee"
type="checkbox"
/>
</div>{''}
<label>Referee</label>{' '}
<input
name="referee"
disabled={isReferee}
placeholder={referee}
value={referee}
onChange={(e) => setReferee(e.target.value)}
/>
</div>
{address && !isReferee &&
<>
<button disabled={!referee} onClick={() => sendMove(PAPER_MOVE)}>Send "paper" move</button>
<button disabled={!referee} onClick={() => sendMove(SCISSORS_MOVE)}>Send "scissors" move</button>
<button disabled={!referee} onClick={() => sendMove(ROCK_MOVE)}>Send "rock" move</button>
</>
}
{notification && <><br />{notification}</>}
<>
<br />
<WebSocketHandler
...
/>
</>
</div >
)
}
Game Dynamic
Upon load, the RPS game prompts the user with the connecting information and a decision on whether the particular node
should be a Player
or a Referee
. Player
nodes can send moves, and Referee
nodes can listen to multiple messages.
Move Phase
To play, Player
nodes need to paste a Referee
address before being able to send a move. Once they have done
that, they can pick one of three moves: "rock", "paper", and "scissors". Picking a move will send an API request for a message
to the Referee
.
Once at least 2
Player
nodes have sent a move to the same Referee
, the game moves to the Decision Phase.
Note
A Referee
node will echo whatever move is sent. In an ideal setup, this information will only be known to the Referee
and not displayed. For the purposes of this demo, everything is shown.
Decision Phase
A Referee
node that has received at least two messages can execute the following game logic within the application:
useEffect(() => {
// Game logic goes here, when messages are received.
const gameLogic = async () => {
const [player1, player2] = messages
.slice(messages.length - 2)
.map((move) => ({ address: move.split('-')[0], move: move.split('-')[1] }))
// We ignore all other messages.
if (!player1 || !player2) return
if (!player1.move || !player2.move) return
if (player1.address != player2.address) {
if (
(player1.move == ROCK_MOVE && player2.move == ROCK_MOVE) ||
(player1.move == ROCK_MOVE && player2.move == ROCK_MOVE) ||
(player1.move == ROCK_MOVE && player2.move == ROCK_MOVE)
) {
await sendMessage(player1.address, `You tied with ${player2.address}: [1] ${player1.move}, [2] ${player2.move}`)
await sendMessage(player2.address, `You tied with ${player1.address}: [1] ${player1.move}, [2] ${player2.move}`)
} else if (
(player1.move == ROCK_MOVE && player2.move == SCISSORS_MOVE) ||
(player1.move == SCISSORS_MOVE && player2.move == PAPER_MOVE) ||
(player1.move == PAPER_MOVE && player2.move == ROCK_MOVE)
) {
await sendMessage(player1.address, `You won! ${player2.address} lost: [1] ${player1.move}, [2] ${player2.move}`)
await sendMessage(
player2.address,
`You lost... ${player1.address} won: [1] ${player1.move}, [2] ${player2.move}`
)
} else {
await sendMessage(player2.address, `You won! ${player1.address} lost: [1] ${player1.move}, [2] ${player2.move}`)
await sendMessage(
player1.address,
`You lost... ${player2.address} won: [1] ${player1.move}, [2] ${player2.move}`
)
}
}
}
isReferee && gameLogic()
}, [messages])
The logic can be described as follows.
First, we make sure we parse only moves and not any message sent to our nodes. In lines
4-6
, we parse the messages given the expected formataddress-move
. We need both values to know who’s playing and reply to them.In lines
9-10
we make sure to ignore messages that do not follow the expected format.Line
12
makes sure the game can only be played by different users.Lines
14-16
and21-23
are the ones actually comparing the moves, and send their respective lines after sending the appropriate response to the players, concluding the game.Last but not least, line
33
ensures this logic is only called byReferee
nodes, so even ifPlayer
nodes get messages, they will not act asReferee
nodes and send messages back.
Demo - Rock, Paper, Scissors Game
Player 1
Loading Player 1...
Referee
Loading Referee...
Player 2
Loading Player 2...
Important notes
This RPS game already highlights some of the benefits of the HOPR network as a p2p platform:
- Instead of relying on a central authority, anyone can execute a game logic and evaluate player actions as needed.
- Because of how the technology works, these applications can be coded as simple web pages that connect to a more complex backend.
- Since all information is private, you could send sensitive information and even create games around secrets or private keys.
That being said, it should also be obvious there are also wrinkles and shortcomings:
- Since there is no sender-receiver linkage, anyone can pretend to send a message (this can be solved using the
/sign
API endpoint, which ensures a message was sent by your node and not any node). - At the time of writing, the HOPR protocol does not have an acknowledge-like feature, so message senders have no way
to know whether their messages arrive (this is currently handled on an application level, like the
Referee
sending a response back). - Messages can take any shape or form, so the encoding and decoding of the messages to information that’s relevant is currently handled on an application basis. In the future, there will be a HOPR SDK which will help with some basic message parsing.
Annex: RockPaperScissorsGame.jsx
import React, { useEffect, useState } from 'react'
import WebSocketHandler from './WebSocketHandler'
import Connector from './atoms/Connector'
import ClusterHelper from './atoms/ClusterHelper'
import { getHeaders } from './utils'
export default function RPSGame() {
const [securityToken, setSecurityToken] = useState('')
const [selectedNode, setSelectedNode] = useState()
const [wsEndpoint, setWsEndpoint] = useState('ws://localhost:3000')
const [httpEndpoint, setHTTPEndpoint] = useState('http://localhost:3001')
const [messages, setMessages] = useState([])
const [address, setAddress] = useState('')
const [isReferee, setIsReferee] = useState()
const [referee, setReferee] = useState('')
const [notification, setNotification] = useState('')
const SCISSORS_MOVE = 'scissors'
const ROCK_MOVE = 'rock'
const PAPER_MOVE = 'paper'
useEffect(() => {
const loadAddress = async () => {
const headers = getHeaders(securityToken)
const account = await fetch(`${httpEndpoint}/api/v2/account/addresses`, {
headers
})
.then((res) => res.json())
.catch((err) => console.error(err))
setAddress(account?.hopr)
}
loadAddress()
}, [securityToken, httpEndpoint])
const sendMessage = async (recipient, body) => {
if (!address) return
await fetch(`${httpEndpoint}/api/v2/messages`, {
method: 'POST',
headers: getHeaders(securityToken, true),
body: JSON.stringify({
recipient,
body
})
}).catch((err) => console.error(err))
}
const sendMove = async (move) => {
await sendMessage(referee, `${address}-${move}`)
setNotification(`You have sent the move ${move} to referee ${referee}`)
}
useEffect(() => {
// Game logic goes here, when messages are received.
const gameLogic = async () => {
const [player1, player2] = messages
.slice(messages.length - 2)
.map((move) => ({ address: move.split('-')[0], move: move.split('-')[1] }))
// We ignore all other messages.
if (!player1 || !player2) return
if (!player1.move || !player2.move) return
if (player1.address != player2.address) {
if (
(player1.move == ROCK_MOVE && player2.move == ROCK_MOVE) ||
(player1.move == ROCK_MOVE && player2.move == ROCK_MOVE) ||
(player1.move == ROCK_MOVE && player2.move == ROCK_MOVE)
) {
await sendMessage(
player1.address,
`You tied with ${player2.address}: [1] ${player1.move}, [2] ${player2.move}`
)
await sendMessage(
player2.address,
`You tied with ${player1.address}: [1] ${player1.move}, [2] ${player2.move}`
)
} else if (
(player1.move == ROCK_MOVE && player2.move == SCISSORS_MOVE) ||
(player1.move == SCISSORS_MOVE && player2.move == PAPER_MOVE) ||
(player1.move == PAPER_MOVE && player2.move == ROCK_MOVE)
) {
await sendMessage(
player1.address,
`You won! ${player2.address} lost: [1] ${player1.move}, [2] ${player2.move}`
)
await sendMessage(
player2.address,
`You lost... ${player1.address} won: [1] ${player1.move}, [2] ${player2.move}`
)
} else {
await sendMessage(
player2.address,
`You won! ${player1.address} lost: [1] ${player1.move}, [2] ${player2.move}`
)
await sendMessage(
player1.address,
`You lost... ${player2.address} won: [1] ${player1.move}, [2] ${player2.move}`
)
}
}
}
isReferee && gameLogic()
}, [messages])
return (
<div>
<ClusterHelper
selectedNode={selectedNode}
setSelectedNode={setSelectedNode}
setHTTPEndpoint={setHTTPEndpoint}
setWsEndpoint={setWsEndpoint}
setSecurityToken={setSecurityToken}
/>
<br />
<span>PeerId: {address}</span>
<Connector
httpEndpoint={httpEndpoint}
setHTTPEndpoint={setHTTPEndpoint}
wsEndpoint={wsEndpoint}
setWsEndpoint={setWsEndpoint}
securityToken={securityToken}
setSecurityToken={setSecurityToken}
/>
<div>
<div style={{ display: 'inline-block', marginRight: '10px' }}>
<label htmlFor="isReferee">Is Referee</label>
<input onChange={(e) => setIsReferee(e.target.checked)} id="isReferee" type="checkbox" />
</div>
{''}
<label>Referee</label>{' '}
<input
name="referee"
disabled={isReferee}
placeholder={referee}
value={referee}
onChange={(e) => setReferee(e.target.value)}
/>
</div>
{address && !isReferee && (
<>
<button disabled={!referee} onClick={() => sendMove(PAPER_MOVE)}>
Send "paper" move
</button>
<button disabled={!referee} onClick={() => sendMove(SCISSORS_MOVE)}>
Send "scissors" move
</button>
<button disabled={!referee} onClick={() => sendMove(ROCK_MOVE)}>
Send "rock" move
</button>
</>
)}
{notification && (
<>
<br />
{notification}
</>
)}
<>
<br />
<WebSocketHandler
wsEndpoint={wsEndpoint}
securityToken={securityToken}
multipleMessages={isReferee}
messages={messages}
setMessages={setMessages}
/>
</>
</div>
)
}