RSS

Client-Server Architecture

How the Godot client and the Go server talk to each other

Isles of the Cloud Realm is an online game. Each player runs a client that connects to a central server, which owns the world.

I covered how players log in and get handed off to a server in Logging into a Realm. This post picks up with the client holding a signed token and about to connect to a game server. From there, I'll explain how the two sides communicate and what each one is responsible for.

Rendering diagram…

This is a standard client-server model (opens in new tab). The server is a single Go (opens in new tab) process that runs combat, gathering, movement, and the rest of the world state. Anything that needs to survive a restart, including characters, inventories, and experience, goes into PostgreSQL (opens in new tab). The Godot (opens in new tab) client renders the world, plays animations, and collects player input without deciding game state.

Real-time communication with WebSockets

Login works over HTTPS because it's a series of isolated questions. Once the player is in the world, that model stops working.

In a live game, the server needs to tell the client about events it never asked for: another player walked into view, an enemy attacked, a tree fell. Ordinary request-response HTTP can't deliver an event until the client makes another request. The client would have to poll "anything new?" several times a second, and even over a reused connection, every poll costs a request-response round trip and a set of HTTP headers, most of them to learn that nothing happened.

WebSockets (opens in new tab) solve this. More specifically, I use Secure WebSockets (wss://). A WebSocket connection starts as an ordinary HTTPS request with an Upgrade header. The TCP (opens in new tab) and TLS (opens in new tab) handshakes happen once up front. After the server agrees to upgrade, the connection stays open as a persistent, encrypted channel. Both sides can then send messages with only a few bytes of framing instead of full HTTP headers: the client sends "move north," the server sends "another player appeared," and neither side waits for a request-response cycle.

The rest of the game session runs over that single connection. Godot includes a WebSocketPeer class, which keeps the client implementation small:

func open_connection() -> void:
	_websocket_connection = WebSocketPeer.new()
	_websocket_connection.handshake_headers = PackedStringArray(
		["Authorization: Bearer " + Globals.session_token]
	)
	_websocket_connection.connect_to_url(Env.get_websocket_url())

func _process(_delta: float) -> void:
	_websocket_connection.poll()
	if _websocket_connection.get_ready_state() == WebSocketPeer.STATE_OPEN:
		while _websocket_connection.get_available_packet_count():
			_handle_websocket_message(
				_websocket_connection.get_packet().get_string_from_utf8()
			)

Every frame, the client polls the socket and processes whatever arrived.

Note

A common alternative for real-time multiplayer is a custom protocol over UDP (opens in new tab), which trades guaranteed delivery and ordering for the lowest possible latency. For a grid-locked, slower-paced RPG like this one, I favored simplicity. TCP provides a reliable, ordered byte stream while the connection remains healthy, so I don't have to manage packet loss, ordering, or reassembly myself.

How TCP (opens in new tab) actually pulls off those guarantees on top of an underlying network protocol that has none of them is great engineering, and well worth reading about (opens in new tab).

The server is the authority

The server is the only source of truth, because the client is code running on someone else's machine and can't be trusted. A player can attach a memory editor or write a custom client that speaks the same protocol. If the client could assert "I am standing at (40, 12, 1)," someone would claim to be inside a locked treasure room, looting chests without fighting through the dungeon. If the client could report how much damage an attack dealt, someone would modify it to one-shot every boss.

The client only expresses intent. It sends "move north" instead of "I moved north," and "chop this tree" instead of "I collected a log." The server validates the request, updates the world state, and tells nearby clients what changed.

A move request goes through several checks on the server:

  • Does a tile exist to the north?
  • Does the entity's height fit under whatever is above that tile?
  • Does the edge geometry between the two tiles allow passage? (Slopes only connect in specific directions.)
  • Is another entity already on that tile?
  • Is the player already mid-step? Moves arriving while a previous step is still in flight are rejected, capping movement speed regardless of client modifications.

If any check fails, the player doesn't move, and the server tells the client to snap back to its true position. This rarely happens in practice. The client mirrors these checks locally and catches invalid moves before sending them, so players only see a correction when the two sides disagree, such as when another player claims the tile first.

Messages

Messages in both directions are JSON (opens in new tab) objects, but their envelopes differ slightly. Client requests contain an action and data. Server messages add an actor identifying the entity the message concerns. The server already knows which player sent a request from the authenticated WebSocket, so the client doesn't send an actor.

A server-to-client move message looks like this:

{
  "actor": "player/01a077bf-e6db-71e8-8054-a386d958e1ba",
  "action": "move",
  "data": {
    "from": { "x": 41, "y": 12, "z": 1 },
    "to":   { "x": 41, "y": 11, "z": 1 }
  }
}
  • actor appears only on server messages and identifies the entity the message is about. Entity IDs use a type/identifier format, so the client can parse the type directly from the string. Players use their character's UUID; other entities receive a random identifier upon spawning.
  • action names the event or request.
  • data contains the payload for that action, with a schema specific to the action type.

Note

Messages are serialized (opens in new tab) as JSON for now. I plan to switch to protobuf (opens in new tab) before going to production, but the wire format doesn't affect the architecture. The message envelope and routing logic don't care how the bytes get packed. JSON is easier to work with during development because I can read it in logs and inspect it in Godot without compiling a schema.

On the server, an outbound message is a small struct that handles its own serialization:

type BaseMessage struct {
	ID     uint64
	Actor  interfaces.Entity
	Action actions.Action
	Data   any
}

type SerializedBaseMessage struct {
	Actor  string         `json:"actor"`
	Action actions.Action `json:"action"`
	Data   any            `json:"data,omitempty"`
}

The ID never reaches the wire. The server assigns it and uses it internally; it comes up again in the pub/sub section.

An Action is a typed string:

type Action string

const Move Action = "move"

type MoveData struct {
	From math.Vector3i `json:"from"`
	To   math.Vector3i `json:"to"`
}

Client to server

Client actions describe requests such as moving, interacting with an entity, or using an item. Here is a representative subset:

ActionWhat the player did
movePressed a movement key or clicked a tile (requesting a single step)
interaction_requestedClicked an entity to chop a tree, mine ore, or talk to an NPC
inventory_useUsed an item from their backpack (e.g., eating food)
inventory_moveDragged an item between slots or to a chest
crafting_startAsked to start a recipe at a crafting station
request_map_chunkRequested terrain data the client doesn't have cached

The client never sends "set position," "take damage," or "add item." It can only express intent.

Server to client

The server's vocabulary is much larger. Here is a subset used in the examples below:

GroupActions
Entity lifecyclespawn, despawn, appear, disappear
Movementmove, snap_to_coordinates
Statestate_change, health_loss, use_ability, die
Player updatesexperience_gained, inventory_slots_updated
Terrainmap_chunk_version, map_chunk_data, map_chunk_unsubscribed

Lifecycle events are split into two pairs. spawn and despawn mean an entity entered or left the world, such as when a player logs in or out. appear and disappear mean an existing entity entered or left the player's view. The client treats them differently (spawning plays a materialization animation, while appearing is instant), but the payload is the same: the entity's coordinates and a state object describing it.

When an Oak TreeOak Tree comes into view, the client receives:

{
  "actor": "tree/7QK3M9X2NHBW",
  "action": "appear",
  "data": {
    "at": { "x": 38, "y": 15, "z": 1 },
    "state": {
      "health": { "current_health": 30, "max_health": 30 },
      "state_machine": { "current_state": "available" },
      "variant": { "value": "oak" },
      "interactions": {
        "list": [
          { "slug": "chop", "title": "Chop", "max_distance": 1,
            "prerequisites": { "woodcutting_level": 7 } },
          { "slug": "inspect", "title": "Inspect", "max_distance": null }
        ]
      }
    }
  }
}

The server builds that state object from the entity's behaviors. Every entity is a BaseEntity with a list of attached behaviors for things like health, movement, inventories, and state machines. Each behavior with client-facing state contributes an entry: health comes from HealthBehavior, and variant comes from VariantBehavior. A behavior can also return data only to the entity's owner. Your inventory therefore appears in your own spawn message but not in the appear messages other players receive about you.

Every entity also runs a state machine on the server. When it transitions between states, nearby clients receive a state_change and update the entity's visuals, such as swapping an oak tree to a stump when it moves from available to depleted. How behaviors and those state machines are built is covered in Entities and Behaviors.

A round trip, end to end

When a player chops an oak tree, the exchange looks like this:

Rendering diagram…

The client sends only the initial request at the top. Everything after that is server coordination and downstream notifications. Tree damage and animation states go to nearby players, while experience and inventory updates go straight to the acting player. Pub/sub gets each message to the right clients.

Pub/sub: who hears what

Broadcasting every event to every connected client only works for small games. A player on one island doesn't need updates about an enemy patrolling on another, and sending those updates wastes bandwidth and CPU.

The server uses publish-subscribe (opens in new tab) messaging (pub/sub). Publishers send messages to a named topic without tracking who is listening. Subscribers register for topics and receive whatever gets published there.

Topics

The server uses three kinds of topics.

Each entity has a topic named after its ID (<id>). Behaviors publish actions here, including moves, health updates, and state changes.

Each entity also has a direct topic (<id>/direct) that only it subscribes to. The server uses this topic for messages with a single recipient, such as private inventory updates or interaction requests between entities.

Chunk topics cover map regions. The map is divided into 16x16 tile chunks using spatial partitioning (opens in new tab), which lets the server find nearby entities without scanning the entire map. Each chunk has its own topic.

Subscribing to what you can see

When an entity is placed on the map or changes position, it calculates a bounding box based on its view distance (18 tiles for players, matching the client's view distance) and subscribes to every chunk that box intersects. With 16-tile chunks, that's a 3x3 block most of the time (the current chunk and its eight neighbors), growing to 4x4 when the player stands near a chunk edge.

Rendering diagram…

Subscriptions update on every step. Most moves stay within the same chunk, leaving subscriptions unchanged. When a step crosses a boundary, the entity unsubscribes from chunks that fell out of range and subscribes to the newly visible ones.

Subscribing to a chunk immediately sends the new subscriber an appear message for each entity inside that chunk, so the players and resources already there show up right away. Unsubscribing sends a corresponding disappear message. The client never reasons about chunks to decide what's visible; entities appear and disappear as the player travels.

Publishing to where you are

When an entity takes an action, its behavior publishes to the entity's own topic. The map is the only subscriber to that topic. It inspects the coordinates on the message, determines which chunk (or chunks) contain those coordinates, and republishes the message to those chunk topics.

Rendering diagram…

Because a move includes both from and to coordinates, crossing a chunk boundary publishes to both chunks. Player 2 in the diagram above subscribes to both chunks and would receive the message twice. This is what the ID on BaseMessage is for: it's monotonically increasing (opens in new tab), so an entity can remember the IDs it has seen recently and drop the second copy.

Once a player entity receives a message from a chunk topic, it serializes the message and sends it down that player's WebSocket. The enemy's MoveBehavior doesn't know who is nearby, and the map doesn't track connected players. They only know which topics they publish to.

Appear and disappear as translations

That double publish has a second consequence. When an entity steps from a chunk a player isn't subscribed to into one they are, the player receives a move whose from tile they've never seen. From that player's perspective, the entity didn't move from somewhere visible; it appeared. The server catches this on delivery and rewrites the message:

if !entity.subscribedMapChunks.ContainCoordinates(moveData.From) {
	return &messages.BaseMessage{
		Actor:  message.GetActor(),
		Action: actions.Appear,
		Data: actions.AppearData{
			At:    moveData.To,
			State: message.GetActor().GetCurrentState(entity.GetEntityID()),
		},
	}
}

Similarly, a move heading outside subscribed chunks gets rewritten into a disappear. The client never calculates chunk boundaries; it receives explicit notifications when entities enter or leave view.

The client

The client holds no authoritative game logic. It turns incoming network messages into visuals, and player input into outbound messages.

Message routing

Every message received on the socket passes through a single function that parses the JSON and emits a Godot signal (opens in new tab) named message_received. The emitter doesn't know who is listening. Each client system that cares about server events connects to the signal and filters for the actions it handles: NetworkEntityManager handles entity lifecycles, MapChunkManager handles terrain updates, and the unit frame watches for health changes on the player.

Rendering diagram…

Other systems connect to message_received too, including trackers for experience and cooldowns. This diagram focuses on entity management, terrain streaming, and UI. A new UI element can react to network events by connecting another listener to the signal.

NetworkEntityManager

The NetworkEntityManager maintains a dictionary mapping entity IDs to scene nodes. Four actions modify this collection: spawn and appear instantiate a node, while despawn and disappear free it. All other messages targeting an entity ID are forwarded directly to that node:

func process_message(message: WebsocketInboundBaseMessage) -> void:
	match message.action:
		WebsocketMessageAction.SPAWN:
			_handle_spawned_message(message.actor, ...)
		WebsocketMessageAction.APPEAR:
			_handle_appeared_message(message.actor, ...)
		WebsocketMessageAction.DESPAWN:
			_handle_despawned_message(message.actor)
		WebsocketMessageAction.DISAPPEAR:
			_handle_disappeared_message(message.actor)
		_:
			_forward_message_to_entity(message.actor, message)

Every entity is the same entity

There is only one NetworkEntity scene in the project, and every entity in the world is an instance of it. Player characters, enemies, trees, and mining deposits all share the same script and base node.

NetworkEntity manages the parts every entity shares: occupying a tile, interpolating movement between tiles, facing directions, snapping to corrected coordinates, and delegating visual rendering to a child node. That child is the presentation node, which contains the type-specific logic.

When a spawn or appear message arrives, the manager extracts the type from the actor ID and loads the corresponding scene by convention:

func set_entity_type(type: String) -> void:
	var path: String = "res://src/entities/" + type + "/" + type + ".tscn"
	_presentation_node = ResourceLoader.load(path).instantiate()

An ID like tree/7QK3M9X2NHBW loads res://src/entities/tree/tree.tscn. Enemies work the same way: each enemy type loads its own dedicated .tscn with custom sprites and visual states.

These scenes extend NetworkEntityPresentationNode and implement a few hooks: picking sprite frames for facing and movement, animating health_loss, and responding to state_change events. The tree's presentation node displays a stump frame when "depleted" and shakes its canopy on damage, while an enemy's presentation node might play an attack animation on use_ability. None of these presentation nodes handle networking, coordinates, or interpolation.

This separation means I can attach an existing behavior to a new kind of entity without writing new client code. Give an NPC a HealthBehavior and it gets a health bar and damage numbers. What travels on the wire is fixed; the presentation node decides how it looks.

The client is also a controller

The other half of the client's role is converting player inputs into server requests.

The local player character is also a NetworkEntity. Its presentation node is identical to any other player: it loads the same scene, plays the same animations, and moves using the exact same interpolation logic and move messages as every other character on screen. The only difference is that its spawn message contains "you": true, which tells the manager to attach a PlayerController as a child node.

The PlayerController translates player input into messages. Pressing a WASD key queues a single-step move in that direction. Clicking a tile runs A* (opens in new tab) pathfinding locally to find a route, feeding each step to the server one by one. Clicking an entity selects its default interaction from the interactions list sent in its appear payload, walks within range if needed, and dispatches an interaction_requested message.

func _send_interaction_requested(entity: NetworkEntity, interaction: InteractionStateData) -> void:
	Globals.websocket_manager.send_message_to_websocket(
		WebsocketOutboundInteractionRequestedMessage.as_string(
			entity.get_entity_id(), interaction.slug
		)
	)

The UI acts as a controller, too. Dragging an item between inventory slots sends inventory_move. Choosing Destroy on an item sends inventory_destroy. Typing in chat sends send_chat_message.

Client-side pathfinding is just a convenience for navigation. The client mirrors the server's traversal rules locally so it can reject blocked steps immediately without a network round trip. But every step is still sent as an individual move request, and the server validates each one independently. When the two maps disagree, the server's version is the one that sticks.