Updated

dot-net

Multiplayer netcode for Godot — tick synchronisation, a bit-packed wire format, declarative replication, snapshot interpolation, client-side prediction with reconciliation, lag compensation and interest management.

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. _sanitise is for the relationships between fields — quantisation already bounds each one individually.

DotNetMessage subclass

_type_name (namespaced; yours should not start with net.), _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 _candidates for a bulk filter, _prepare to rebuild an index once per snapshot, and _score for prioritisation. DotNetInterestAll, DotNetInterestDistance and DotNetInterestGrid ship.

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_fn pair, 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_prefab is the simple form.

DotNetManager.send_fnCallable

func(peer_id, payload, delivery). The whole transport boundary. Receiving is receive(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.

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_fn as 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.” — on peer_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 with required = false if a peer may do without it.

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_snapshot reads rtt_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 an rtt_source callable of its own.
  • Adopt the server’s tick rate on the client with DotNetClock.adopt_tick_rate(), which also sets Engine.physics_ticks_per_second and 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 inside advance(), 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 from clock.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.