2022-10-30 07:49:02 +00:00
## # Tron example
##
## In this tutorial, we will create a video game based on libp2p, using
## all of the features we talked about in the last tutorials.
##
## We will:
## - Discover peers using the Discovery Manager
## - Use GossipSub to find a play mate
## - Create a custom protocol to play with him
##
## While this may look like a daunting project, it's less than 150 lines of code.
##
## The game will be a simple Tron. We will use [nico](https://github.com/ftsf/nico)
## as a game engine. (you need to run `nimble install nico` to have it available)
##
## ![multiplay](https://user-images.githubusercontent.com/13471753/198852714-b55048e3-f233-4723-900d-2193ad259fe1.gif)
##
## We will start by importing our dependencies and creating our types
import os
import nico , chronos , stew / byteutils , stew / endians2
import libp2p
import libp2p / protocols / rendezvous
import libp2p / discovery / rendezvousinterface
import libp2p / discovery / discoverymngr
const
directions = @ [ ( K_UP , 0 , - 1 ) , ( K_LEFT , - 1 , 0 ) , ( K_DOWN , 0 , 1 ) , ( K_RIGHT , 1 , 0 ) ]
mapSize = 32
tickPeriod = 0 .2
type
Player = ref object
x , y : int
currentDir , nextDir : int
lost : bool
color : int
Game = ref object
gameMap : array [ mapSize * mapSize , int ]
tickTime : float
localPlayer , remotePlayer : Player
peerFound : Future [ Connection ]
hasCandidate : bool
tickFinished : Future [ int ]
GameProto = ref object of LPProtocol
proc new ( _ : type [ Game ] ) : Game =
# Default state of a game
result = Game (
tickTime : - 3 .0 , # 3 seconds of "warm-up" time
localPlayer : Player ( x : 4 , y : 16 , currentDir : 3 , nextDir : 3 , color : 8 ) ,
remotePlayer : Player ( x : 27 , y : 16 , currentDir : 1 , nextDir : 1 , color : 12 ) ,
2024-06-11 15:18:06 +00:00
peerFound : newFuture [ Connection ] ( ) ,
2022-10-30 07:49:02 +00:00
)
for pos in 0 .. result . gameMap . high :
if pos mod mapSize in [ 0 , mapSize - 1 ] or pos div mapSize in [ 0 , mapSize - 1 ] :
result . gameMap [ pos ] = 7
## ## Game Logic
## The networking during the game will work like this:
##
## * Each player will have `tickPeriod` (0.1) seconds to choose
## a direction that he wants to go to (default to current direction)
## * After `tickPeriod`, we will send our choosen direction to the peer,
## and wait for his direction
## * Once we have both direction, we will "tick" the game, and restart the
## loop, as long as both player are alive.
##
## This is a very simplistic scheme, but creating proper networking for
## video games is an [art](https://developer.valvesoftware.com/wiki/Latency_Compensating_Methods_in_Client/Server_In-game_Protocol_Design_and_Optimization)
##
## The main drawback of this scheme is that the more ping you have with
## the peer, the slower the game will run. Or invertedly, the less ping you
## have, the faster it runs!
proc update ( g : Game , dt : float32 ) =
# Will be called at each frame of the game.
#
# Because both Nico and Chronos have a main loop,
# they must share the control of the main thread.
# This is a hacky way to make this happen
waitFor ( sleepAsync ( 1 . milliseconds ) )
# Don't do anything if we are still waiting for an opponent
2024-06-11 15:18:06 +00:00
if not ( g . peerFound . finished ( ) ) or isNil ( g . tickFinished ) :
return
2022-10-30 07:49:02 +00:00
g . tickTime + = dt
# Update the wanted direction, making sure we can't go backward
for i in 0 .. directions . high :
if i ! = ( g . localPlayer . currentDir + 2 mod 4 ) and keyp ( directions [ i ] [ 0 ] ) :
g . localPlayer . nextDir = i
if g . tickTime > tickPeriod and not g . tickFinished . finished ( ) :
# We choosen our next direction, let the networking know
g . localPlayer . currentDir = g . localPlayer . nextDir
g . tickFinished . complete ( g . localPlayer . currentDir )
proc tick ( g : Game , p : Player ) =
# Move player and check if he lost
p . x + = directions [ p . currentDir ] [ 1 ]
p . y + = directions [ p . currentDir ] [ 2 ]
2024-06-11 15:18:06 +00:00
if g . gameMap [ p . y * mapSize + p . x ] ! = 0 :
p . lost = true
2022-10-30 07:49:02 +00:00
g . gameMap [ p . y * mapSize + p . x ] = p . color
proc mainLoop ( g : Game , peer : Connection ) {. async . } =
while not ( g . localPlayer . lost or g . remotePlayer . lost ) :
if g . tickTime > 0 .0 :
g . tickTime = 0
g . tickFinished = newFuture [ int ] ( )
# Wait for a choosen direction
let dir = await g . tickFinished
# Send it
await peer . writeLp ( toBytes ( uint32 ( dir ) ) )
# Get the one from the peer
g . remotePlayer . currentDir = int uint32 . fromBytes ( await peer . readLp ( 8 ) )
# Tick the players & restart
g . tick ( g . remotePlayer )
g . tick ( g . localPlayer )
## We'll draw the map & put some texts when necessary:
proc draw ( g : Game ) =
for pos , color in g . gameMap :
setColor ( color )
boxFill ( pos mod 32 * 4 , pos div 32 * 4 , 4 , 4 )
2024-06-11 15:18:06 +00:00
let text =
if not ( g . peerFound . finished ( ) ) :
" Matchmaking.. "
elif g . tickTime < - 1 .5 :
" Welcome to Etron "
elif g . tickTime < 0 .0 :
" - " & $ ( int ( abs ( g . tickTime ) / 0 .5 ) + 1 ) & " - "
elif g . remotePlayer . lost and g . localPlayer . lost :
" DEUCE "
elif g . localPlayer . lost :
" YOU LOOSE "
elif g . remotePlayer . lost :
" YOU WON "
else :
" "
2022-10-30 07:49:02 +00:00
printc ( text , screenWidth div 2 , screenHeight div 2 )
## ## Matchmaking
## To find an opponent, we will broadcast our address on a
## GossipSub topic, and wait for someone to connect to us.
## We will also listen to that topic, and connect to anyone
## broadcasting his address.
##
## If we are looking for a game, we'll send `ok` to let the
## peer know that we are available, check that he is also available,
## and launch the game.
proc new ( T : typedesc [ GameProto ] , g : Game ) : T =
2023-12-05 07:05:32 +00:00
proc handle ( conn : Connection , proto : string ) {. async . } =
2024-06-11 15:18:06 +00:00
defer :
await conn . closeWithEof ( )
2022-10-30 07:49:02 +00:00
if g . peerFound . finished or g . hasCandidate :
await conn . close ( )
return
g . hasCandidate = true
await conn . writeLp ( " ok " )
if " ok " ! = string . fromBytes ( await conn . readLp ( 1024 ) ) :
g . hasCandidate = false
return
g . peerFound . complete ( conn )
# The handler of a protocol must wait for the stream to
# be finished before returning
await conn . join ( )
2024-06-11 15:18:06 +00:00
2022-12-01 11:20:40 +00:00
return T . new ( codecs = @ [ " /tron/1.0.0 " ] , handler = handle )
2022-10-30 07:49:02 +00:00
proc networking ( g : Game ) {. async . } =
# Create our switch, similar to the GossipSub example and
# the Discovery examples combined
let
rdv = RendezVous . new ( )
2024-06-11 15:18:06 +00:00
switch = SwitchBuilder
. new ( )
2022-10-30 07:49:02 +00:00
. withRng ( newRng ( ) )
2024-06-11 15:18:06 +00:00
. withAddresses ( @ [ MultiAddress . init ( " /ip4/0.0.0.0/tcp/0 " ) . tryGet ( ) ] )
2022-10-30 07:49:02 +00:00
. withTcpTransport ( )
. withYamux ( )
. withNoise ( )
. withRendezVous ( rdv )
. build ( )
dm = DiscoveryManager ( )
gameProto = GameProto . new ( g )
2024-06-11 15:18:06 +00:00
gossip = GossipSub . init ( switch = switch , triggerSelf = false )
2022-10-30 07:49:02 +00:00
dm . add ( RendezVousInterface . new ( rdv ) )
switch . mount ( gossip )
switch . mount ( gameProto )
gossip . subscribe (
" /tron/matchmaking " ,
2024-06-11 15:18:06 +00:00
proc ( topic : string , data : seq [ byte ] ) {. async . } =
2022-10-30 07:49:02 +00:00
# If we are still looking for an opponent,
2024-06-03 18:05:40 +00:00
# try to match anyone broadcasting its address
2024-06-11 15:18:06 +00:00
if g . peerFound . finished or g . hasCandidate :
return
2022-10-30 07:49:02 +00:00
g . hasCandidate = true
try :
let
( peerId , multiAddress ) = parseFullAddress ( data ) . tryGet ( )
stream = await switch . dial ( peerId , @ [ multiAddress ] , gameProto . codec )
await stream . writeLp ( " ok " )
if ( await stream . readLp ( 10 ) ) ! = " ok " . toBytes :
g . hasCandidate = false
return
g . peerFound . complete ( stream )
# We are "player 2"
swap ( g . localPlayer , g . remotePlayer )
except CatchableError as exc :
discard
2024-06-11 15:18:06 +00:00
,
2022-10-30 07:49:02 +00:00
)
await switch . start ( )
2024-06-11 15:18:06 +00:00
defer :
await switch . stop ( )
2022-10-30 07:49:02 +00:00
# As explained in the last tutorial, we need a bootnode to be able
# to find peers. We could use any libp2p running rendezvous (or any
# node running tron). We will take it's MultiAddress from the command
# line parameters
if paramCount ( ) > 0 :
let ( peerId , multiAddress ) = paramStr ( 1 ) . parseFullAddress ( ) . tryGet ( )
await switch . connect ( peerId , @ [ multiAddress ] )
else :
echo " No bootnode provided, listening on: " , switch . peerInfo . fullAddrs . tryGet ( )
# Discover peers from the bootnode, and connect to them
dm . advertise ( RdvNamespace ( " tron " ) )
let discoveryQuery = dm . request ( RdvNamespace ( " tron " ) )
discoveryQuery . forEach :
try :
await switch . connect ( peer [ PeerId ] , peer . getAll ( MultiAddress ) )
except CatchableError as exc :
echo " Failed to dial a peer: " , exc . msg
# We will try to publish our address multiple times, in case
# it takes time to establish connections with other GossipSub peers
var published = false
while not published :
await sleepAsync ( 500 . milliseconds )
for fullAddr in switch . peerInfo . fullAddrs . tryGet ( ) :
if ( await gossip . publish ( " /tron/matchmaking " , fullAddr . bytes ) ) = = 0 :
published = false
break
published = true
discoveryQuery . stop ( )
# We now wait for someone to connect to us (or for us to connect to someone)
let peerConn = await g . peerFound
2024-06-11 15:18:06 +00:00
defer :
await peerConn . closeWithEof ( )
2022-10-30 07:49:02 +00:00
await g . mainLoop ( peerConn )
let
game = Game . new ( )
netFut = networking ( game )
nico . init ( " Status " , " Tron " )
nico . createWindow ( " Tron " , mapSize * 4 , mapSize * 4 , 4 , false )
2024-06-11 15:18:06 +00:00
nico . run (
proc ( ) =
discard
,
proc ( dt : float32 ) =
game . update ( dt )
,
proc ( ) =
game . draw ( )
,
)
2022-10-30 07:49:02 +00:00
waitFor ( netFut . cancelAndWait ( ) )
## And that's it! If you want to run this code locally, the simplest way is to use the
## first node as a boot node for the second one. But you can also use any rendezvous node