Does my netcode truly work? + more questions and concerns. (More info in topic)

Godot Version

4.3

Background

Ok, these aren’t fully objective questions, or even fully questions at all.

However, I’m having some anxiety about whether or not my netcode truly works how I want it to. Do I REALLY know what’s going on under the hood?

For reference, I’m developing a prototype/MVP of a multiplayer PvP, third-person shooter that uses a dedicated server-to-client model. Where the client only sends input, while the server processes the true game state and sends that to the clients.

I’ve been spending the past 2-3 months learning Godot multiplayer architecture + the engine itself in order to create it. However, I’m having a problem with the way Godot abstracts multiplayer functionality.

I have a good amount of experience with other game engines, so I have no issue with the way Godot abstracts things like code (GDscript is amazing), nodes, rendering, lighting, etc. This is NOT the case for netcode.

Before I ask my questions I need to clarify something important:

I’m ready for my code to be imperfect, buggy, and flawed, because a broken game that gets fixed/improved is better than a perfect game that doesn’t exist.

With that being said, here’s my specific issue:

“Question”

The way I currently sync player actions is via RPCs. NOT the multiplayer synchronizer node. I explain why with one of my previous topics:

Basically, I use direct RPCs in order to directly control what gets synchronized when and by who.

Let’s look at my working multiplayer movement code for an example. The server is the authority of the player script, while the client has a separate script to control their input authority.

@rpc("any_peer","call_local","unreliable",0)
func client_move() -> void:
	@warning_ignore("unused_variable")
	var Server_Authorized = false
	
	if Player_ID == str(Client_Input.get_multiplayer_authority()):
		server_move.rpc(Input.get_vector("move_right", "move_left", "move_back", "move_forward"), true)

@rpc("any_peer","call_local","unreliable",0)
func server_move(input : Vector2, Server_Authorized : bool) -> void:
	if Server_Authorized == false: return
	if Client_Input.is_multiplayer_authority() == false: return
	
	var Movement_Direction = Camera_Rotation.basis * Vector3(input.x, 0, input.y).normalized()
	
	velocity.x = Movement_Direction.x * Movement_Speed
	velocity.z = Movement_Direction.z * Movement_Speed
	
	sync_player_position.rpc(self.global_position)

The client sends their movement input to a server authorized RPC, then sends that info to the player script’s _physics_process function. Which triggers a separate RPC that sends the movement to all other clients:

func _physics_process(delta) -> void:
	
	client_move.rpc()
	
	if Input.is_action_just_pressed("jump") \
	and Client_Input.is_multiplayer_authority():
		client_jump()
	
	apply_gravity(delta)
	
	move_and_slide()
@rpc("any_peer","call_remote","unreliable_ordered",0)
func sync_player_position(pos : Vector3) -> void:
	self.global_position = pos

THIS. CODE. WORKS. However…

…When I was trying to implement interpolation, I noticed I truly have no idea how many packets my RPCs are sending, when they were being sent, if they were successfully sent, if they’re synchronized with every other client, if my server authority was even working, and even more critical info.

I mean, I “understand” what’s happening. It still works. But I feel I need even more precision in order to make the most out of Godot’s network systems and my game’s networking requirements. Right now I feel I understand the abstraction more than the true networking systems at play.

These are rhetorical questions:

  • How will I implement server-side validation for networked hitscan/projectile/melee hit detection when I can’t create a reliable timestamp for packets and world state?
  • How will I implement interpolation if there’s no saved worldstates to interpolate from?
  • How do I know that server is actually the one in calling the shots? (Because if it was the movement of my client would be delayed by their latency, which it isn’t) (Since it isn’t, there’s no client prediction or anti-cheat measures either)

Luckily, there might be another way.

I was combing through Godot’s well written and maintained documentation, when I stumbled upon the PacketPeer, PacketPeerUDP, UDPSserver, and more. Which allow you to directly send packets and manager servers without RPCs or the multiplayer nodes.

It’s still technically an abstraction, and you lose the ability to conveniently use RPC arguments and the like. But this might be just what I need in order to have full control over network synchronization and build/maintain the necessary features for my game.

So after all this, here’s the REAL question(s):

  1. Should I take the leap and learn these raw multiplayer solutions? (I’m ready to get dirty, my codebase is tiny, and I got git so my code won’t get gone)
  2. If I do go with these raw solutions:
    - How can I replicate RPC arguments like mode, sync, transfer_mode, and transfer_channel?
    - How can I serialize objects/callables over the network to rid myself of the multiplayer spawner node? Do I pass a reference to a game file?
  3. Do you have any other misc networking advice?

Thank you in advance and I apologize if this is a loaded question.

