Hi! In this post, I want to walk through my senior research project for CSC 499 (Senior Research Methods) at Shippensburg University, mentored by Dr. Girard: NodeStrike, a controlled performance comparison between Godot’s built-in ENet networking stack and a custom C++ UDP dedicated server.

If you read my previous post on Godot and UDP with Python, you know I’ve been experimenting with low-level networking in Godot. This project takes that exploration much further into a controlled, empirical benchmark.


1. The Core Problem: Engine Overhead vs. Convenience

When developing a multiplayer game in Godot, developers face two main architectural choices:

  1. Godot’s Built-in ENet Stack (ENetMultiplayerPeer): Godot comes with high-level multiplayer abstractions built on ENet. You get Remote Procedure Calls (RPCs), scene replication, connection management, and automatic packet reliability out of the box. However, running a dedicated server requires exporting a headless version of your Godot project. While graphics and audio are stripped out, the server is still running an instance of the Godot engine process—meaning the scene tree, physics ticks, and engine subsystems are still executing in the background.

  2. Custom UDP Dedicated Server (C++): Godot also provides low-level networking primitives like PacketPeerUDP. This allows developers to bypass ENet completely and communicate with an external dedicated server over raw UDP sockets. A custom C++ server carries zero engine overhead: it runs only the networking loop and authoritative game state. But the trade-off is clear—you have to write your own packet serialization, client registry, heartbeats, and connection eviction from scratch.

The Question

Developers often assume a custom C++ server will drastically outperform Godot’s headless ENet server, but there is virtually no published empirical data comparing them under identical workloads.

Does a custom C++ UDP server actually yield meaningful performance gains over Godot ENet, and at what client scale does the gap become significant?

That is what NodeStrike is designed to answer.


2. System Architecture

To ensure the benchmark is strictly controlled, both backends share the exact same Godot client running in a peer-to-server topology. The client can toggle between ENet and raw UDP transports without altering any gameplay code.

flowchart TD subgraph ClientLayer["Shared Godot Client (NodeStrike)"] CL_Logic["Game Logic & Bot Movement"] CL_UDP["PacketPeerUDP (Raw UDP)"] CL_ENet["ENetMultiplayerPeer (High-Level API)"] CL_Logic --> CL_UDP CL_Logic --> CL_ENet end subgraph BackendCPP["Custom C++ Backend"] CPP_Sock["POSIX UDP Socket (recvfrom / sendto)"] CPP_Reg["Client Registry (IP & Port Map)"] CPP_Loop["Single-Threaded Tick Loop (20 Hz)"] CPP_Sock --> CPP_Reg CPP_Reg --> CPP_Loop end subgraph BackendENet["Godot Headless ENet Backend"] ENet_Peer["ENetMultiplayerPeer"] ENet_API["MultiplayerAPI (RPC & Sync)"] ENet_Engine["Headless Godot Runtime (Physics/Scene)"] ENet_Peer --> ENet_API ENet_API --> ENet_Engine end CL_UDP <-->|Binary UDP Packets| CPP_Sock CL_ENet <-->|ENet Protocol / RPC| ENet_Peer classDef client fill:#3b82f6,stroke:#1d4ed8,color:#fff classDef cpp fill:#10b981,stroke:#047857,color:#fff classDef enet fill:#8b5cf6,stroke:#6d28d9,color:#fff class CL_Logic,CL_UDP,CL_ENet client class CPP_Sock,CPP_Reg,CPP_Loop cpp class ENet_Peer,ENet_API,ENet_Engine enet

Architectural Components

Component Backend / Layer Role
ENetConnection / ENetMultiplayerPeer ENet Backend Low-level host wrapper and Godot high-level multiplayer bridge.
MultiplayerAPI ENet Backend Handles RPCs and scene synchronization across connected peers.
PacketPeerUDP Shared Client Directly sends and receives binary UDP datagrams without engine abstraction.
socket / bind / recvfrom / sendto C++ Backend Direct POSIX socket operations for low-latency non-blocking I/O.
Client Registry C++ Backend In-memory map from client IP:Port to player state, sequence numbers, and heartbeat timestamps.

3. The NodeStrike Workload

To isolate networking overhead from gameplay complexity, the test workload is a lightweight 2D objective-collection game named NodeStrike.

  • Grid Map: Players and objectives occupy discrete grid positions.
  • Bot-Driven Movement: Clients run automated bot logic that calculates the Manhattan distance to the nearest spawned objective node and steps toward it each tick.
  • Synchronized State: The only state sent over the wire is player positions, goals, and timestamps. No large physics snapshots or bloated payloads.

Here is the core client tick from the Godot client:

func _game_tick() -> void:
    tick_count += 1
    _move_bot()
    _send_state()

func _move_bot() -> void:
    if local_x == goal_x and local_y == goal_y:
        _new_goal()
        return
    var dx := goal_x - local_x
    var dy := goal_y - local_y
    if abs(dx) >= abs(dy):
        local_x += (1 if dx > 0 else -1)
    else:
        local_y += (1 if dy > 0 else -1)

func _send_state() -> void:
    var pkt := PackedByteArray()
    pkt.resize(14)
    pkt[0] = PKT_STATE
    pkt[1] = local_id
    pkt.encode_u16(2, local_x)
    pkt.encode_u16(4, local_y)
    pkt.encode_u32(6, Time.get_ticks_msec())
    pkt.encode_u16(10, goal_x)
    pkt.encode_u16(12, goal_y)
    udp.put_packet(pkt)

