Bunyip a game engine in Go GitHub

Example examples/network

Network

This is a small multiplayer program: one copy hosts and the others join. Typed lines travel over TCP and every peer's pointer position travels over UDP, so both halves of network are in one place. With -reliable the chat goes over UDP too, through SendReliable, and a panel counts how many lines arrived and whether they arrived in order, with the link's round trip and loss.

Like roguelike, it runs turn-based. engine.Config{TurnBased: true} lets the engine loop block in the operating system until events or a wake request arrive. Network goroutines still run, including the UDP peer's retries and keepalives. Network traffic has to wake it, which is what SetOnActivity(ctx.Wake) arranges: the network calls ctx.Wake from its own goroutine and the loop runs an update. Read the window guide for the two loop modes and the game services guide for the rest.

The example needs a peer, so the examples test skips it and this page has no screenshot. Run two copies:

go run ./examples/network -listen :7777
go run ./examples/network -join localhost:7777 -name second

The flags are -seconds N and -shot file.png, -listen addr to host, -join host:port to connect, -name for the name shown to others, -bot to send a greeting once connected, and -reliable to send chat over reliable UDP.

The messages

A message is a Go type registered with a network.Registry, which both ends build the same way so a type id means the same thing on each. Four types are enough here: a join announcing a name, a chat line, a welcome telling a new client which UDP port the host listens on, and a pointer position.

pointer implements MarshalBinary and UnmarshalBinary, so it is sent as eight bytes and a name rather than through the default encoding. That is worth doing for a message sent on every mouse move and not worth doing for anything sent once. The coordinates are rounded to integers on the way out, which is all a pointer needs.

// Command network is a chat room over the network package. Run one copy
// with -listen and others with -join; typed lines travel over TCP and
// every peer's pointer position is shared over UDP. With -reliable the
// chat lines go over UDP too, with SendReliable, and the panel counts
// how many arrived in order. Both sides run in turn-based mode and
// sleep until input or a message arrives, which the network's activity
// hook signals through Context.Wake.
package main

import (
	"encoding/binary"
	"flag"
	"fmt"
	"os"
	"time"

	"golang.org/x/image/font/gofont/goregular"

	"github.com/matjam/bunyip/engine"
	"github.com/matjam/bunyip/gfx"
	"github.com/matjam/bunyip/input"
	"github.com/matjam/bunyip/network"
	"github.com/matjam/bunyip/ui"
)

// Messages shared by both ends.
type join struct{ Name string }
type chat struct {
	From, Text string
	Seq        int // the sender's count of reliable lines, to check the order
}
type welcome struct{ UDPPort int }

// pointer is a UDP message with a compact binary encoding.
type pointer struct {
	Name string
	X, Y float32
}

func (p pointer) MarshalBinary() ([]byte, error) {
	b := make([]byte, 8+len(p.Name))
	binary.LittleEndian.PutUint32(b, uint32(int32(p.X)))
	binary.LittleEndian.PutUint32(b[4:], uint32(int32(p.Y)))
	copy(b[8:], p.Name)
	return b, nil
}

func (p *pointer) UnmarshalBinary(b []byte) error {
	if len(b) < 8 {
		return fmt.Errorf("short pointer")
	}
	p.X = float32(int32(binary.LittleEndian.Uint32(b)))
	p.Y = float32(int32(binary.LittleEndian.Uint32(b[4:])))
	p.Name = string(b[8:])
	return nil
}

The game state

The game holds both ends of both protocols, because one process can be either. server and client are the TCP halves and only one is set; udp is a peer that exists either way. peers is the set of UDP addresses the host fans messages out to, and target is the one address a client sends to.

type game struct {
	seconds  float64
	shot     string
	listen   string
	joinTo   string
	name     string
	bot      bool
	reliable bool

	font     *gfx.Font
	ui       *ui.Context
	reg      *network.Registry
	server   *network.Server
	client   *network.Client
	udp      *network.Peer
	peers    map[string]*network.Addr // connected UDP addresses to fan out to (server)
	target   *network.Addr            // where a client sends over UDP
	lines    []string
	draft    string
	points   map[string]pointer
	mouseX   float32
	mouseY   float32
	shotDone bool

	sent     int            // reliable lines sent
	received int            // reliable lines received
	lastSeq  map[string]int // last reliable Seq seen per sender
	inOrder  bool
}