architecture wise when you RPC some data, it gets put into a buffer. What happens next is typically handled by the ENet packet peer. It will pack your data to fit into packets with an MTU size which is typically 1500 bytes. I want to say that Godots main loop will typically “pull” the API once a frame, (1/60fps, ~16ms). I don’t have a full understanding but I would hope the ENet library will not fragment your data, but it could. A until of data being a single RPC. (Node path, function, and any parameters.)

That being said, whenever you call an RPC you also need to stamp a frame number. So you have to add an extra parameter for every RPC in your current layout. Your current setup has some disadvantages.
One, you cannot guarantee that all RPCs will be in the same frame. (If an RPC is lost, or arrives later you could end up in some weird situations, depending on your character complexity)
Two, having a frame count for every RPC will eat bandwidth.
Three, you will need to hold onto some history of input and having many action centric RPCs will make it very complicated to buffer input.

(This is one benefit of a MultiplayerSynchronizer as it forces you to write code that can aggregate all your data into one RPC (if it’s smaller then one MTU) that can all have one frame number. This is a data centric RPC vs an your action centric RPC)

The simplest way to keep track of time is to fix your game rate and periodically share your process frame. Then use that time when sending RPCs.

You cant, you first need to buffer state to start. This creates an inherent delay, which is unavoidable when using interpolation.

If the client doesn’t listen to the server and does its own thing, this is considered desync. And they will not see an accurate representation of the world. This will not effect other peers negatively who do listen to the server. Just make sure that you have RPCs set to authority were appropriate because any peer can send an RPC to any function that is defined as any_peer

You can still use RPC API with these nodes. RPC is Godots MultiayerAPI. Look at SceneMultiplayer (built for multiplayer nodes in mind) and MultiplayerAPIExtenstion.

Yes? It depends on how much work you want to do. Take a look at the ENet implementation which has lots of nice features, and see how much you want to do yourself.

2 Likes

I will address your rhetorical questions since I think they are interesting :smiley:

How will I implement server-side validation for networked hitscan/projectile/melee hit detection when I can’t create a reliable timestamp for packets and world state?

You have a lot of options for timestamps. Personally, I just keep a count of the number of physics steps I’ve run (There’s a built in count but I didn’t realize at the time lol). There’s also Time.get_ticks_msec(). You can just send the timestamp along as an rpc argument. Even if it’s technically redundant to include the timestamp in multiple rpcs, the extra bandwidth usage is negligible because the vast majority of bandwidth usage comes from the position updates that run every frame for every player.

How will I implement interpolation if there’s no saved worldstates to interpolate from?

Godot’s physics is annoying, you will have to write your own code to capture the current worldstate (you can include one of the timestamps I suggested above with your world data). This doesn’t have much to do with networking though.

How do I know that server is actually the one in calling the shots?

Because the server decides who spawns, who gets hit by bullets, and who gets points? I think over time you will feel more confident in your own code. If the server only accepts inputs from clients and isn’t doing some kind of rollback there isn’t much a client can do to cheat.

Should I take the leap and learn these raw multiplayer solutions? (I’m ready to get dirty, my codebase is tiny, and I got git so my code won’t get gone)

I’m not sure, from the problems you described, why you would do this? If you want to hyper-optimize bandwidth usage you would want to use raw UDP or ENET but you said yourself that you are trying to make a mvp. Another reason people don’t use the high level multiplayer is that they don’t like that it requires them to use identical nodepaths; which you did not say anything about. I think you would be better off implementing a timestamp system to solve your stated problems first and then, and only if necessary, optimizing bandwidth or adjusting the structure of your netcode.

2 Likes

Thank you so much for this reply. The reason I wanted to switch to raw UDP packet management was not due to optimization. I figured I could track the packets better if I sent them raw.

But now I see that a single RPC is a single packet and I only use RPCs to sync over the network. So I can track what gets sent when. Plus, I can also track the amount of time has passed using physics ticks. This is very valuable info.

NetworkManager.gd

extends Node

const TICK_RATE = 60 # Server ticks per second
const MS_PER_TICK = 1000 / TICK_RATE
const INTERPOLATION_OFFSET = 100 # ms

var world_state_buffer =
var last_world_state = {}
var server_clock = 0.0

class WorldState:
var timestamp: float
var player_states: Dictionary

func _init(ts: float):
    timestamp = ts
    player_states = {}

func _physics_process(delta: float):
if multiplayer.is_server():
server_clock += delta
process_server_tick()

func process_server_tick():
var world_state = WorldState.new(server_clock)

# Collect all player states
for player in get_tree().get_nodes_in_group("players"):
    world_state.player_states[player.name] = {
        "position": player.global_position,
        "velocity": player.velocity,
        # Add other relevant state
    }

