Commit
Theria
feat: add networked listen-server with authoritative state replication
modified CHANGELOG.md
@@ -26,6 +26,13 @@ protocol version.
### Added
- Networked multiplayer over a listen-server: one player hosts the authoritative
match and a second joins over the network, each driving their own hero while the
host simulates and broadcasts the world every tick. Peers exchange a protocol
version on connect and a mismatch is refused; an empty player slot is filled by
a bot. Launch with `-- --host` or `-- --join <address>`; the default remains a
single-machine game. This activates the netcode protocol version as a
compatibility axis.
- Combat heroes: the player's hero and the bot now auto-attack the nearest enemy
in range, so a hero can clear creep waves, pressure towers, duel the enemy hero,
and push a lane toward the nexus. Heroes spawn at their base fountain.
modified README.md
@@ -33,8 +33,8 @@ and a jungle to break each other's nexus.
The first milestone is a **walking skeleton**: one player-controlled hero and
one bot moving on the 3v3 arena under a server-authoritative, fixed-timestep
simulation. Online play, items, and the meta layer come later — the skeleton
exists to prove the authority model first.
simulation. With that authority model proven, networked play over a
listen-server now runs on top of it; items and the meta layer come later.
## Architecture
@@ -47,10 +47,13 @@ core is driven by:
resulting state;
- the bot (`src/bot`), which derives its command from the world state;
- the headless tests (`test/`), which replay scripted input and assert the
outcome.
outcome;
- the networked drivers (`src/net`), where a host simulates and broadcasts the
world and a client sends its input up and renders the snapshots it receives.
Because authority lives entirely in the simulation, networked play can be added
later as another driver without rewriting gameplay.
Because authority lives entirely in the simulation, networked play is just
another driver — a listen-server — added without rewriting gameplay. The host is
the sole authority; a client never simulates, it only renders what it is sent.
## Layout
@@ -58,8 +61,9 @@ later as another driver without rewriting gameplay.
| :------------- | :---------------------------------------------------- |
| `src/sim` | The authoritative simulation core and its data types. |
| `src/bot` | Bot input derived from the world state. |
| `src/net` | Listen-server transport and the client/server wire protocol. |
| `src/client` | Local input sampling and rendering. |
| `test/unit` | Headless tests of the simulation. |
| `test/unit` | Headless tests of the simulation and the wire protocol. |
| `scenes` | Godot scenes. |
## Running
@@ -72,6 +76,18 @@ godot --path .
Move the hero with **WASD** or the **arrow keys**; the bot walks toward it.
### Multiplayer
Pass arguments after `--` to choose a role; with neither, the game runs on a
single machine. One peer hosts and a second joins it:
```sh
godot --path . -- --host # host the match (you are team 0)
godot --path . -- --join 127.0.0.1 # join a host at an address (you are team 1)
```
The host is authoritative and fills any empty player slot with a bot.
## Testing
Tests run headless with [GUT](https://github.com/bitwes/Gut):
modified src/client/main.gd
@@ -1,18 +1,29 @@
extends Node2D
## Thin presentation + input driver for the v0.1 walking skeleton.
## Presentation + driver for the v0.1 match. It runs in one of three modes,
## selected from the command line (`-- --host`, `-- --join [address]`, or nothing
## for a single-machine game):
##
## It owns a SimCore and advances it one tick per physics frame (physics is
## pinned to the simulation's 60 Hz in project.godot). All authority lives in
## the simulation; this layer only samples local input, asks the bot for its
## command, and draws the resulting state — the static map geometry plus the
## live entities. Swapping this for a networked driver later does not touch the
## simulation.
## LOCAL — owns the authoritative SimCore and drives both heroes (player + bot),
## exactly the single-machine walking skeleton.
## HOST — the listen-server: owns the authoritative SimCore, drives team 0 from
## local input, hands team 1 to a remote client when one connects (a bot
## until then), and broadcasts a snapshot every tick.
## CLIENT — owns no simulation: it samples local input and sends it up, and draws
## whatever authoritative snapshot the server last sent down.
##
## All authority stays in SimCore; the transport lives in NetSession; the wire
## shaping lives in NetProtocol. This node only samples input, routes it, and
## draws the resulting state — the same `_draw` serves every mode.
enum Mode { LOCAL, HOST, CLIENT }
const HERO_SPEED := 320.0
const BOT_SPEED := 300.0
const HERO_TEAM := 0
const BOT_TEAM := 1
const DEFAULT_JOIN_ADDRESS := "127.0.0.1"
const HERO_COLOR := Color(0.36, 0.66, 1.0)
const BOT_COLOR := Color(1.0, 0.42, 0.38)
const ENTITY_RADIUS := 44.0
@@ -43,28 +54,166 @@ const CREEP_HP_BAR_OFFSET := Vector2(-35.0, -55.0)
const HP_BAR_BG := Color(0.0, 0.0, 0.0, 0.6)
const HP_BAR_FG := Color(0.4, 0.85, 0.4)
var _sim := SimCore.new()
var _mode: int = Mode.LOCAL
var _join_address := DEFAULT_JOIN_ADDRESS
## The authoritative simulation. Present in LOCAL and HOST; null on a pure CLIENT,
## which renders snapshots instead of simulating.
var _sim: SimCore = null
var _bot := BotController.new()
var _hero_id: int = 0
var _bot_id: int = 0
var _net: NetSession = null
## HOST: the peer id controlling team 1, or 0 while the slot is bot-filled.
var _team1_peer: int = 0
## CLIENT: set once the server has accepted our handshake.
var _joined: bool = false
## CLIENT: the latest authoritative world decoded from a snapshot, or null.
var _client_state: SimState = null
func _ready() -> void:
_sim.spawn_structures()
_hero_id = _sim.add_hero(HERO_TEAM, MapData.spawn_for_team(HERO_TEAM), HERO_SPEED)
_bot_id = _sim.add_hero(BOT_TEAM, MapData.spawn_for_team(BOT_TEAM), BOT_SPEED)
_configure_from_cmdline()
match _mode:
Mode.HOST:
_start_host()
Mode.CLIENT:
_start_client()
_:
_start_local()
queue_redraw()
func _physics_process(_delta: float) -> void:
var inputs := {
_hero_id: _sample_player_input(),
_bot_id: _bot.decide(_sim.state, _bot_id),
}
_sim.step(inputs)
match _mode:
Mode.HOST:
_tick_host()
Mode.CLIENT:
_tick_client()
_:
_tick_local()
queue_redraw()
# --- Mode setup -------------------------------------------------------------
func _configure_from_cmdline() -> void:
var args := OS.get_cmdline_user_args()
var i := 0
while i < args.size():
var arg := args[i]
if arg == "--host":
_mode = Mode.HOST
elif arg == "--join":
_mode = Mode.CLIENT
if i + 1 < args.size() and not args[i + 1].begins_with("--"):
_join_address = args[i + 1]
i += 1
i += 1
func _start_local() -> void:
_sim = SimCore.new()
_sim.spawn_structures()
_hero_id = _sim.add_hero(HERO_TEAM, MapData.spawn_for_team(HERO_TEAM), HERO_SPEED)
_bot_id = _sim.add_hero(BOT_TEAM, MapData.spawn_for_team(BOT_TEAM), BOT_SPEED)
func _start_host() -> void:
_start_local() # the authoritative world; team 1 is bot-filled until a client takes it
_net = _make_session()
var err := _net.start_host()
if err != OK:
push_error("failed to host on port %d: error %d" % [NetSession.DEFAULT_PORT, err])
return
_net.client_joined.connect(_on_client_joined)
_net.client_left.connect(_on_client_left)
print("hosting on port %d — team 0 is local, team 1 awaits a client" % NetSession.DEFAULT_PORT)
func _start_client() -> void:
_net = _make_session()
var err := _net.start_client(_join_address)
if err != OK:
push_error("failed to join %s: error %d" % [_join_address, err])
return
_net.joined_server.connect(_on_joined_server)
_net.rejected.connect(_on_rejected)
_net.server_left.connect(_on_server_left)
print("joining %s:%d" % [_join_address, NetSession.DEFAULT_PORT])
func _make_session() -> NetSession:
var net := NetSession.new()
net.name = "NetSession"
add_child(net)
return net
# --- Per-tick drivers -------------------------------------------------------
func _tick_local() -> void:
_sim.step({_hero_id: _sample_player_input(), _bot_id: _bot.decide(_sim.state, _bot_id)})
func _tick_host() -> void:
var team1_command: InputCommand
if _team1_peer != 0:
var remote := _net.input_for(_team1_peer)
team1_command = remote if remote != null else InputCommand.new()
else:
team1_command = _bot.decide(_sim.state, _bot_id)
_sim.step({_hero_id: _sample_player_input(), _bot_id: team1_command})
_net.broadcast_snapshot(_sim.state)
func _tick_client() -> void:
if _joined:
_net.send_input(_sample_player_input())
_client_state = _net.latest_state()
# --- Network event handlers -------------------------------------------------
func _on_client_joined(peer_id: int, team: int) -> void:
_team1_peer = peer_id
print("client connected: peer %d controls team %d" % [peer_id, team])
func _on_client_left(peer_id: int) -> void:
if peer_id == _team1_peer:
_team1_peer = 0
print("client disconnected: peer %d — team 1 reverts to a bot" % peer_id)
func _on_joined_server(team: int) -> void:
_joined = true
print("joined the server as team %d" % team)
func _on_rejected(reason: String) -> void:
push_error("the server refused the connection: %s" % reason)
get_tree().quit()
func _on_server_left() -> void:
push_error("lost the connection to the server")
get_tree().quit()
# --- Rendering --------------------------------------------------------------
## The world this client should draw: the decoded snapshot on a pure CLIENT, the
## authoritative simulation otherwise.
func _active_state() -> SimState:
return _client_state if _mode == Mode.CLIENT else _sim.state
func _draw() -> void:
_draw_map()
_draw_entities()
@@ -81,10 +230,13 @@ func _draw_map() -> void:
## Draws the live world: towers and nexuses as squares, mobile units as circles,
## each with an HP bar. Structures and units share one entity list, so they all
## come straight from the authoritative state.
## come straight from the authoritative state (local or received).
func _draw_entities() -> void:
for id in _sim.state.entities:
var entity: SimEntity = _sim.state.entities[id]
var state := _active_state()
if state == null:
return
for id in state.entities:
var entity: SimEntity = state.entities[id]
if entity.is_structure:
var size := NEXUS_SIZE if entity.is_nexus else TOWER_SIZE
draw_rect(Rect2(entity.position - size * 0.5, size), _team_color(entity.team), true)
added src/net/net_protocol.gd
@@ -0,0 +1,101 @@
class_name NetProtocol
extends RefCounted
## The wire contract between the authoritative server and its clients.
##
## Pure, engine-free serialization: it turns an InputCommand or a whole SimState
## into plain Variant data (Arrays / Dictionaries the high-level multiplayer layer
## encodes for us) and back, with no transport or rendering coupling. Keeping it
## here — separate from the socket layer in NetSession — is what lets the round
## trip be unit-tested headlessly, exactly like the simulation core.
##
## The server is authoritative: a client only ever *renders* a decoded snapshot,
## it never trusts its own copy. PROTOCOL_VERSION is the netcode compatibility
## axis — peers exchange it on connect and a mismatch is refused, so an old client
## cannot desync against a newer server. Bump it on any wire-shape change here.
const PROTOCOL_VERSION := 1
## Bit positions for the packed entity-flags field (slot 11 of an entity row).
const _FLAG_STRUCTURE := 1
const _FLAG_NEXUS := 2
const _FLAG_CREEP := 4
## Encodes one tick of intent for a single entity. Only the move direction is
## carried today; richer intent (abilities) extends this row without a reshape.
static func encode_input(command: InputCommand) -> Array:
return [command.move_dir.x, command.move_dir.y]
static func decode_input(data: Array) -> InputCommand:
var command := InputCommand.new()
command.move_dir = Vector2(data[0], data[1])
return command
## Encodes the full authoritative world into a snapshot: the tick, the winner,
## and every entity as a fixed-order row. Insertion order is preserved so the
## decoded state iterates identically to the server's — deterministic rendering.
static func encode_snapshot(state: SimState) -> Dictionary:
var rows: Array = []
for id in state.entities:
rows.append(_encode_entity(state.entities[id]))
return {"tick": state.tick, "winner": state.winner, "entities": rows}
## Rebuilds a SimState from a snapshot. The result is a render target, not a
## simulation: it carries no id allocator and is never stepped on the client.
static func decode_snapshot(data: Dictionary) -> SimState:
var state := SimState.new()
state.tick = data["tick"]
state.winner = data["winner"]
for row in data["entities"]:
state.add_entity(_decode_entity(row))
return state
## Fixed entity row, by slot:
## 0 id 1 team 2 pos.x 3 pos.y 4 move_speed 5 hp 6 max_hp
## 7 attack_damage 8 attack_range 9 attack_cooldown_ticks 10 cooldown
## 11 flags (structure|nexus|creep bitmask) 12 lane 13 waypoint_index
static func _encode_entity(entity: SimEntity) -> Array:
var flags := 0
if entity.is_structure:
flags |= _FLAG_STRUCTURE
if entity.is_nexus:
flags |= _FLAG_NEXUS
if entity.is_creep:
flags |= _FLAG_CREEP
return [
entity.id,
entity.team,
entity.position.x,
entity.position.y,
entity.move_speed,
entity.hp,
entity.max_hp,
entity.attack_damage,
entity.attack_range,
entity.attack_cooldown_ticks,
entity.cooldown,
flags,
entity.lane,
entity.waypoint_index,
]
static func _decode_entity(row: Array) -> SimEntity:
var entity := SimEntity.new(row[0], row[1], Vector2(row[2], row[3]), row[4])
entity.hp = row[5]
entity.max_hp = row[6]
entity.attack_damage = row[7]
entity.attack_range = row[8]
entity.attack_cooldown_ticks = row[9]
entity.cooldown = row[10]
var flags: int = row[11]
entity.is_structure = (flags & _FLAG_STRUCTURE) != 0
entity.is_nexus = (flags & _FLAG_NEXUS) != 0
entity.is_creep = (flags & _FLAG_CREEP) != 0
entity.lane = row[12]
entity.waypoint_index = row[13]
return entity
added src/net/net_protocol.gd.uid
@@ -0,0 +1 @@
uid://dqmoqepeh4hm7
added src/net/net_session.gd
@@ -0,0 +1,149 @@
class_name NetSession
extends Node
## The transport layer for the listen-server: it owns the ENet peer and the RPCs
## that carry input up to the authoritative server and snapshots back down.
##
## This is the only place the engine's networking is touched. All wire shaping
## lives in NetProtocol (pure, tested); all authority lives in SimCore (pure,
## tested). NetSession just moves bytes and tracks who is connected, so the
## untestable socket surface stays as thin as possible.
##
## Topology: one peer hosts (a listen-server) and is the multiplayer
## authority — peer id 1 — running the only real SimCore. A remote client renders
## the snapshots it receives and sends its input up; it never simulates. On
## connect the two exchange PROTOCOL_VERSION and a mismatch is refused, so an
## incompatible client can never feed or desync the authoritative world.
## Server: a verified client finished the handshake and now controls `team`.
signal client_joined(peer_id: int, team: int)
## Server: a client dropped; its slot reverts to a bot.
signal client_left(peer_id: int)
## Client: the server accepted us; we control `team`.
signal joined_server(team: int)
## Client: the server refused us (today: a protocol-version mismatch).
signal rejected(reason: String)
## Client: the server connection was lost.
signal server_left
## The walking skeleton seats one remote player (team 1); the host is team 0.
const DEFAULT_PORT := 8642
const MAX_CLIENTS := 1
const REMOTE_TEAM := 1
var is_server: bool = false
## Server: latest input per connected peer id. Held until superseded — an
## unreliable packet may be dropped, so the last known intent persists rather
## than snapping the unit to a halt on a single lost frame.
var _latest_inputs: Dictionary = {}
## Client: the most recent snapshot Dictionary, or empty until the first arrives.
var _latest_snapshot: Dictionary = {}
## Starts hosting on `port`. Returns OK or an ENet error; on success this peer is
## the multiplayer authority and runs the authoritative simulation.
func start_host(port: int = DEFAULT_PORT) -> Error:
var peer := ENetMultiplayerPeer.new()
var err := peer.create_server(port, MAX_CLIENTS)
if err != OK:
return err
multiplayer.multiplayer_peer = peer
is_server = true
multiplayer.peer_disconnected.connect(_on_server_peer_disconnected)
return OK
## Connects to a host at `address`:`port`. Returns OK or an ENet error. The
## protocol handshake runs once the transport connects.
func start_client(address: String, port: int = DEFAULT_PORT) -> Error:
var peer := ENetMultiplayerPeer.new()
var err := peer.create_client(address, port)
if err != OK:
return err
multiplayer.multiplayer_peer = peer
is_server = false
multiplayer.connected_to_server.connect(_on_connected_to_server)
multiplayer.server_disconnected.connect(func() -> void: server_left.emit())
return OK
func close() -> void:
if multiplayer.multiplayer_peer != null:
multiplayer.multiplayer_peer.close()
multiplayer.multiplayer_peer = null
_latest_inputs.clear()
_latest_snapshot = {}
# --- Server side ------------------------------------------------------------
## Broadcasts the authoritative world to every client. Called once per tick by
## the host driver, after the simulation has stepped.
func broadcast_snapshot(state: SimState) -> void:
_push_snapshot.rpc(NetProtocol.encode_snapshot(state))
## The last input received from `peer_id`, or null if none has arrived yet.
func input_for(peer_id: int) -> InputCommand:
return _latest_inputs.get(peer_id, null)
func _on_server_peer_disconnected(peer_id: int) -> void:
_latest_inputs.erase(peer_id)
client_left.emit(peer_id)
@rpc("any_peer", "call_remote", "reliable")
func _submit_hello(protocol_version: int) -> void:
var peer_id := multiplayer.get_remote_sender_id()
if protocol_version != NetProtocol.PROTOCOL_VERSION:
_reject.rpc_id(peer_id, "protocol_version")
(multiplayer.multiplayer_peer as ENetMultiplayerPeer).disconnect_peer(peer_id)
return
_accept.rpc_id(peer_id, REMOTE_TEAM)
client_joined.emit(peer_id, REMOTE_TEAM)
@rpc("any_peer", "call_remote", "unreliable_ordered")
func _push_input(data: Array) -> void:
_latest_inputs[multiplayer.get_remote_sender_id()] = NetProtocol.decode_input(data)
# --- Client side ------------------------------------------------------------
## Sends this tick's intent up to the server. A no-op before the handshake.
func send_input(command: InputCommand) -> void:
_push_input.rpc_id(1, NetProtocol.encode_input(command))
func has_snapshot() -> bool:
return not _latest_snapshot.is_empty()
## Decodes and returns the latest authoritative world, or null if none yet.
func latest_state() -> SimState:
if _latest_snapshot.is_empty():
return null
return NetProtocol.decode_snapshot(_latest_snapshot)
func _on_connected_to_server() -> void:
_submit_hello.rpc_id(1, NetProtocol.PROTOCOL_VERSION)
@rpc("authority", "call_remote", "reliable")
func _accept(team: int) -> void:
joined_server.emit(team)
@rpc("authority", "call_remote", "reliable")
func _reject(reason: String) -> void:
rejected.emit(reason)
@rpc("authority", "call_remote", "unreliable_ordered")
func _push_snapshot(data: Dictionary) -> void:
_latest_snapshot = data
added src/net/net_session.gd.uid
@@ -0,0 +1 @@
uid://b10vdxvcpl23d
added test/unit/test_net_protocol.gd
@@ -0,0 +1,77 @@
extends GutTest
## Round-trip checks on the wire protocol. These run headless and free of any
## socket or engine-networking coupling: they exercise the exact encode/decode
## the live server and client use, so a snapshot that survives the trip here
## renders identically on a real client. The transport itself (NetSession) is an
## ENet surface verified by the headless host smoke, not these unit tests.
func test_protocol_version_is_pinned() -> void:
# The netcode compatibility axis. A wire-shape change must bump this in the
# same commit; this guard makes an accidental drift fail the suite.
assert_eq(NetProtocol.PROTOCOL_VERSION, 1)
func test_input_round_trips() -> void:
var command := InputCommand.new()
command.move_dir = Vector2(-1.0, 0.5)
var restored := NetProtocol.decode_input(NetProtocol.encode_input(command))
assert_eq(restored.move_dir, command.move_dir, "the move direction survives the trip")
func test_a_populated_snapshot_round_trips_every_field() -> void:
var state := _populated_state()
var restored := NetProtocol.decode_snapshot(NetProtocol.encode_snapshot(state))
assert_eq(restored.tick, state.tick, "the tick is carried")
assert_eq(restored.entities.size(), state.entities.size(), "every entity is carried")
for id in state.entities:
var original: SimEntity = state.entities[id]
var copy: SimEntity = restored.get_entity(id)
assert_not_null(copy, "entity %d survives the trip" % id)
if copy == null:
continue
assert_eq(copy.id, original.id)
assert_eq(copy.team, original.team)
assert_eq(copy.position, original.position)
assert_eq(copy.move_speed, original.move_speed)
assert_eq(copy.hp, original.hp)
assert_eq(copy.max_hp, original.max_hp)
assert_eq(copy.attack_damage, original.attack_damage)
assert_eq(copy.attack_range, original.attack_range)
assert_eq(copy.attack_cooldown_ticks, original.attack_cooldown_ticks)
assert_eq(copy.cooldown, original.cooldown)
assert_eq(copy.is_structure, original.is_structure)
assert_eq(copy.is_nexus, original.is_nexus)
assert_eq(copy.is_creep, original.is_creep)
assert_eq(copy.lane, original.lane)
assert_eq(copy.waypoint_index, original.waypoint_index)
func test_snapshot_preserves_entity_order() -> void:
# Insertion order keeps server and client iteration identical, which keeps
# rendering and any future client-side logic deterministic.
var state := _populated_state()
var restored := NetProtocol.decode_snapshot(NetProtocol.encode_snapshot(state))
assert_eq(restored.entities.keys(), state.entities.keys())
func test_snapshot_carries_the_winner() -> void:
var state := SimState.new()
state.winner = 1
var restored := NetProtocol.decode_snapshot(NetProtocol.encode_snapshot(state))
assert_eq(restored.winner, 1, "a decided match is carried so the client can show it")
assert_true(restored.is_match_over())
## A representative world — structures, both heroes, and a creep — advanced a few
## ticks so positions, cooldowns, and hp carry non-default values for the trip.
func _populated_state() -> SimState:
var sim := SimCore.new()
sim.spawn_structures()
sim.add_hero(0, MapData.spawn_for_team(0), 320.0)
sim.add_hero(1, MapData.spawn_for_team(1), 300.0)
sim.add_creep(0, 0, MapData.lane_path(0, 0)[0])
for _i in 5:
sim.step({})
return sim.state
added test/unit/test_net_protocol.gd.uid
@@ -0,0 +1 @@
uid://3qi60dko41fx