By keeping the payload deterministic and lightweight (14 bytes per state packet), any variance in latency or throughput between backends is directly attributable to the networking layer itself.


4. Custom UDP Server & Binary Protocol

Packet Protocol

The custom C++ server uses a fixed, little-endian binary layout:

Upstream Packets:
  JOIN       : 0x01                                  (1 Byte)
  STATE      : 0x02 | id | x16 | y16 | ts32 | gx16 | gy16 (14 Bytes)
  HEARTBEAT  : 0x03 | id                             (2 Bytes)
  DISCONNECT : 0x04 | id                             (2 Bytes)

Downstream Packets:
  ACK        : 0x01 | id                             (2 Bytes)
  TICK       : 0x02 | count | [id | x16 | y16 | gx16 | gy16] x N  (2 + 9N Bytes)
  EVICT      : 0x05 | id                             (2 Bytes)

Server Loop & Client Eviction

The C++ server runs a single-threaded event loop with select() and non-blocking recvfrom(). Single-threaded operation eliminates lock contention on the client registry and keeps timing deterministic:

int sock = socket(AF_INET, SOCK_DGRAM, 0);
sockaddr_in srv{};
srv.sin_family = AF_INET;
inet_pton(AF_INET, bind_ip.c_str(), &srv.sin_addr);
srv.sin_port = htons(port);
::bind(sock, (sockaddr*)&srv, sizeof(srv));

while (running) {
    struct timeval tv{0, 5000}; // 5ms select timeout
    fd_set fds; 
    FD_ZERO(&fds); 
    FD_SET(sock, &fds);

    if (select(sock + 1, &fds, nullptr, nullptr, &tv) > 0) {
        int n = (int)recvfrom(sock, buf, sizeof(buf), 0, (sockaddr*)&cli, &cli_len);
        if (n > 0) {
            handle_incoming_packet(cli, buf, n);
        }
    }

    // Check 20 Hz tick timer for state broadcast
    broadcast_tick_if_due();

    // Evict clients that haven't sent heartbeats within 5 seconds
    sweep_stale_clients();
}

Eviction uses a 5-second silence threshold matching ENet’s default timeout:

auto now = std::chrono::steady_clock::now();
for (auto it = players.begin(); it != players.end(); ) {
    double age = std::chrono::duration<double>(now - it->second.last_seen).count();
    if (age > 5.0) {
        log_event("EVICT: " + it->first);
        it = players.erase(it);
    } else {
        ++it;
    }
}

5. The Experiment: How Performance Will Be Measured

Independent and Dependent Variables

  • Independent Variables:
    • Backend type: Custom C++ UDP Server vs. Godot ENet Headless Server.
    • Client scale: 1, 5, 10, 20, and 50 concurrent headless clients (with higher counts tested against C++ once ENet limits are established).
  • Dependent Variables:
    • Round-trip latency (median, 95th, and 99th percentile in milliseconds).
    • Packet handling throughput (packets per second).
    • Packet loss percentage.
    • Bandwidth consumption (MB/s).
    • Client capacity threshold prior to degradation.

Testing Harness

Note: The benchmark now runs on Google Cloud Platform (GCP) using VM fleets rather than Docker Compose—see the Execution post for details.

The original design used Docker Compose to scale headless Godot client containers:

flowchart LR subgraph Host["Benchmark Host Machine"] subgraph Harness["Docker Compose Harness"] C1["Client 1 Container"] C2["Client 2 Container"] CN["Client N Container..."] end Srv["Server Under Test (ENet or C++)"] WS["Wireshark / Packet Capture"] Vol["Shared Volume (metrics.csv)"] C1 <--> Srv C2 <--> Srv CN <--> Srv C1 -.-> Vol C2 -.-> Vol CN -.-> Vol WS -.-> Vol end classDef host fill:#f3f4f6,stroke:#9ca3af,color:#000 classDef container fill:#38bdf8,stroke:#0284c7,color:#000 classDef srv fill:#f87171,stroke:#dc2626,color:#fff classDef log fill:#facc15,stroke:#ca8a04,color:#000 class C1,C2,CN container class Srv srv class WS,Vol log
  • Test Budget: 240 total testing minutes (5 client counts × 8 minutes per step × 3 repetitions × 2 backends).
  • Statistical Analysis: Two-tailed Welch’s t-test on median latencies and throughput distributions ($p < 0.05$).

6. Hypotheses

  • Null Hypothesis ($H_0$): There will be no statistically significant difference in latency, packet throughput, or packet loss between Godot ENet and the custom C++ UDP server across all client counts (1 to 50).
  • Alternative Hypothesis ($H_1$): Both backends will perform comparably at low client counts (1 to 5), but the C++ UDP server will demonstrate significantly lower latency and higher throughput as concurrency ramps (20 to 50), as the Godot ENet engine process begins encountering queue latency and scheduling overhead.

7. Timeline & What’s Next

The implementation and testing schedule spans Summer through Fall 2026:

  • PR 1: Godot ENet headless server operational with RPCs and state synchronization.
  • PR 2: Custom C++ UDP server prototype with binary protocol, client registry, and tick broadcast.
  • PR 3: NodeStrike demo game complete; shared client connecting seamlessly to both backends.
  • PR 4: Benchmark suite execution on GCP; data collection and granular ramp testing. (In progress — see the Execution post)
  • PR 5: Statistical analysis, final paper, poster presentation, and open-source benchmark release.

Stay tuned for follow-up posts where I’ll share the live benchmark results, graphs, and lessons learned from building the servers!