netplay

package
v0.2.1 Latest Latest
Warning

This package is not in the latest version of its module.

Go to latest
Published: Sep 1, 2026 License: MIT Imports: 22 Imported by: 0

Documentation

Overview

Package netplay carries a TwixT game between two machines.

The protocol is written once, over an io.ReadWriter, and each transport is a thin adapter that produces one:

  • direct TCP (tcp.go): one player listens, the other dials. Zero infrastructure, and it is also how a game runs over a Tailscale or WireGuard address, or through a forwarded port.
  • relay (relay.go): both players dial out to a third machine running the same binary, which pairs them by a short code. This is for the common case where neither end can accept an inbound connection. A relay is not trusted with the game: it sees every byte it carries, so both ends authenticate every frame with a key taken from the part of the pairing code the relay is never told. See auth.go for what that does and does not cover, and relay.go for what an operator can still see.
  • correspondence codes (code.go): no live connection at all. Each move becomes a short string the players paste to each other over any chat. Its failure modes are disjoint from the other two: it needs no socket, no relay and no reachability.

Every transport carries the same moves and the same position hashes, so a divergence between the two ends is caught the moment it happens rather than discovered several moves later.

Nothing that arrives from the other end is believed or printed as it stands. Every string field of an incoming message is bounded and filtered as it is decoded, in framer.read, because all of them can reach the player's terminal in an error message and a terminal acts on the control bytes in what it is asked to draw. sanitise.go says why that happens where it happens.

Index

Constants

View Source
const (

	// DefaultPairingWait is how long the first player waits in a room for the
	// second. It is generous because the player has to pass the code on by
	// hand, over whatever chat they use. A waiting client's socket is watched,
	// so this bounds only a client that is still there and still waiting.
	DefaultPairingWait = 10 * time.Minute
	// DefaultMaxRelayConnections bounds the relay's live sockets and handler
	// goroutines. Operators may lower it before Serve starts.
	DefaultMaxRelayConnections = 512
	// DefaultRelayIdleTimeout releases a paired room that carries no bytes.
	// Real sessions send keepalive frames well inside this window.
	DefaultRelayIdleTimeout = 2 * time.Minute
)
View Source
const (
	DefaultKeepalive        = 15 * time.Second
	DefaultHandshakeTimeout = 30 * time.Second
)

Defaults for Tuning.

View Source
const DefaultJoinTimeout = 5 * time.Second

DefaultJoinTimeout bounds one unproven connection's handshake on a hosting listener.

It is far shorter than DefaultHandshakeTimeout because there is no human in this exchange: the host writes its invitation the moment the socket opens, and a real opponent's client answers within a round trip. Anything that has not answered by now is a scanner, a mistyped address or a stalled socket.

View Source
const DefaultPort = "4270"

DefaultPort is the port a direct game uses when an address gives none.

View Source
const DefaultRelayPort = "4271"

DefaultRelayPort is the port a relay uses when an address gives none.

View Source
const MaxFrameSize = 1 << 20

MaxFrameSize bounds one payload. The largest message the protocol sends is a resync transcript, a few tens of bytes per move, so a megabyte is generous for any real game and still small enough that a hostile peer cannot make the receiver allocate freely.

View Source
const Version = 1

Version is the wire protocol version this build speaks. It appears in the header of every frame and again inside the handshake. The header copy lets a peer speaking a different version be refused before its payload is interpreted at all; the handshake copy is what produces the message the player sees.

Variables

View Source
var (
	// ErrProtocol marks a peer that is not following the protocol.
	ErrProtocol = errors.New("protocol error")
	// ErrVersion marks a peer speaking a different protocol version.
	ErrVersion = errors.New("protocol version mismatch")
	// ErrRuleset marks a peer playing by different rules.
	ErrRuleset = errors.New("ruleset mismatch")
	// ErrDiverged marks two ends whose games are no longer the same game.
	ErrDiverged = errors.New("the two games have diverged")
	// ErrRefused marks a game the opponent declined.
	ErrRefused = errors.New("the opponent refused the game")
	// ErrClosed is returned by a session that has already finished.
	ErrClosed = errors.New("the session is closed")
	// ErrBadCode marks a correspondence code that cannot be trusted.
	ErrBadCode = errors.New("bad move code")
	// ErrUnauthenticated marks a frame that did not come from the opponent, or
	// that did not arrive in the order the opponent sent it.
	ErrUnauthenticated = errors.New("the frame is not authenticated")
)