Init: registry, sockets and wake-ups

The registry registers the four message types. network.ListenUDP(":0", g.reg) opens a UDP socket on a port the operating system chooses, which both the host and a client do.

SetOnActivity(ctx.Wake) is what makes turn-based mode work with a network: registration wakes for queued events and the socket calls it when later traffic arrives. Server hooks cover existing connections too. ctx.Wake is safe to call from another goroutine, unlike almost everything else on the context. Each opened endpoint registers its close operation with ctx.Cleanup, including cleanup after an initialization error.

network.Listen starts a TCP server and network.Dial connects to one with a timeout. The client sends its join immediately. g.ui.OnTextInputRect = ctx.SetTextInputRect tells the platform where the text field is, so an input method's candidate window appears in the right place.

func (g *game) Init(ctx *engine.Context) error {
	var err error
	if g.font, err = ctx.Gfx.NewFont(goregular.TTF, 15, gfx.FontOptions{}); err != nil {
		return err
	}
	g.ui = ui.New(ctx.Gfx, ui.DarkTheme(g.font))
	g.ui.OnTextInputRect = ctx.SetTextInputRect
	g.reg = network.NewRegistry().Register(join{}, chat{}, welcome{}, pointer{})
	g.peers = map[string]*network.Addr{}
	g.points = map[string]pointer{}
	g.lastSeq = map[string]int{}
	g.inOrder = true
	if g.udp, err = network.ListenUDP(":0", g.reg); err != nil {
		return err
	}
	ctx.Cleanup(func() { g.udp.Close() })
	g.udp.SetOnActivity(ctx.Wake)
	switch {
	case g.listen != "":
		if g.server, err = network.Listen(g.listen, g.reg); err != nil {
			return err
		}
		ctx.Cleanup(func() { g.server.Close() })
		g.server.SetOnActivity(ctx.Wake)
		g.say(fmt.Sprintf("Listening on %s (UDP %d). Others join with -join %s", g.server.Addr(), g.udp.Addr().Port, g.server.Addr()))
	case g.joinTo != "":
		if g.client, err = network.Dial(g.joinTo, g.reg, 5*time.Second); err != nil {
			return err
		}
		client := g.client // retain this connection even after a disconnect clears g.client
		ctx.Cleanup(func() { client.Close() })
		g.client.SetOnActivity(ctx.Wake)
		g.client.Send(join{Name: g.name})
		g.say("Connected to " + g.joinTo)
	default:
		g.say("Run with -listen :7777 to host, or -join host:7777 to connect.")
	}
	return nil
}

say appends a line to the log and keeps the last fourteen.

func (g *game) say(line string) {
	g.lines = append(g.lines, line)
	if len(g.lines) > 14 {
		g.lines = g.lines[1:]
	}
}

sendChat is where the two transports diverge. Over TCP a client sends to the server and a server broadcasts to everyone. Over reliable UDP each line is numbered with the sender's own counter and sent to every known address with SendReliable, which retransmits until it is acknowledged and delivers in order.

// sendChat delivers a line to everyone else: over TCP, or with
// -reliable over UDP with SendReliable.
func (g *game) sendChat(msg chat) {
	if !g.reliable {
		if g.client != nil {
			g.client.Send(msg)
		}
		if g.server != nil {
			g.server.Broadcast(msg)
		}
		return
	}
	msg.Seq = g.sent
	g.sent++
	if g.target != nil {
		g.udp.SendReliable(g.target, msg)
	}
	for _, addr := range g.peers {
		g.udp.SendReliable(addr, msg)
	}
}

receiveChat checks the numbering: a line whose sequence is not one more than the last from that sender means the ordering guarantee failed, which the panel reports.

// receiveChat counts a reliable line and checks it follows the
// sender's previous one.
func (g *game) receiveChat(m *chat) {
	g.received++
	if last, seen := g.lastSeq[m.From]; seen && m.Seq != last+1 {
		g.inOrder = false
	}
	g.lastSeq[m.From] = m.Seq
	g.say(m.From + ": " + m.Text)
}

Update: polling the three sockets

Nothing arrives asynchronously into the game. Each socket is polled once per update and returns the events that have queued up, so all the game state changes on one goroutine, in the update, and no locking is needed.

