diff --git a/examples/tutorial_6_game.nim b/examples/tutorial_6_game.nim new file mode 100644 index 0000000..ffbf09a --- /dev/null +++ b/examples/tutorial_6_game.nim @@ -0,0 +1,259 @@ +## # 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), + peerFound: newFuture[Connection]() + ) + 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 + if not(g.peerFound.finished()) or isNil(g.tickFinished): return + 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] + if g.gameMap[p.y * mapSize + p.x] != 0: p.lost = true + 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) + 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: "" + 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 = + proc handle(conn: Connection, proto: string) {.async, gcsafe.} = + defer: await conn.closeWithEof() + 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() + return T(codecs: @["/tron/1.0.0"], handler: handle) + +proc networking(g: Game) {.async.} = + # Create our switch, similar to the GossipSub example and + # the Discovery examples combined + let + rdv = RendezVous.new() + switch = SwitchBuilder.new() + .withRng(newRng()) + .withAddresses(@[ MultiAddress.init("/ip4/0.0.0.0/tcp/0").tryGet() ]) + .withTcpTransport() + .withYamux() + .withNoise() + .withRendezVous(rdv) + .build() + dm = DiscoveryManager() + gameProto = GameProto.new(g) + gossip = GossipSub.init( + switch = switch, + triggerSelf = false) + dm.add(RendezVousInterface.new(rdv)) + + switch.mount(gossip) + switch.mount(gameProto) + + gossip.subscribe( + "/tron/matchmaking", + proc (topic: string, data: seq[byte]) {.async.} = + # If we are still looking for an opponent, + # try to match anyone broadcasting it's address + if g.peerFound.finished or g.hasCandidate: return + 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 + ) + + await switch.start() + defer: await switch.stop() + + # 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 + defer: await peerConn.closeWithEof() + + await g.mainLoop(peerConn) + +let + game = Game.new() + netFut = networking(game) +nico.init("Status", "Tron") +nico.createWindow("Tron", mapSize * 4, mapSize * 4, 4, false) +nico.run(proc = discard, proc(dt: float32) = game.update(dt), proc = game.draw()) +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 diff --git a/libp2p.nim b/libp2p.nim index 46f8023..8d44aae 100644 --- a/libp2p.nim +++ b/libp2p.nim @@ -17,7 +17,7 @@ when defined(nimdoc): ## stay backward compatible during the Major version, whereas private ones can ## change at each new Minor version. ## - ## If you're new to nim-libp2p, you can find a tutorial `here`_ + ## If you're new to nim-libp2p, you can find a tutorial `here`_ ## that can help you get started. # Import stuff for doc diff --git a/libp2p.nimble b/libp2p.nimble index 1c5f403..c0aa3c5 100644 --- a/libp2p.nimble +++ b/libp2p.nimble @@ -31,8 +31,8 @@ proc runTest(filename: string, verify: bool = true, sign: bool = true, exec excstr & " -r " & " tests/" & filename rmFile "tests/" & filename.toExe -proc buildSample(filename: string, run = false) = - var excstr = "nim c --opt:speed --threads:on -d:debug --verbosity:0 --hints:off -p:. " +proc buildSample(filename: string, run = false, extraFlags = "") = + var excstr = "nim c --opt:speed --threads:on -d:debug --verbosity:0 --hints:off -p:. " & extraFlags excstr.add(" examples/" & filename) exec excstr if run: @@ -92,6 +92,7 @@ task website, "Build the website": tutorialToMd("examples/tutorial_3_protobuf.nim") tutorialToMd("examples/tutorial_4_gossipsub.nim") tutorialToMd("examples/tutorial_5_discovery.nim") + tutorialToMd("examples/tutorial_6_game.nim") tutorialToMd("examples/circuitrelay.nim") exec "mkdocs build" @@ -106,6 +107,9 @@ task examples_build, "Build the samples": buildSample("tutorial_3_protobuf", true) buildSample("tutorial_4_gossipsub", true) buildSample("tutorial_5_discovery", true) + # Nico doesn't work in 1.2 + exec "nimble install -y nico" + buildSample("tutorial_6_game", false, "--styleCheck:off") # pin system # while nimble lockfile diff --git a/libp2p/discovery/discoverymngr.nim b/libp2p/discovery/discoverymngr.nim index 6c18543..140f533 100644 --- a/libp2p/discovery/discoverymngr.nim +++ b/libp2p/discovery/discoverymngr.nim @@ -89,10 +89,12 @@ method advertise*(self: DiscoveryInterface) {.async, base.} = type DiscoveryError* = object of LPError + DiscoveryFinished* = object of LPError DiscoveryQuery* = ref object attr: PeerAttributes peers: AsyncQueue[PeerAttributes] + finished: bool futs: seq[Future[void]] DiscoveryManager* = ref object @@ -137,7 +139,22 @@ proc advertise*[T](dm: DiscoveryManager, value: T) = pa.add(value) dm.advertise(pa) +template forEach*(query: DiscoveryQuery, code: untyped) = + ## Will execute `code` for each discovered peer. The + ## peer attritubtes are available through the variable + ## `peer` + + proc forEachInternal(q: DiscoveryQuery) {.async.} = + while true: + let peer {.inject.} = + try: await q.getPeer() + except DiscoveryFinished: return + code + + asyncSpawn forEachInternal(query) + proc stop*(query: DiscoveryQuery) = + query.finished = true for r in query.futs: if not r.finished(): r.cancel() @@ -158,6 +175,8 @@ proc getPeer*(query: DiscoveryQuery): Future[PeerAttributes] {.async.} = raise exc if not finished(getter): + if query.finished: + raise newException(DiscoveryFinished, "Discovery query stopped") # discovery loops only finish when they don't handle the query raise newException(DiscoveryError, "Unable to find any peer matching this request") return await getter diff --git a/libp2p/multiaddress.nim b/libp2p/multiaddress.nim index d7ef6e9..055d8b1 100644 --- a/libp2p/multiaddress.nim +++ b/libp2p/multiaddress.nim @@ -1084,6 +1084,9 @@ proc `$`*(pat: MaPattern): string = elif pat.operator == Eq: result = $pat.value +proc bytes*(value: MultiAddress): seq[byte] = + value.data.buffer + proc write*(pb: var ProtoBuffer, field: int, value: MultiAddress) {.inline.} = write(pb, field, value.data.buffer) diff --git a/libp2p/peerinfo.nim b/libp2p/peerinfo.nim index 39a6ad6..12aeb47 100644 --- a/libp2p/peerinfo.nim +++ b/libp2p/peerinfo.nim @@ -15,7 +15,7 @@ else: import std/[options, sequtils] import pkg/[chronos, chronicles, stew/results] -import peerid, multiaddress, crypto/crypto, routing_record, errors, utility +import peerid, multiaddress, multicodec, crypto/crypto, routing_record, errors, utility export peerid, multiaddress, crypto, routing_record, errors, results @@ -69,6 +69,27 @@ proc update*(p: PeerInfo) {.async.} = proc addrs*(p: PeerInfo): seq[MultiAddress] = p.addrs +proc fullAddrs*(p: PeerInfo): MaResult[seq[MultiAddress]] = + let peerIdPart = ? MultiAddress.init(multiCodec("p2p"), p.peerId.data) + var res: seq[MultiAddress] + for address in p.addrs: + res.add(? concat(address, peerIdPart)) + ok(res) + +proc parseFullAddress*(ma: MultiAddress): MaResult[(PeerId, MultiAddress)] = + let p2pPart = ? ma[^1] + if ? p2pPart.protoCode != multiCodec("p2p"): + return err("Missing p2p part from multiaddress!") + + let res = ( + ? PeerId.init(? p2pPart.protoArgument()).orErr("invalid peerid"), + ? ma[0 .. ^2] + ) + ok(res) + +proc parseFullAddress*(ma: string | seq[byte]): MaResult[(PeerId, MultiAddress)] = + parseFullAddress(? MultiAddress.init(ma)) + proc new*( p: typedesc[PeerInfo], key: PrivateKey, diff --git a/mkdocs.yml b/mkdocs.yml index 10d8f05..e9cdaac 100644 --- a/mkdocs.yml +++ b/mkdocs.yml @@ -40,12 +40,13 @@ theme: name: Switch to light mode nav: - - Introduction: README.md - Tutorials: + - 'Introduction': README.md - 'Simple connection': tutorial_1_connect.md - 'Create a custom protocol': tutorial_2_customproto.md - 'Protobuf': tutorial_3_protobuf.md - 'GossipSub': tutorial_4_gossipsub.md - 'Discovery Manager': tutorial_5_discovery.md + - 'Game': tutorial_6_game.md - 'Circuit Relay': circuitrelay.md - Reference: '/nim-libp2p/master/libp2p.html'