Errors the protocol reports. They are sentinels so that a UI, and the tests, can react to the kind of failure rather than matching message text.

Functions

func ApplyMove

func ApplyMove(g *game.Game, id, code string) (string, error)

ApplyMove plays the move a code carries on g. The move is tried on a copy first, so a code whose result does not match the opponent's leaves the game untouched rather than half advanced.

func BindRelay added in v0.1.1

func BindRelay(addr string) (net.Listener, error)

BindRelay opens a relay's listening socket. The address may be a bare port (":4271"), a host and port, or a bare host, which takes DefaultRelayPort.

Binding is a step of its own so that a caller can announce the relay after it holds the address rather than before it asks for it. That log line is what an operator — or a readiness probe watching for it — reads to know the relay is up, and a taken port or a host that does not resolve is exactly when the announcement must not appear.

func DecodeMove

func DecodeMove(g *game.Game, id, code string) (string, error)

DecodeMove returns the move a code carries, having checked that the code was made for the position g is in and that the move produces the position the opponent got. It does not modify g.

func EncodeInvite

func EncodeInvite(inv Invite) (string, error)

EncodeInvite renders an invite as a code to paste to the opponent.

func EncodeLastMove

func EncodeLastMove(g *game.Game, id string) (string, error)

EncodeLastMove returns the code for the move already played on g. This is the call a UI wants: the player makes their move on their own board, and this turns it into the string to paste to the opponent.

func EncodeMove

func EncodeMove(g *game.Game, id, notation string) (string, error)

EncodeMove returns the code for playing notation in the position g, which is not modified. The move is attributed to the side to move; after a resignation or draw message that came from the other side, use EncodeLastMove instead, which reads the side from the game's own record.

func EncodeTranscript

func EncodeTranscript(id string, rs game.Ruleset, moves []Entry) (string, error)

EncodeTranscript renders a whole game as a block of move codes, one per line. It is how a correspondence game is handed over in full: to start one from a position, or to rescue a live game whose connection cannot be re-established.

func GameDigest

func GameDigest(id string) string

GameDigest returns the digest a code carries for a game identifier. The identifier itself never travels: the digest is enough to tell games apart and costs four bytes.

func NewGameID

func NewGameID() string

NewGameID returns a fresh identifier for a correspondence game.

func NormalizeAddr

func NormalizeAddr(addr string) string

NormalizeAddr fills in DefaultPort when an address does not name a port, so that a player can type a bare host name or a bare port.

func PairingCode

func PairingCode() string

PairingCode returns a fresh pairing code for a relayed game. It is one string for the player to pass on, in two parts: the first names the room the relay pairs the two clients in, and the rest is the key they authenticate the game with and the relay never sees. It is written in dashed groups so that a player can read it out; case, the dashes and the characters that are misread by eye are all forgiven when it is typed back in.

func PositionHash

func PositionHash(g *game.Game) string

PositionHash returns a hash of the position: the board, whose turn it is, the result, and the two further pieces of state that decide what may legally happen next, namely whether the swap option has been used and whose draw offer is standing.

It is a function of the position alone and not of the moves that reached it, so two ends that reached the same board by different routes agree. It deliberately excludes the move count: the protocol carries the ply and a transcript digest separately, and folding history into a position hash would spoil the one job it has, which is answering "are we looking at the same board?".

func Serve

func Serve(ctx context.Context, addr string) error

Serve runs a relay on addr until ctx is cancelled.

func ServeOn

func ServeOn(ctx context.Context, l net.Listener) error

ServeOn runs a relay on a listener the caller bound, which is how a relay asked for port 0 can report its port before it starts serving.

Types

type Entry

type Entry struct {
	Side game.Player
	Move string
}

Entry is one line of the shared transcript: a move in the engine's notation together with the side that made it. The side is recorded because resign and the two draw messages do not name a player in notation, and any of the three may come from the side that is not to move.

func ApplyTranscript

func ApplyTranscript(g *game.Game, id, block string) ([]Entry, error)

ApplyTranscript plays a block of move codes onto g and returns the transcript it added.