ctx.RequestRedraw in a timed run keeps the turn-based loop ticking even with no input, so the timeout and the screenshot still happen.

The server's events are Connected, Disconnected and Message. A connection carries a Data field the game can use for its own per-connection state, which is where the peer's name is kept. Broadcast(*m, ev.Conn) sends to everyone except the connections listed, so the sender does not receive its own line back.

The client's loop handles welcome by resolving the host's UDP address and calling Connect, which makes the host aware of this peer before the first pointer arrives.

The UDP loop fans pointers and reliable chat out to every other peer, which is what makes the host the relay: clients talk to the host, and the host repeats to the others.

The last block sends this peer's pointer whenever the mouse moves, which is the only unreliable traffic here. A dropped pointer does not matter, because the next one replaces it.

func (g *game) Update(ctx *engine.Context) error {
	in := ctx.Input
	if in.KeyPressed(input.KeyEscape) && !g.ui.WantsKeyboard() || (g.seconds > 0 && ctx.Time >= g.seconds) {
		ctx.Quit()
	}
	if g.seconds > 0 {
		ctx.RequestRedraw() // keep ticking so the timeout and screenshot fire
	}
	if g.shot != "" && !g.shotDone && (g.seconds == 0 || ctx.Time >= g.seconds/2) {
		ctx.Screenshot(g.shot)
		g.shotDone = true
	}
	if g.server != nil {
		for _, ev := range g.server.Poll() {
			switch ev.Kind {
			case network.Connected:
				ev.Conn.Send(welcome{UDPPort: g.udp.Addr().Port})
				g.say(fmt.Sprintf("Peer %d connected from %s", ev.Conn.ID, ev.Conn.Addr()))
			case network.Disconnected:
				if ev.Conn != nil {
					g.say(fmt.Sprintf("Peer %d left (%v)", ev.Conn.ID, ev.Err))
				}
			case network.Message:
				switch m := ev.Msg.(type) {
				case *join:
					ev.Conn.Data = m.Name
					g.say(m.Name + " joined")
					g.server.Broadcast(chat{From: "server", Text: m.Name + " joined"})
				case *chat:
					g.say(m.From + ": " + m.Text)
					g.server.Broadcast(*m, ev.Conn) // everyone but the sender, who already has it
				}
			}
		}
	}
	if g.client != nil {
		for _, ev := range g.client.Poll() {
			switch ev.Kind {
			case network.Disconnected:
				g.say(fmt.Sprintf("Disconnected (%v)", ev.Err))
				g.client = nil
			case network.Message:
				switch m := ev.Msg.(type) {
				case *welcome:
					host, _, _ := splitHostPort(g.joinTo)
					g.target, _ = network.Resolve(fmt.Sprintf("%s:%d", host, m.UDPPort))
					g.udp.Connect(g.target) // so the host sees us before the first pointer
					if g.bot && !g.reliable {
						g.client.Send(chat{From: g.name, Text: "hello from a bot"})
					}
					if g.bot && g.reliable {
						for i := range 20 {
							g.sendChat(chat{From: g.name, Text: fmt.Sprintf("reliable line %d", i+1)})
						}
					}
				case *chat:
					g.say(m.From + ": " + m.Text)
				}
			}
			if g.client == nil {
				break
			}
		}
	}
	for _, ev := range g.udp.Poll() {
		switch ev.Kind {
		case network.Connected:
			if g.server != nil {
				g.peers[ev.From.String()] = ev.From
			}
		case network.Disconnected:
			delete(g.peers, ev.From.String())
		case network.Message:
			switch m := ev.Msg.(type) {
			case *pointer:
				g.points[m.Name] = *m
				if g.server != nil { // fan out to every other peer
					for key, addr := range g.peers {
						if key != ev.From.String() {
							g.udp.Send(addr, *m)
						}
					}
				}
			case *chat:
				g.receiveChat(m)
				if g.server != nil { // relay in order to every other peer
					for key, addr := range g.peers {
						if key != ev.From.String() {
							g.udp.SendReliable(addr, *m)
						}
					}
				}
			}
		}
	}
	x, y := in.Mouse()
	if float32(x) != g.mouseX || float32(y) != g.mouseY {
		g.mouseX, g.mouseY = float32(x), float32(y)
		me := pointer{Name: g.name, X: g.mouseX, Y: g.mouseY}
		g.points[g.name] = me
		if g.target != nil {
			g.udp.Send(g.target, me)
		}
		for _, addr := range g.peers {
			g.udp.Send(addr, me)
		}
	}
	return nil
}