# Store state and broadcast to clients
world_state_buffer.append(world_state)
broadcast_world_state.rpc(world_state.timestamp, world_state.player_states)

# Cleanup old states
while world_state_buffer.size() > 1000:  # Keep last ~16 seconds
    world_state_buffer.pop_front()

@rpc(“authority”, “call_remote”, “reliable”)
func broadcast_world_state(timestamp: float, state: Dictionary):
last_world_state = state

# Interpolate positions
for player_id in state:
    var player = get_node_or_null(str(player_id))
    if player and player.name != str(multiplayer.get_unique_id()):
        var target_pos = state[player_id]["position"]
        # Implement interpolation logic here
        player.global_position = lerp(player.global_position, target_pos, 0.3)

Client-side input handling

func send_player_input(input_vector: Vector2, actions: Dictionary):
if !multiplayer.is_server():
var timestamp = Time.get_ticks_msec()
process_input.rpc_id(1, timestamp, input_vector, actions)

@rpc(“any_peer”, “call_local”, “reliable”)
func process_input(timestamp: int, input_vector: Vector2, actions: Dictionary):
if !multiplayer.is_server(): return

var player = get_node_or_null(str(multiplayer.get_remote_sender_id()))
if !player: return

# Validate input
if !validate_input(input_vector, actions):
    return
    
# Process movement with server-side validation
player.process_movement(input_vector)

# Handle actions
for action in actions:
    if actions[action]:
        player.handle_action(action)

func validate_input(input_vector: Vector2, actions: Dictionary) → bool:
# Implement validation logic
if input_vector.length() > 1.0:
return false
return true

1 Like

Thank you so much for this reply!

This has me worried, but my player character isn’t that complex. Around 8 actions in total. Will I be able to remedy the frame delivery issue using raw UDP packets + a system to track it?

This is what I intended. As I want entity movement to be smooth and I can use client prediction in order to mask the delay.

This is great to know. And now that I understand the 1 RPC function = 1 packet, this makes things a lot easier to manage.

I wasn’t really looking for an outside library, more Godot’s own abstraction with the UDP/PacketPeer classes. I could be missing something, let me know.

This is an absolute goldmine of a reply. I’ll make sure to use this as reference when programming my netcode.

You shouldn’t need to track individual packets on either side of the network. The things you care about for interpolation are object positions and the timestamps of those positions. Even if you end up doing client side prediction and lag correction and you care about the relationship between client and server timestamps you will only really care about the timestamps of game state not the timestamps of packets.

1 Like

Understood.

I don’t know the class very well, but my guess its that it is a no-frills implementations. so you will have to define your own behaviors from the ground up. Enet is a UDP implementation with game features in mind.

Not to say you shouldn’t do it, just be aware it could only give you the bare essentials. leaving your free to define your own behaviors.

I spent a little time to mock out what happens in godot source code with the MultiplayerAPI and ENet packet peer.


2 Likes

Thanks for your responses, everyone. I’m going to edit this reply with my path forward as soon as I can. Then, I’ll mark it as the solution.

To round out my opinion more succinctly, it sounds like you’re trying to implement high level multiplayer features.

I think focusing on the UDP packet peer is not where you want to focus. You should focus on the multiplayer API. This is where you will get the most bang for your buck in terms of the behavior you want to do and achieve in general.

The packet peer is just a very low level packet handling class and is not really responsible for any high-level features (it just moves data), and the ENet implementation should suffice in most cases because it’s design philosophy was to provide the essential behavior for communicating packets effectively for games, leaving high level features to the developer.

The multiplayer API is where you want to imbue your packet organization and behaviors to the game scene. I would focus here until the packet peer cannot handle the packet organization you designed.

1 Like

The multiplayer API is the approach I’ve decided to take with the new knowledge this topic has blessed me with. I’ll elaborate in an edit to above reply when I get farther in.

Well it could be not so hard to add ALL data into one single RPC if using for instance:

var rpc_data:={data}
var bytes:=var_to_bytes(rpc_data)
bytes.compress(3)

then send it into rpc, since its not an image it will be compressed fast. Gzip can compress text data almost 1:1032 on max lvl compression. So you shouldn’t to worry about its size much

restore dict

var bytes:=packet.decompress_dynamic(-1, 3)
var restored_dict:Dictionary=bytes_to_var(bytes)
1 Like

@solver10 the data will be fragmented once it exceeds MTU limit of 1500 bytes. If the packet is unreliable it will be dropped if a fragment is lost, causing corruption. Reliable RPC will cause delay and stutter. It’s better to have smaller packets to squeeze into the MTU size.

1 Like