// Build journal · 2D mage-vs-mage PvP

SPELL DUEL

Anatomy of a LAN netcode build — the desync that wasn't a bug report, the hitbox that ignored your hat, and why the right netcode is the one you don't write.

Engine  Godot 4.6 Lang  GDScript Transport  ENet / UDP Online  Tailscale Model  Owner-authoritative
SCROLL
01
The Premise

Two wizards. One arena. Skillshots, terrain, and prediction.

Spell Duel is a side-view platformer duel. Four mouse-aimed abilities per mage, environmental patches that linger — ice that strips your jump, fire that ticks, lightning that amplifies — and an elemental matrix where fire melts ice into water and lightning shatters earth walls. The whole identity is reactive terrain, which (spoiler) is exactly what makes one popular netcode technique the wrong choice here.

Visually it's NeonShape: flat fill, bright outline, soft glow — procedural neon, no sprites. That same arcade-cabinet look is what this page is wearing.

02
Getting Bits Between Two Machines

The transport saga — click a stop

Online play went through four transports before it felt right. The lesson: a dead end (Cloudflare) forced the architecture that was actually correct.

WebSocket
TCP
Cloudflare
tunnel
ENet
UDP
Tailscale
WireGuard
Net.gdfinal transport
03
The Bug You Feel, Not Read

Two writers, one transform

The first real playtest symptom: "I'm standing on a platform but my brother sees me on the floor." Classic. The cause wasn't a dropped packet — it was two systems both writing the same position every frame.

The host simulated the client's mage from relayed input and a MultiplayerSynchronizer pushed the client's own authoritative position back over the top. They fought. The fix is structural: each mage gets exactly one simulator, and the synchronizer replicates a shadow field — _net_position — never the live position.

✗ Before — dual authority
local sim →
pos
← MoveSync
Two writers → jitter, rubber-band, platform/floor disagreement.
✓ After — single writer
owner → _net_position → lerp →
pos
Owner publishes; the puppet only ever lerps. One writer per peer.
Mage.gdowner publishes
04
Making 30 Packets Look Like 60 Frames

The puppet only lerps

The opponent's mage is a puppet: no physics, no input. It feeds its position forward by the synced velocity, then eases toward the authoritative target. The one twist — skip the feed-forward during a dash, because a 780px/s blink that stops on a dime would overshoot and snap back.

Drag the slider: watch the rendered dot chase the authoritative one. Low smoothing floats; high snaps. This is _puppet_tick.

40
authoritative (_net_position) rendered (global_position)

Shipping value: 40. Note this sim feeds a frame-perfect target — the real game syncs at ~30Hz with network jitter, so higher values trade jitter-smoothing for tightness. Judge it on two machines, especially a dashing opponent.

Mage.gdthe puppet tick
05
Hiding The Round Trip

Predict the cast, relay the intent

Your own casts fire instantly (a visual-only mirror), while the host stays authoritative for damage. But charged Fireball was intermittently swallowing casts — the host re-derived your charge from relayed press/hold/release timing, and if your press landed during a cooldown it was dropped, so the release found no charge.

Fix: you own your mage, so you already know the resolved charge at release. Relay the intent — slot, charge, aim — and let the host spawn that exact variant. No re-derivation, no dropped press, no variant mismatch.

Mage.gdclient → host cast intent
06
When The Hat Is Invincible

A hurtbox that isn't the body

"When I hit the top of the enemy they don't take damage." The collision box stopped at the shoulders — the pointy wizard hat had no hurtbox, so head shots sailed over. But you can't just enlarge the body: surface patches mask layer 2 to detect a mage standing in them, and that same box drives platform clearance.

  • Add a separate Area2D hurtbox on the Hitboxes layer, sized to the full silhouette — it forwards take_damage to the mage.
  • Drop layer 2 from projectile masks (31 → 29) so spells hit the hurtbox, not the physics body.
  • Patches still key off the unchanged body. Platform clearance untouched. Heads now count.
Hurtbox.gdnon-physics damage receiver
07
The Cheapest Multiplayer Feature

Random arenas, zero extra netcode

Seven hand-built, left/right-symmetric platform layouts; a random one each round. The trick: the round counter is already synced across the network. Seed an RNG with round_id and both peers independently compute the identical layout — no RPC, no handshake, no desync surface.

Arena.gddeterministic-from-round_id
Why it works
Determinism is free synchronization. Anything both peers can derive from already-synced state (the round id) needs no replication at all. The opposite — re-deriving timing-sensitive state independently — is exactly what broke charged Fireball. Same coin, both sides.
08
The Netcode You Don't Write

Why not rollback?

The obvious instinct for a fighting game is full client prediction + server reconciliation — the Counter-Strike / fighting-game model. Two analyses, one conclusion: wrong tool here.

  • Nothing to hide. On LAN, round-trip is ~1–5ms. Your own mage is already frame-perfect (you simulate it locally). Rollback exists to mask latency you don't have.
  • It fights the game. Reconciliation needs deterministic replay — but spawnable earth walls and surface patches change the collision world mid-replay, so it would hitch precisely during combat, the worst possible moment.
  • The engine resists it. Godot's high-level multiplayer isn't built for tick-aligned rollback; you'd hand-roll a lot to lose on LAN.
Verdict
Owner-authoritative movement + opponent interpolation, with host-authoritative combat left intact. On LAN it's indistinguishable from perfect, it's a fraction of the code, and it doesn't pick a fight with the dynamic terrain that makes the game the game. The best engineering decision of the build was the feature we chose not to build.