Designing Client-Server Architecture for Singleplayer and LAN Multiplayer

Godot Version

Godot 4.4 beta 3

Question

Hi everyone,

I’m working on a client-server game in Godot 4, but I’m using Godot’s low level networking system (TCPServer, PacketPeerStream & StreamPeerTCP) instead. I’m finding that while the client-side (visuals, input) is relatively straightforward, structuring the server-side logic and handling “remote” versions of client-side objects is quite difficult to design.

The setup that I’m designing currently looks something like this (with all the tcp networking taken out first for simplicity):

  • Client Scene: This is a standard Godot scene that handles all the visual representation of the game, animations and user input. It’s also the root node for the game
class_name Client extends Node2D

##Handles network communication
var network_layer: NetworkLayer

func _ready() -> void:
	# Register cliient-related items
	CommonRegister.register_client()
	
	# Create network_layer
	network_layer = NetworkLayerIntegrated.new()
	network_layer.data_received.connect(_on_data_received)
	add_child(network_layer)
	
	# Connect the integrated network layer to the local server
	if network_layer is NetworkLayerIntegrated:
		# Create and add a server instance directly to the client scene
		var server := Server.new()
		add_child(server)
		
		# Attach the server to the network layer to communicate to
		@warning_ignore("unsafe_method_access")
		network_layer.attach_local_server(server)

	# Connect to server via network_layer
	network_layer.connect_to_server({"username":"ExampleName"})

func _on_data_received(packet_name: String, packet_data: Dictionary) -> void:
	pass
  • NetworkLayer (Abstract): A base class for network communication.
class_name NetworkLayer extends Node
##Base network layer class defining common interface

var port := 3115
var address := "127.0.0.1"

##Receive packet data FROM server
@warning_ignore("unused_signal")
signal data_received(packet_name: String, packet_data: Dictionary)

##Unique Player ID generated by server
var my_player_id: String

##Connect to server
func connect_to_server(_user_data: Dictionary) -> void:
	pass

##Send packet data TO server
func send_data(_packet_name: String, _packet_data: Dictionary) -> void:
	pass 
  • Integrated NetworkLayer: Manages client-server communication if client and server are running locally
class_name NetworkLayerIntegrated extends NetworkLayer
##Integrated network implementation for direct local server communication

var _server: Server
func connect_to_server(user_data: Dictionary) -> void:
	var username: String = user_data["username"]
	my_player_id = generate_player_id(username)
	_server.connection_request(my_player_id, user_data)

func send_data(packet_name: String, packet_data: Dictionary) -> void:
	_server._on_data_received(my_player_id, packet_name, packet_data)

func generate_player_id(username: String) -> String:
	return username.sha256_text()

func attach_local_server(server_: Server) -> void:
	_server = server_
	_server.send_data_to_player.connect(_on_server_data_send)

func _on_server_data_send(player_id: String, packet_name: String, packet_data: Dictionary) -> void:
	if player_id == my_player_id:
		data_received.emit(packet_name, packet_data)
	else:
		# Handle network communication here like `put_var(var)`
		pass
  • Connection NetworkLayer: Manages Client to Server communication over a network. Mostly empty since I’ve stripped out most of the network communication for the sake of simplicity.
class_name NetworkLayerConnection extends NetworkLayer
##Network Layer for actual network communication to a server

##Connect to server
func connect_to_server(user_data: Dictionary) -> void:
	# Handle network communication here like `put_var(var)`
	pass

##Send packet data TO server
func send_data(packet_name: String, packet_data: Dictionary) -> void:
	# Handle network communication here
	pass

  • Server Instance: A separate object that runs the server logic.
class_name Server extends Node

signal send_data_to_player(player_id: String, packet_name: String, packet_data: Dictionary)

var world_manager: WorldManager

var global_player_list: Dictionary[String, PlayerData] = {}

func _ready() -> void:
	# Register server-related items
	CommonRegister.register_server()

func connection_request(player_id: String, _request_data: Dictionary) -> void:
	if player_id in global_player_list:
		printerr("Duplicate ids connection request")
		return
	
	global_player_list[player_id] = PlayerData.new(player_id)
	player_join_world(player_id, "test_map")

func get_player(player_id: String) -> PlayerData:
	return global_player_list.get(player_id, null)

func send_data(player_id: String, packet_name: String, packet_data: Dictionary) -> void:
	send_data_to_player.emit(player_id, packet_name, packet_data)

func _on_data_received(sender_id: String, packet_name: String, packet_data: Dictionary) -> void:
	pass

The problem I’m running into is that the server needs to maintain its own authoritative representation of the game world. I need to create server-side equivalents (“remote” versions) of the client-side objects (players, enemies, etc.) to track their state and enforce game rules.

I’ve been thinking about different approaches, but I’m not sure what the best practice is for structuring these “remote” objects.

Here are my questions/concerns:

  1. How should I structure the server-side “remote” versions of my entities? Should I be duplicating the entire scene graph on the server, or is there a more efficient way to represent the game state?
  2. What’s the best way to keep the client and server synchronized? Should I send full state updates, delta updates, or a combination of both?
  3. How do I avoid tight coupling between the client-side visuals and the server-side logic? I want to keep the client as a “dumb” renderer that simply displays the state it receives from the server.

If you have any code examples to help me or to visualize your understanding of the problem I would greatly appreciate it

Here’s some approaches I’ve found out so far:

  • Separate “Game Entity” Class: Create a base class (or script) that defines the common properties and behaviors of all my game entities (players, enemies, items, etc.). This class would not include visual or rendering code.
  • Client-Side: Visual Representation: A Godot Node that handles the visual representation of the entity. It receives data from the network and updates its appearance accordingly.
  • Server-Side: Logic and State: A script that inherits from the “Game Entity” class and handles the game logic and state of the entity on the server. It receives input from clients, updates its state, and sends updates to clients.

I would really appreciate any guidance, suggestions, or examples you can provide. I understand that using low-level networking without godot’s RPC is more complex, but I want to gain a deeper understanding of the underlying network architecture.

Thanks in advance! :sweat_smile:

I think the specific design decisions really depend on what kind of game you are making.

Some clients/servers (especially for faster paced FPS type stuff) will run a simulation of sorts on both clients and servers. These games should share a lot of code because all instances run the exact same simulation. I can imagine something like a strategy game might make the client really dumb/visual and keep logic on the server.

You’ve asking many questions but I think generally you should try to avoid duplicating code. Any time the client and server use similar behavior or logic you should ask yourself whether they could share that code (or data, i.e. scene structure, resources). This will save you an immense amount of time when you inevitably want to change that shared behavior. Similarly, I suspect you could delete the integrated NetworkLayer and just run the connection NetworkLayer locally instead.

You can also strip out audiovisual resources from your server builds which will remove the extra cost of using those nodes in a server. Exporting for dedicated servers — Godot Engine (stable) documentation in English