splitHostPort splits an address at the last colon, which keeps IPv6 addresses in brackets intact.

func splitHostPort(s string) (host, port string, err error) {
	for i := len(s) - 1; i >= 0; i-- {
		if s[i] == ':' {
			return s[:i], s[i+1:], nil
		}
	}
	return s, "", fmt.Errorf("no port")
}

Every known pointer is drawn as a square with a name. The chat panel is a label per line, the draft panel a text field bound to a string by pointer, and the reliable panel reports the counters and udp.Stats(addr), the measured round trip and loss for one link.

Enter is read after the interface is built, so u.TextField has had this frame's input first. Input edges are latched for the whole frame, so a press is visible here whether or not an update ran.

func (g *game) Draw(ctx *engine.Context) error {
	gr := ctx.Gfx
	for name, p := range g.points {
		gr.FillRect(p.X-5, p.Y-5, 10, 10, gfx.RGB(255, 200, 80))
		gr.DrawText(g.font, name, p.X+8, p.Y-8, gfx.RGB(255, 220, 140))
	}
	u := g.ui
	u.Begin(ctx.Input, func() {
		u.Panel("Chat", ui.Rect{X: 16, Y: 16, W: 480, H: 380}, func() {
			for _, l := range g.lines {
				u.Label(l)
			}
		})
		u.Panel("", ui.Rect{X: 16, Y: 404, W: 480, H: 52}, func() {
			u.TextField("Type and press Enter", &g.draft)
		})
		if g.reliable {
			u.Panel("Reliable UDP", ui.Rect{X: 512, Y: 16, W: 192, H: 120}, func() {
				u.Label(fmt.Sprintf("Sent %d", g.sent))
				u.Label(fmt.Sprintf("Received %d", g.received))
				order := "in order"
				if !g.inOrder {
					order = "OUT OF ORDER"
				}
				u.Label(order)
				if g.target != nil {
					if s, ok := g.udp.Stats(g.target); ok {
						u.Label(fmt.Sprintf("rtt %.1fms loss %.0f%%", float64(s.RTT)/1e6, s.Loss*100))
					}
				}
			})
		}
	})
	if ctx.Input.KeyPressed(input.KeyEnter) && g.draft != "" {
		msg := chat{From: g.name, Text: g.draft}
		g.draft = ""
		g.say(msg.From + ": " + msg.Text)
		g.sendChat(msg)
	}
	return nil
}

main

TurnBased: true is the flag that changes the loop. Everything else is the usual configuration.

func main() {
	seconds := flag.Float64("seconds", 0, "exit after this many seconds")
	shot := flag.String("shot", "", "write a screenshot to this PNG")
	listen := flag.String("listen", "", "host on this address, like :7777")
	joinTo := flag.String("join", "", "connect to a host, like 192.168.1.5:7777")
	name := flag.String("name", "player", "your name")
	bot := flag.Bool("bot", false, "send a greeting once connected (20 numbered lines with -reliable)")
	reliable := flag.Bool("reliable", false, "send chat over UDP with SendReliable and count lines delivered in order")
	flag.Parse()
	err := engine.Run(engine.Config{Title: "Bunyip network", Width: 720, Height: 480, TurnBased: true},
		&game{seconds: *seconds, shot: *shot, listen: *listen, joinTo: *joinTo, name: *name, bot: *bot, reliable: *reliable})
	if err != nil {
		fmt.Fprintln(os.Stderr, "network:", err)
		os.Exit(1)
	}
}

What to try

  • Start a host and two clients and watch the host relay both the chat in Update and the pointers.
  • Run with -reliable -bot on a client: it sends twenty numbered lines through sendChat, and the host's panel counts them in order.
  • Drop TurnBased in main and compare the regular frame updates and CPU use with the event-driven loop.
  • Remove the SetOnActivity calls in Init and see messages arrive only when the mouse moves.
  • Send the pointer at a fixed rate from Update instead of on every move, which is what a game with many players does.

Source files

main.go

The whole directory on GitHub