The block is applied to a copy first and adopted only once every line of it has been accepted, so a block that goes wrong at its third line leaves g exactly as it was rather than two moves further on. That matters because the block came out of a paste: ApplyMove and checkMoveCode take the same care with a single code, and session.applyPeer with a single frame, so that a hostile or merely mistaken entry cannot advance the player's live game before it is known to be good.

type Event

type Event struct {
	Kind EventKind
	// Move is the move in the engine's notation, for EventMove.
	Move string
	// Err is the failure, for EventError.
	Err error
	// Text is a line fit to show the player.
	Text string
}

Event is one thing the opponent, or the connection, did.

type EventKind

type EventKind int

EventKind is what happened.

const (
	// EventConnected arrives once, first, when the handshake succeeded.
	EventConnected EventKind = iota
	// EventMove is a move the opponent made, in the engine's notation.
	EventMove
	// EventResign means the opponent resigned.
	EventResign
	// EventDrawOffer means the opponent offered a draw.
	EventDrawOffer
	// EventDrawAccept means the opponent accepted a standing draw offer.
	EventDrawAccept
	// EventDisconnected means the game is no longer connected. It is not by
	// itself a rules event: the game may be resumed, see Save.
	EventDisconnected
	// EventError is a protocol failure, including a detected divergence. The
	// session is finished when this arrives.
	EventError
)

The events a session reports.

func (EventKind) String

func (k EventKind) String() string

String names the event kind.

type GuestOptions

type GuestOptions struct {
	// Name is the local player's name, shown to the host.
	Name string
	// Rules, when set, is the ruleset this end insists on: if the host offers
	// anything else the game is refused, naming the difference. Left zero, the
	// host's ruleset is adopted.
	Rules game.Ruleset
	// Side, when set, is the side this end expects to be given. Left zero,
	// whichever side the host did not take is accepted.
	Side game.Player
	// Resume continues the game in the snapshot instead of starting a new one.
	Resume *Snapshot
	Tuning
}

GuestOptions configures the end that joins.

type HostOptions

type HostOptions struct {
	// Name is the local player's name, shown to the guest.
	Name string
	// Rules is the ruleset both ends will play by. Required.
	Rules game.Ruleset
	// Side is the side the host takes; the guest is given the other one.
	// Required, because the choice of side is the player's to make.
	Side game.Player
	// Resume continues the game in the snapshot instead of starting a new one.
	// The snapshot's ruleset and side win over the fields above.
	Resume *Snapshot
	Tuning
}

HostOptions configures the end that sets the terms of the game.

type Invite

type Invite struct {
	// ID identifies the game. Every move code carries its digest.
	ID string
	// Rules is the ruleset both players will use.
	Rules game.Ruleset
	// HostSide is the side the host took; the guest plays the other one.
	HostSide game.Player
	// HostName is the host's name, for the guest to see who invited them.
	HostName string
}

Invite is what a host must tell a guest before a correspondence game can start: which game, by which rules, with the host on which side.

func DecodeInvite

func DecodeInvite(code string) (Invite, error)

DecodeInvite reads an invite code. It rebuilds the ruleset from the flags and then checks that this build fingerprints it the way the host did, so two releases that do not agree about the rules are caught here rather than several moves later.

func NewInvite

func NewInvite(rs game.Ruleset, hostSide game.Player, hostName string) (Invite, error)

NewInvite returns an invite for a fresh game.

func (Invite) GuestSide

func (inv Invite) GuestSide() game.Player

GuestSide is the side the invited player takes.

type Listener

type Listener struct {
	// contains filtered or unexported fields
}

Listener accepts exactly one opponent for a hosted game.

Binding is a separate step from waiting so that a host who asked for port 0, or who wants to show the player the address to pass on, can read the address it actually got before it blocks for the opponent.

func Bind

func Bind(addr string) (*Listener, error)

Bind opens a listening socket for one game. The address may be a bare port (":4270"), a host and port, or empty for the default port on every interface.

func (*Listener) Addr

func (h *Listener) Addr() string

Addr is the address actually bound, which is what to show the opponent.

func (*Listener) Close

func (h *Listener) Close() error

Close gives up the listening socket. It is safe to call after Wait.

func (*Listener) Wait

