Netcode: tick synchronisation, a bit-packed wire format, replication, snapshot
interpolation, client-side prediction, lag compensation and interest management.
Use it with dot-server, with Godot’s raw
multiplayer, or with your own transport — it never owns a socket.
Requires dot-core.
var net := DotNetManager.new()
net.is_server = true
net.config = DotNetConfig.new()
add_child(net)
net.spawner.register_prefab(&"player", preload("res://player.tscn"))
net.interest = DotNetInterestGrid.new()
net.send_fn = func(peer, payload, delivery): my_transport.send(peer, payload)
net.start()
Make something networked by adding a DotNetIdentity and declaring what
replicates:
class_name PlayerMovement extends DotNetBehaviour
var position: Vector3
var velocity: Vector3
var ammo: int
func _register_net_vars() -> void:
replicate(&"position", DotNetVar.Type.VECTOR3_POSITION).interpolated()
replicate(&"velocity", DotNetVar.Type.VECTOR3_VELOCITY).with_epsilon(0.05)
replicate(&"ammo", DotNetVar.Type.UINT).bits(9).to_owner_only()
func _net_simulate(tick: int, delta: float) -> void:
position += velocity * delta # server AND owning client
Dirty tracking, quantisation, audience filtering, rate limiting, interpolation and prediction all follow from that declaration.
What it gives you
A wire format that fits. Positions quantised to a centimetre over a 4 km
world cost 19 bits an axis instead of 32. Rotations use smallest-three: 29 bits
instead of 128, accurate to under a degree. Bit packing, varints, per-property
deadbands, and a describe_budget() that says what your settings cost per
client per second.
Prediction that converges. The owning client simulates immediately, the server corrects, and the client replays its unacknowledged inputs on top of the correction. Small errors ease out over a tenth of a second; large ones snap, because easing across a teleport drags you through geometry.
Interpolation that hides jitter. Remote entities render slightly in the
past, far enough that the bracketing snapshots have arrived. The buffer grows
quickly under jitter and shrinks slowly. interpolation_buffer is counted in
snapshots, and the interpolator converts it to ticks in one place
(delay_ticks()) before subtracting it from a tick. It used to subtract the
snapshot count unconverted — at 128 ticks and 20 snapshots a second the render
time sat past the newest snapshot, so every remote entity was quietly
extrapolated.
A clock that catches up. A client behind its target tick stamps every command for a tick the server has already simulated, and every one is discarded as late — so a client behind by more than the input margin (catch_up_threshold_ticks()) jumps forward at once, and only a client ahead is eased back. Eased both ways, a half-second loading hitch on a first connect was ten seconds of a player who could not steer.
Lag compensation. The server rewinds every other entity to where the shooter
saw them — accounting for both their latency and their interpolation buffer —
tests the shot, and restores. Bounded by max_rewind_sec, so a client cannot
claim an arbitrary rewind.
Bandwidth budgeting. A per-client byte budget with a priority accumulator, so important entities update often, unimportant ones update eventually, and nothing is starved — with an explicit bound on how long “eventually” can be.
Stats that name the cause. Netcode fails quietly and players call every failure “lag”. A high correction rate is a determinism bug; high starvation is bandwidth; high late-input counts are the clock. Different fixes.
Where a game plugs in
- DotNetBehaviour subclass
The class a game subclasses most.
_register_net_vars,_net_ready,_net_removed,_net_simulate,_net_state_applied,_net_interpolated,_net_read_property,_net_write_property.- DotNetInput subclass
Your game’s controls.
_write,_read,_sanitise,_equals._sanitiseis for the relationships between fields — quantisation already bounds each one individually.- DotNetMessage subclass
_type_name(namespaced; yours should not start withnet.),_write,_read,_validate. Validate in_validate, not in the handler: a handler that validates is a handler somebody copies without the validation.- DotNetInterest subclass
_is_relevant— override this and nothing else for most strategies. Plus_candidatesfor a bulk filter,_prepareto rebuild an index once per snapshot, and_scorefor prioritisation.DotNetInterestAll,DotNetInterestDistanceandDotNetInterestGridship.This is the biggest lever on bandwidth and the only anti-cheat that actually works: data never sent cannot be drawn on a wallhack.
- DotNetVar.Type.CUSTOM
With a
write_fn/read_fnpair, replicates any type at all.on_change(handler)adds a per-property callback.- DotNetSpawner.register_factory(id, callable)
How entities are constructed, when a prefab scene is not enough.
register_prefabis the simple form.- DotNetManager.send_fnCallable
func(peer_id, payload, delivery). The whole transport boundary. Receiving isreceive(payload, from_peer_id).- DotNetConfig
tick_rate,snapshot_rate,mtu,per_client_budget,per_client_packet_rate,interpolation_buffer,adaptive_interpolation,max_extrapolation_sec,enable_prediction,reconcile_smooth_sec,reconcile_snap_distance,input_margin_ticks,adaptive_input_margin,enable_lag_compensation,max_rewind_sec,history_sec,interest_cell_size,max_entities_per_snapshot,max_tracked_entities,world_extent,position_step,velocity_bits,rotation_bits.- Signals
ticked,snapshot_sent,snapshot_applied,entity_spawned,entity_despawned,registered,unregistered,owner_changed,became_owner,net_spawned,net_despawned.
Determinism is a requirement, not an aspiration
_net_simulate must be deterministic across machines or reconciliation will not
converge — which rules out Godot’s physics for anything needing exact agreement.
Read nothing that differs between machines: no wall clock, no unseeded random,
no other players’ unpredicted state.
`_net_state_applied` must not move the node on a predicted entity
receive_snapshot calls it before DotNetPredictor.reconcile, and the first
thing reconcile does is capture the node as “what the client is showing” in
order to measure the correction. Writing the server’s position there first makes
the measured error the entire replay distance: the correction rate reads 0.9,
every reconciliation logs a snap, and the simulation was right the whole time.
Three separate games shipped this line.
`_net_interpolated` is a different hook, and you need both
_net_state_applied runs when a snapshot arrives — a few times a second — and
is where a game adopts authoritative state. _net_interpolated runs every
frame on a remote entity, after the smoothed value has been written. Without
it a game copies replicated state into its nodes at the snapshot rate while the
interpolated value sits in a property nobody read, and it looks exactly like a
broken interpolator.
Two builds on one wire
A server and a client built from different dot- sources can play, as long as neither lacks a message type the other requires.* Three things make that true:
- Message ids are derived from the type’s name — the first 32 bits of its SHA-256 — so registering one more type renumbers nothing, and the id is the same number in every build that has the type.
- Every body is framed with its length in bits. A receiver without the type skips exactly that message, mid-batch included; one with an older version of the type reads what it knows and skips the rest.
- The schema is exchanged. The manager sends its table through
send_fnas it adds a peer or starts. A type registered as required (the default) that the other end lacks refuses the pair in words — “This server’s game needs a newer game client.” — onpeer_schema_refused. dot-net owns no socket, so the host drops the peer;dot-game’s netcode bridge does it for you. Register a type withrequired = falseif a peer may do without it.
Append fields; never reorder, retype, re-width or remove one
Derived ids no longer protect you from a changed message. Read a field added
after the type first shipped only if reader.has_more(), so a message from an
older sender keeps the field’s default. A change that cannot be an append is a
new type with a new name — renaming a type makes a new one, because its id
is its name — registered beside the old one until nothing sends the old.
This was the last coordinated protocol break for dot-net messages: every client and server built before 2026-09-26 must be rebuilt once.
Three things a host has to do that nothing here can
- Feed
DotNetStats.note_rtt.receive_snapshotreadsrtt_percentile(0.5)on every snapshot to drive the clock, and dot-net never touches a transport — so nothing inside it can write a sample. Call it with your link’s ping every tick; game-arena’s bridge does it through anrtt_sourcecallable of its own. - Adopt the server’s tick rate on the client with
DotNetClock.adopt_tick_rate(), which also setsEngine.physics_ticks_per_secondand raises the engine’s physics step cap so a slow frame does not silently drop ticks. A client counting at its own rate replays prediction at the wrong step and reconstitutes every replicated time through the wrong divisor. - Call
DotNetManager.interpolate_frame()once per rendered frame.clock.server_tick()returns an int and only moves insideadvance(), so sampling it draws the same instant on every frame inside one physics step and remote entities step at the tick rate.interpolate_frame()samples at a fractional tick fromclock.render_alpha(), which needs the clock advanced once per physics frame with the engine’s physics rate equal to the tick rate.
Each of those was a real bug whose every simulated value was correct. The host still has to make the calls: nothing here runs on its own schedule.
