Skip to content

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 as a game engine. (you need to run nimble install nico to have it available)

multiplay

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

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.new(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