Anshuman K. Yadav
Back to site

NetcodeJun 2026 · 6 min

Typed GDScript: what a shooter taught me about state synchronisation

Godot, GDScript, and the specific ways a networked shooter punishes an untyped variable.

Somewhere around week three of building Rush Fracture’s netcode, a teammate’s client started reporting full health on a run where he’d clearly just eaten a shotgun blast at point blank range. Not a visual glitch, the number itself was wrong on his screen while the server’s copy was correct. It took an embarrassingly long time to find, and the fix was one line. The cause was a variable I’d declared without a type, three files away from anything that looked like it mattered.

The bug that shouldn’t have shipped

GDScript lets you write var health = 100 and it just works, inferred as an int, right up until one code path assigns it a float from a damage falloff calculation and another assigns it a value that arrived over the network as a string because the RPC argument on the other end wasn’t typed either. Locally, in a single player test, this never surfaces, GDScript is forgiving enough to coerce most of it silently. Over the network, with client-side prediction reconciling against an authoritative server snapshot, a value that’s silently the wrong type on one side is enough to make the reconciliation compare unequal things and just... give up, keeping whatever the client already had instead of correcting it. That’s how you get a health bar that stops listening to the server.

Client and server exchange state on a schedule. The packet that doesn’t match the expected shape gets dropped before it can cause a silent desync.

Why GDScript’s optional typing is a trap and a fix at once

GDScript is dynamically typed by default, and that default is exactly right for a lot of game code, prototyping a pickup or a UI animation shouldn’t need a type declaration up front. The trap is that this default quietly follows you into the one place it’s actually dangerous: anything that crosses the network boundary. Godot 4 lets you writevar health: int = 100 and enforce it, and the engine will throw at the assignment site the moment something tries to hand that variable a value it doesn’t accept, instead of three systems later when a health bar renders nonsense. I made typing mandatory for every field that gets serialised into an RPC or a network snapshot, no exceptions, and that one rule caught more bugs before merge than every other lint rule in the project combined.

The discipline isn’t “type everything.” It’s “type everything that leaves the process,” because that’s the boundary where a silent coercion turns into a silent desync, and a silent desync is the single hardest class of multiplayer bug to reproduce, because it only shows up on one client, under specific timing, and looks completely fine in the editor.

A type error at the network boundary is a bug you get to fix at compile time. Past that boundary, it’s a support ticket you can’t reproduce.

Behaviour, not just position

Position and health are the obvious things to synchronise. The part that actually broke my mental model of “state” was realising enemy behaviour needed the same discipline. Rush Fracture ships seven distinct enemy AI behaviours, and each one is a small state machine, idle, alert, chase, attack, and so on. Early on I only synchronised position and animation state, and assumed clients could infer behaviour from movement. They couldn’t, reliably, and the two clients in a co-op session would occasionally render the same enemy acting like it was in two different states at once, one player watching it flee while the other watched it attack.

Each enemy is a small state machine. The current state is server-authoritative and synced explicitly, not inferred from position.

The fix was to treat the behaviour state itself as a first class piece of typed, replicated data, an enum, not a string, not a guess derived from velocity. Once behaviour was explicit and typed like everything else that crosses the network, the inference problem disappeared because there was nothing left to infer.

What I’d tell someone starting their first networked game

  • Type every field that touches an RPC or a snapshot, even when GDScript would happily let you skip it.
  • Never let a client infer state it could instead be told directly. Inference is where desyncs hide.
  • Test reconciliation under artificial latency early. Everything works fine at zero ping, which tells you nothing.
  • When something desyncs, suspect the boundary between systems before you suspect the network itself.

None of this is exotic advice. It’s the same lesson typed languages teach everywhere else, just with a much shorter feedback loop between the mistake and someone noticing they’re invincible.