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
- Variables
- func ApplyMove(g *game.Game, id, code string) (string, error)
- func BindRelay(addr string) (net.Listener, error)
- func DecodeMove(g *game.Game, id, code string) (string, error)
- func EncodeInvite(inv Invite) (string, error)
- func EncodeLastMove(g *game.Game, id string) (string, error)
- func EncodeMove(g *game.Game, id, notation string) (string, error)
- func EncodeTranscript(id string, rs game.Ruleset, moves []Entry) (string, error)
- func GameDigest(id string) string
- func NewGameID() string
- func NormalizeAddr(addr string) string
- func PairingCode() string
- func PositionHash(g *game.Game) string
- func Serve(ctx context.Context, addr string) error
- func ServeOn(ctx context.Context, l net.Listener) error
- type Entry
- type Event
- type EventKind
- type GuestOptions
- type HostOptions
- type Invite
- type Listener
- type MoveCode
- type Relay
- type RelayStats
- type Resumable
- type Role
- type Session
- func Dial(ctx context.Context, addr string, opts GuestOptions) (Session, error)
- func HostOver(ctx context.Context, rw io.ReadWriter, opts HostOptions) (Session, error)
- func HostViaRelay(ctx context.Context, relayAddr, code string, opts HostOptions) (Session, error)
- func JoinOver(ctx context.Context, rw io.ReadWriter, opts GuestOptions) (Session, error)
- func JoinViaRelay(ctx context.Context, relayAddr, code string, opts GuestOptions) (Session, error)
- func Listen(ctx context.Context, addr string, opts HostOptions) (Session, error)
- type Snapshot
- type Tuning
Constants ¶
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 )
const ( DefaultKeepalive = 15 * time.Second DefaultHandshakeTimeout = 30 * time.Second )
Defaults for Tuning.
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.
const DefaultPort = "4270"
DefaultPort is the port a direct game uses when an address gives none.
const DefaultRelayPort = "4271"
DefaultRelayPort is the port a relay uses when an address gives none.
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.
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 ¶
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 ¶
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
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 ¶
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 ¶
EncodeInvite renders an invite as a code to paste to the opponent.
func EncodeLastMove ¶
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 ¶
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 ¶
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 ¶
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 ¶
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 ¶
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?".
Types ¶
type Entry ¶
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 ¶
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.
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 ¶
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.
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 ¶
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) Wait ¶
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.
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 (*Relay) Rooms ¶
Rooms reports how many rooms the relay is holding, which is the only thing it knows about the games it carries.
func (*Relay) Serve ¶
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.
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 ¶
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 ¶
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 ¶
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.
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.
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.