func (h *Listener) Wait(ctx context.Context, opts HostOptions) (Session, error)

Wait accepts connections until one completes the game handshake, then stops listening. A scanner, a silent socket or a client speaking another protocol does not consume the address the host already gave its opponent.

The handshakes run concurrently and each is bounded by DefaultJoinTimeout, which is what stops a connection that says nothing from keeping the invited opponent out. One at a time, a silent socket owned the listener for a whole handshake timeout, and twenty of them owned it for twenty of those.

type MoveCode

type MoveCode struct {
	// Game is the digest of the game identifier. Compare it with GameDigest of
	// a saved game's identifier to find the game the code belongs to.
	Game string
	// Entries is how many entries the record held before this one, which is
	// what the code follows on from.
	Entries int
	// Side is the player who made the move.
	Side game.Player
	// Move is the move in the engine's notation.
	Move string
	// Before is the position hash the move must be applied to, shortened.
	Before string
	// After is the position hash the move produces, shortened.
	After string
}

MoveCode is what a correspondence code carries. Inspect returns one without needing a game, so a pasted code can be routed to the game it belongs to before anything is applied.

func Inspect

func Inspect(code string) (MoveCode, error)

Inspect reads a code without needing a game: it checks the checksum and the format and returns what the code says. Use it to find which saved game a pasted code belongs to.

type Relay

type Relay struct {
	// Wait is how long the first client of a room waits for the second. Zero
	// means DefaultPairingWait.
	Wait time.Duration
	// MaxConnections bounds waiting and paired client sockets together. Set it
	// before Serve starts; zero means DefaultMaxRelayConnections.
	MaxConnections int
	// IdleTimeout closes a paired connection after this long without bytes.
	// Set it before Serve starts; zero means DefaultRelayIdleTimeout.
	IdleTimeout time.Duration
	// Logf, when set, is where the relay reports what its operator needs to
	// see. Left nil, it logs through the standard logger. It is never given a
	// pairing code, a player name or an address: an operator needs enough to
	// tell abuse from popularity, and nothing about the games being carried.
	Logf func(format string, args ...any)
	// contains filtered or unexported fields
}

Relay pairs clients by room name and copies bytes between them.

It is an intermediary its users cannot authenticate, and it should be read as one. Everything it carries passes through it in plain text -- both players' names, the ruleset and every move -- so its operator reads the whole game. It cannot change the game: the two ends authenticate every frame with a key taken from the part of the pairing code the relay is never told, and a frame that does not authenticate is refused rather than played. What is left in the operator's hands is delivery, which nothing can check: a relay may drop a game or refuse to carry it. See auth.go for the shape of the key, and the block comment at the top of this file for the whole of it.

func NewRelay

func NewRelay() *Relay

NewRelay returns a relay with no rooms.

func (*Relay) Rooms

func (r *Relay) Rooms() int

Rooms reports how many rooms the relay is holding, which is the only thing it knows about the games it carries.

func (*Relay) Serve

func (r *Relay) Serve(ctx context.Context, l net.Listener) error

Serve accepts connections until ctx is cancelled or the listener fails. It closes l on the way out.

func (*Relay) Stats

func (r *Relay) Stats() RelayStats

Stats returns what the relay knows about its own traffic.

type RelayStats

type RelayStats struct {
	// Rooms is how many rooms the relay is holding now.
	Rooms int
	// Connections is how many client sockets are live now.
	Connections int
	// Paired is how many pairs the relay has put together since it started.
	Paired uint64
	// Abandoned is how many waiting clients stopped being usable before an
	// opponent arrived. This climbing while Paired does not is what somebody
	// exhausting the relay's rooms looks like.
	Abandoned uint64
	// RefusedBusy is how many connections were turned away because
	// MaxConnections was reached.
	RefusedBusy uint64
	// RefusedPrelude is how many connections never asked for a room in a form
	// the relay understands.
	RefusedPrelude uint64
}

RelayStats is a relay's account of the traffic it has carried. It names no player, room or address, because a relay that recorded those would be one its users had to trust with more than delivery.

type Resumable

type Resumable interface {
	Session
	// Snapshot returns everything needed to resume the game.
	Snapshot() Snapshot
	// Position returns a copy of the session's own view of the game, which the
	// protocol keeps in step with the opponent's.
	Position() *game.Game
}

