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.
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.
Net.gdfinal transport
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.
Mage.gdowner publishes
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.
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
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
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
Area2Dhurtbox on the Hitboxes layer, sized to the full silhouette — it forwardstake_damageto 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
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 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.