Resumable is a session that can be carried over to a new connection after the old one dropped. Every session this package returns implements it; Save is the convenient way to reach it.

type Role

type Role int

Role says which end of a connection this is. The host sets the terms of the game: the ruleset, and which side it takes. The guest adopts the ruleset and is told which side it was given.

const (
	Host Role = iota
	Guest
)

The two ends of a game.

func (Role) String

func (r Role) String() string

String names the role.

type Session

type Session interface {
	// Side reports which side this end plays.
	Side() game.Player
	// Rules reports the ruleset both ends agreed on.
	Rules() game.Ruleset
	// OpponentName reports the name the other end gave.
	OpponentName() string
	// SendMove plays a move in the engine's notation and sends it.
	SendMove(notation string) error
	SendResign() error
	SendDrawOffer() error
	SendDrawAccept() error
	// Events returns the stream of things the opponent did. It is closed when
	// the session finishes.
	Events() <-chan Event
	// Close ends the session and the underlying connection.
	Close() error
}

Session is one end of a remote game. The caller owns its own game state; the session keeps a second copy in step with the opponent's and refuses anything that would let the two drift apart.

func Dial

func Dial(ctx context.Context, addr string, opts GuestOptions) (Session, error)

Dial joins a game hosted at addr. The address is whatever reaches the host: a machine on the same network, a tailnet or WireGuard address, or a forwarded port. Nothing here is specific to any of those.

func HostOver

func HostOver(ctx context.Context, rw io.ReadWriter, opts HostOptions) (Session, error)

HostOver runs the host end of the protocol over any transport. On failure the caller keeps ownership of rw; on success the session owns it and Close closes it. The context bounds the handshake only.

func HostViaRelay

func HostViaRelay(ctx context.Context, relayAddr, code string, opts HostOptions) (Session, error)

HostViaRelay hosts a game through a relay under the given pairing code, which the player must pass to the opponent whole: its first characters name the room the relay pairs the two clients in, and the rest is the key they authenticate the game with and the relay is never told.

It blocks until somebody joins the same room and proves they hold the same key. Somebody who does not hold it is not the opponent -- they learnt or guessed the room and nothing more -- so the room is claimed again rather than the game being handed to whoever got there first. What that does not undo is that they were sent the invitation before they were turned away, so they have seen the host's name and the ruleset.

func JoinOver

func JoinOver(ctx context.Context, rw io.ReadWriter, opts GuestOptions) (Session, error)

JoinOver runs the guest end of the protocol over any transport.

func JoinViaRelay

func JoinViaRelay(ctx context.Context, relayAddr, code string, opts GuestOptions) (Session, error)

JoinViaRelay joins a game through a relay using the pairing code the host gave out. The whole code is needed: without its key part this end could not tell the host's moves from a relay's.

func Listen

func Listen(ctx context.Context, addr string, opts HostOptions) (Session, error)

Listen binds addr and blocks until the opponent arrives. Use Bind when the bound address has to be shown first, as with port 0.

type Snapshot

type Snapshot struct {
	Role     Role
	Rules    game.Ruleset
	Side     game.Player
	Name     string
	Opponent string
	Moves    []Entry
}

Snapshot is what a dropped game needs to be picked up again on a new connection. Pass it back through HostOptions.Resume or GuestOptions.Resume.

func Save

func Save(s Session) (Snapshot, error)

Save returns the state a dropped session needs to be resumed. Pass it back through HostOptions.Resume or GuestOptions.Resume on the new connection.

type Tuning

type Tuning struct {
	// Keepalive is how often a ping goes out on an otherwise idle connection.
	Keepalive time.Duration
	// DeadAfter is how long a connection may carry no traffic at all before
	// the opponent is declared gone. It also bounds a single write, so a peer
	// that has stopped reading cannot block a move for ever.
	DeadAfter time.Duration
	// HandshakeTimeout bounds the handshake.
	HandshakeTimeout time.Duration
}

Tuning holds the timing knobs. Zero values mean the defaults, which suit human-paced play; the tests use short ones.

Jump to

Keyboard shortcuts

? : This menu
/ : Search site
f or F : Jump to
y or Y : Canonical URL