ConduitDocs

Seamless backend transfer

Status: working, owner-verified on the test cluster (2026-07-26). A player walks across a shard seam and keeps the world — no loading screen, no protocol error, no snap-back to the boundary. Position and view carry, time and weather match either side, and the entity id agrees on both servers.

Still unproven is longevity rather than correctness: it has not run under real player load, with several players crossing at once, and the patch has not yet had to survive a Velocity release.

Read in order if you are picking this up cold: this intro, then §Correction (2026-07-26) for which mechanism actually causes the loading screen — most of this document was wrong about that, and the wrong version is kept below because the mistake is the useful part — then §Phase 1 for what now exists, and §Entity ids for the bug that took the longest to find.

The snap-back was the server correcting the client, not a teleport#

Owner-verified working, 2026-07-26. Walking a seam kept yanking the player back to the boundary, twice, every time. Four rounds of fixes missed it, and the reason they missed is the useful part: each one instrumented a teleport Conduit owns, and none of them ever fired. The answer only appeared once the trace logged every teleport within twelve seconds of an arrival, whoever caused it — and stayed silent. Nothing teleported the player at all.

One crossing gave three numbers that settle it:

seam-trace: lead 2.59,-0.02 over 12t from v=0.216,-0.002 (ground)   ← prediction working
(no TELEPORTED line)                                                ← nobody teleported anyone
moved too quickly! 12.145, 0.0, -0.094      three seconds later     ← the server's own correction

0.216 blocks/tick is exactly Minecraft's walking speed, so the velocity is right. And 12 blocks ÷ 0.216 is three seconds — matching the gap between arrival and the warning precisely.

So: a seamless switch keeps the client in PLAY, and it never stops walking. The destination cannot process its movement until login and the terrain handshake finish, which measures about three seconds. The client covers roughly twelve blocks in that window, the server sees the whole gap arrive in a single packet, judges it impossible and corrects the player back to where it still had them — the boundary. That correction is the snap-back. It fires again as soon as the gap reopens, which is why it always happened exactly twice.

Two things had to change, and only together:

  • Feed the prediction a real velocity. Player#getVelocity() reports ~0 horizontally for a walking player: ordinary movement is client-driven, so the server's deltaMovement only carries real numbers when the server is moving them — a fall, a glide, knockback. The crossing announce recorded that zero, so the lead was skipped entirely and the arrival was placed exactly on the boundary. Velocity is measured from consecutive positions now. (This is also why airborne crossings always felt better: they populate deltaMovement honestly.)
  • Tolerate what prediction cannot reach. Dead reckoning can cover the handoff, but nothing can predict the login window, so the residual must be tolerated rather than predicted away. spigot.yml widens moved-too-quickly-multiplieronly for a task that is actually sharded; an ordinary server keeps Spigot's default.

The general lesson, worth more than the fix: when a symptom looks like "something moved the player", confirm that something did. A position correction is not a teleport and fires no event, so the absence of a trace line was the finding.

Entity ids — the bug that actually blocked this#

Keeping the client in play means it also keeps the entity id the previous server gave it. The destination's JoinGame would re-teach it, and suppressing that packet is precisely what the seam does. Usually nothing notices. But when the kept id happens to name a real entity on the destination, every packet about that entity is applied by the client to itself, the decode goes wrong, and the client dies with "network protocol error".

The measurement that found it, three consecutive crossings:

client keepsdestination hasresult
326197fine
347500fine
500497dropped one second later

It also explains why it looked random: a pair of freshly generated worlds reported client 1 == server 1 and never failed, while busier shards mismatched and did. Two servers agreeing by luck is not the same as two servers agreeing by design.

So both ends now derive the id from the player's UUID and agree by construction — no packet rewriting and nothing pinned to a protocol version, which is what makes it survivable across client releases. The value is a high positive in [2^30, 2^31). Negative is the more elegant argument (vanilla's counter only counts up from zero, so a negative id provably cannot collide) but it is also a value no vanilla entity ever holds, and server internals — entity lookups, trackers, region indexes — are written for positive ids. That trade buys a guarantee nobody needed in exchange for a class of bug nobody can see coming. Reaching 2^30 would take a billion entity spawns in a single run.

Bukkit exposes getEntityId() and no setter, so the field is set on the NMS entity in PlayerLoginEvent — the last moment before the level indexes the player by id, and renumbering after that would strand the id→entity key. SeamEntityId verifies the write by reading the id back and logs once, clearly, whether it took; on any failure it changes nothing and the seam behaves exactly as it did before, because a collision risk is better than a player who cannot log in.

What "seamless" means#

A player crosses from one backend to another — a shard boundary, a lobby→game jump — and sees no loading screen: no world unload, no terrain reload, no "Downloading terrain" / dirt-screen flash. The camera, held items, HUD and surrounding chunks stay put; only the server behind the connection changes. The bar is a switch that feels like walking across a seam in one continuous world.

Why the loading screen happens#

On a backend change, Velocity sends the client a JoinGame packet. JoinGame is the vanilla "you just joined a world" signal: the client responds by dropping its current world and reloading terrain from scratch. That reload is the loading screen. It is inherent to how the proxy hands a player from one backend to another — not a Conduit choice — and it fires even when the destination is byte-identical to the origin.

Two things it is not:

  • Not the 1.20.5 Transfer packet. That packet moves a client to a different server address and is a full reconnect (fresh login, configuration phase, the works) — strictly heavier than a proxy backend switch, not lighter. It solves a different problem (moving between proxies/hosts) and does not remove the loading screen.
  • Not a solved problem in the open-source ecosystem. There is no turnkey open-source plugin that delivers a fully-seamless backend transfer. The public building blocks exist but must be assembled and maintained:
    • entity-ID remapping — the destination server assigns its own entity IDs; to continue the session without a reload the client's entity IDs (its own, and everything it can see) have to be reconciled so nothing desyncs.
    • the same-dimension respawn trick — respawning a player into a world with the same dimension type avoids some of the full teardown a dimension change forces, and is the closest vanilla mechanic to "swap the world under the player without a full reload".

Both are version-fragile: they lean on client behaviour that shifts between Minecraft releases, so any implementation is pinned to a protocol version and needs re-validation as clients update.

What ConduitSharding already does#

Conduit's world sharding (ConduitSharding, connector-owned — see FEATURES.md §4.17) already delivers a seamless world, and everything except the switch's loading screen:

  • Identical terrain across the seam — adjacent shards are generated on the same seed, so the world is continuous across a shard boundary; there is no visible discontinuity in the blocks.
  • Exact position carried — the boundary handoff (/api/connector/transfer + pending) preserves the player's precise coordinates, so they arrive where they left.
  • State synced — inventory / ender chest / HP / XP / effects / gamemode ride Redis via ConduitInvShare, restored on the destination before the player is in control.

What remains is the switch itself: because the player still moves between backends through the proxy, Velocity still sends JoinGame, and the client still reloads terrain — a brief loading screen on top of an otherwise-continuous world.

What full seamless additionally needs#

Closing the gap is proxy-core packet work, not a plugin bolt-on:

  • Suppress JoinGame on a shard-internal switch and instead feed the client a continuation of its current session — keep the world loaded, swap the backend behind it.
  • Reconcile entity IDs and login/session state across the switch so the client treats the new backend as the same world it was already in (no re-login, no configuration phase, no entity desync).
  • Pre-load destination chunks around the crossing point before the handoff so nothing pops.

This lives in the proxy core (Velocity internals / a custom handler), is version-fragile by nature, and needs live iteration against a real 1.21 client — it can't be validated purely offline, because the whole point is client behaviour under a packet sequence the vanilla client was never designed to accept.

Phasing#

Staged so the low-regret win lands first and the risky part is gated behind a proof of concept:

  • E0 — spike (go/no-go). Measure the raw mechanics on a 1.21 client: same-dimension respawn behaviour and chunk pre-load timing/feasibility. Output is a decision, not a feature — does a no-loading-screen path exist on the current client, and at what maintenance cost.
  • E1 — near-seamless shard handoff (the low-regret win). Tighten the existing sharding handoff to the smoothest achievable within the JoinGame model — pre-loaded chunks, minimal state stall, fastest possible reconnect — so the crossing is as close to invisible as the standard path allows. Ships value regardless of how E2 lands.
  • E2 — full seamless (stretch, POC-gated). Only if E0 says it's viable: the proxy-core packet work above — suppress JoinGame, continue the session, reconcile entity IDs — for a genuinely no-loading-screen shard switch. Version-pinned, re-validated per client release.

Honesty note#

This is deliberately conservative. Fully-seamless transfer is achievable in principle but is version-fragile client-behaviour work with no open-source precedent to lean on, so it is documented as a spike with an explicit go/no-go rather than promised. E1 is the commitment; E2 is contingent on E0.


Correction (2026-07-26): the patch site below is dead code on modern clients#

Everything in this section is accurate for a client older than 1.20.2 and wrong for every client that matters. Verified link by link against 4498f1e0 (Velocity 3.5.1, PaperMC's recommended release):

  • LoginSessionHandler.handle(ServerLoginSuccessPacket) branches on smc.getProtocolVersion().lessThan(MINECRAFT_1_20_2) — and that is the backend's protocol version. At or above 1.20.2 it writes LoginAcknowledgedPacket, installs ConfigSessionHandler, and calls clientPlaySessionHandler.doSwitch().
  • doSwitch() sets spawned = false and calls ConnectedPlayer.switchToConfigState().
  • switchToConfigState() does connection.write(StartUpdatePacket.INSTANCE) to the client, flips its MinecraftEncoder to StateRegistry.CONFIG, and installs a play-packet outbound queue.
  • The destination's JoinGame then arrives with spawned == false, so handleBackendJoinGame takes its first branch — a plain delayedWrite(joinGame).

So doFastClientServerSwitch is never reached on 1.21.11, and the loading screen is caused by the config-state round trip, not by JoinGame + Respawn. Suppressing the two delayedWrite calls named below would compile, boot, and satisfy all three CI effect-assert layers — the symbol would be present in the class and genuinely called — while changing nothing a player can see. An inert jar that every gate reports green is the worst available outcome, so this correction is the most valuable thing in this document.

To keep the world, the client must never leave PLAY: something has to stand in for it through the destination's configuration phase and ack on its behalf. That is a larger patch than this section implies, and it is planned separately.

One thing this correction does NOT settle: whether the vanilla client tears its level down at StartUpdate or at the following JoinGame. It does not matter for the design — config → PLAY requires a JoinGame, and JoinGame always builds a fresh level — but it is unverified, so do not claim it.

Phase 1 — what was built#

admin-dev/seam, patches/v3/0001-seamless-shard-switch.patch. Three hooks, ~150 lines of new code, off unless a JVM flag names the server pair.

The idea in one line: ConfigSessionHandler is a relay — it forwards the destination's whole configuration handshake to the client and passes the client's answers back — so the patch replaces it with a stand-in that speaks that handshake itself and forwards nothing.

SeamConfigSessionHandler (new, in ...connection.backend, because TransitionSessionHandler's constructor is package-private there) answers on the client's behalf:

Destination sendsStand-in doesWhy
KnownPacksechoes it backclaims the client knows every pack offered — true across a seam
KeepAliveanswers itthe client can't see a config-state keep-alive; silence is a timeout
ResourcePackRequestACCEPTED then SUCCESSFULthe client already has that pack applied. Also must be answered: a server waiting on a required pack never finishes configuring, and the switch would hang forever
registries, tags, links, report details, cookies, branddrops themthe client holds the origin's copies and is in PLAY, so it couldn't decode them anyway
FinishedUpdateacks, forwards the client's brand, installs TransitionSessionHandlerthis is where the stock path rejoins

handleGeneric is overridden to drop, and that override is the patch: inheriting the stock version would forward the whole configuration stream at a client in play. Nothing leaks — MinecraftConnection releases the message after handling returns, which is precisely why the stock handler has to retain().

The other two hooks are omissions:

  • LoginSessionHandler installs the stand-in and then does not call doSwitch(). That call is what sends the client StartUpdate and sets spawned = false. seamDetachBackend() replaces it with only the parts that concern the proxy — release the origin connection, send a keep-alive to cover the handshake — and leaves the client, its tab list, its boss bars and spawned alone.
  • handleBackendJoinGame therefore sees spawned == true and routes into the switch branch, where seamSameWorldSwitch sends the client nothing at all. Note the irony: skipping doSwitch() is what makes doFastClientServerSwitch — dead code in stock Velocity per the correction above — reachable again, and that is the branch being suppressed.

Whether a switch is a seam is recorded on the handler (seamSwitch) at login and read once when JoinGame arrives, rather than recomputed. It cannot be recomputed: by then the origin connection is gone. And the two decisions must not be able to disagree — suppressing JoinGame for a client that did go through configuration would leave it with no world at all. doSwitch() clears the flag, so a seam attempt that fails halfway cannot leak a suppression into the recovery switch that follows.

The flag#

-Dseam.pairs=folia-test-*

Groups split on ;, members on ,, matched case-insensitively against Velocity server names. Any two distinct servers a group matches form a seam, so one group covers a whole grid. Unset ⇒ the jar is stock.

These are Velocity server names, and they are not the container names. engine.ts:408 builds them as `${task.name}-${vmid}` — the task's name, with no group prefix — so the shard whose container is network-folia-test-220 is folia-test-220 to the proxy, and the flag is folia-test-*. Getting this wrong is invisible: eligibility returns false, the stock path runs, and the loading screen is back with nothing saying why.

Use the * form for a Conduit shard grid. The name carries the container id, so a literal list stops matching the moment an instance is replaced — silently, the same way. A prefix names the whole service including instances that do not exist yet, and since every instance of one sharded service shares a seed by construction, it is also the truer statement of the precondition.

Named rather than inferred from a shared seed, because two unrelated servers that agree on a number are not a seam and guessing wrong is not a cosmetic failure. A bare * is refused for the same reason. Also refused: the same server twice, clients or backends below 1.20.2, and Legacy Forge.

What is verified, and what that is worth#

Applies and compiles on both release lines — 3.5.1 at 4498f1e0 and 4.x at b45716de — and passes upstream's own checkstyle on each. Both tracks build from one file; patches/v4/ symlinks patches/v3/ so they cannot drift while both look maintained. assert.txt declares all three hooks, and each layer was tested in the failing direction too: it fails on a pristine tree, on a tree that already carries the marker, and on a callsite that does not call the symbol. Writing that first real spec turned up a latent bug in ci/assert-effect.shgrep exits 1 when the marker is legitimately absent, which under pipefail killed the pristine check before it could report the zero it was looking for. The check had never run before, because there had never been an assert.txt.

None of that is evidence a player sees no loading screen. It is evidence the patch is present, reachable, and structurally what it claims to be. The acceptance test is somebody crossing the seam and looking, and it is deliberately written down as that rather than dressed up as a test.

First real crossing — 2026-07-26 08:52#

The patch fired on a live shard crossing, and the log says so in all three places:

08:38:21  seam: armed for [[folia-test-*]]
08:52:56  seam: keeping the world for admin across folia-test-220 -> folia-test-221
08:53:00  seam: admin arrived on folia-test-221 with the world kept (client entity 1, server 1)

client entity 1, server 1 — the entity ids matched. That is the measurement this line was added to take, and it undercuts the case for Phase 2 on this deployment: each shard is a freshly-generated world where the arriving player is the first entity the server allocates, so both ends independently hand out id 1. If that holds generally, the entity-id swap — the version-fragile part of the whole design, and the part that would need a table pinned per protocol version — is not needed for shard seams at all.

Do not promote that to a rule yet. It is one crossing, into a shard with no other players and few loaded entities. A destination that already has players, mobs from a pre-warmed region, or a restart behind it will allocate a higher id, and then the mismatch is real. What this does establish is that Phase 2 should be driven by observed mismatches rather than assumed necessary: the log line already prints the pair on every crossing, so the evidence accumulates by itself.

The destination also logged restored 5562ms after the crossing was announced (state pre-warmed) and inbound — 25 chunks ready in 6245ms, plus two moved too quickly! warnings from the position restore. Those are the existing sharding handoff working, not the patch.

Expected to be broken on the first walk#

The client keeps entity id A while the destination calls it B, so anything the destination addresses by id is ignored: no damage animation on yourself, riding and boats broken, self potion particles and equipment metadata frozen. Gamemode stays the origin's until something on the destination sets it, because it arrived inside the dropped JoinGame.

Health, food, XP, abilities and inventory carry no entity id. Those working while the list above misbehaves is the pass signal — it says the client held a world across a backend change, which is the one thing Phase 1 exists to find out. The dangerous symptom is a collision — camera snapping to another entity, "you are a cow" — which is probabilistic, so ten clean minutes is not evidence it cannot happen.

Also worth knowing: the switch still sends a title reset, so a title or action-bar HUD blinks for a tick on the crossing. Left in deliberately — suppressing it risks a stuck title from a server that is now disconnected, and a one-tick blink is the cheaper bug.

Deploying it for the walk#

The jar is in the release feed as velocity-seam-v3-*.jar, so a throwaway task from the Velocity (patched) blueprint (velocity-source) picks it up with no new plumbing — the blueprint is only visible while a GitHub token is configured, and POST /api/tasks refuses it without one rather than building a container that can never provision.

A ; in the JVM args used to brick the service — fixed, but worth knowing why. seam.pairs takes several groups separated by ;, and the systemd unit launches the server through tmux new-session … '<java … -jar …>', which tmux runs via sh -c. So the ; split the command: the first half became java -Dseam.pairs=folia-test-* with no -jar, which prints usage and exits. Velocity never started, no tmux session existed, and nothing was written to latest.log — the console only said "no tmux session yet", so the cause was invisible from the panel. shSafeJvm (lib/provision.ts) now backslash-escapes shell metacharacters at all four launch sites (paper + velocity, on both the fresh-provision and the edit-then-restart paths), so multi-group seam.pairs works and no operator JVM arg can do this again. Verified: the exact config that bricked it now boots in 3.4s.

Three things to set on that task, none of which the deploy wizard asks for:

  • fronts = the sharded task only. Nothing else should be reachable through this proxy, so a mistake cannot land a real player on it.
  • jvmArgs = -Dseam.pairs=folia-test-*, via PATCH /api/tasks/<id>. resolveJvm (lib/provision.ts:63) carries it to the systemd unit, applied on the instance's next start.
  • Its own network path. The blueprint's port is 25565, which is fine because each instance is its own container with its own IP — but that also means the walk connects to that container's address, not the public one. Check that address is reachable from wherever the client runs before deploying; part of the cluster is only reachable over the tunnel.

The log lines to look for: seam: armed for … on the first switch after boot, then seam: keeping the world for … and seam: … arrived on … with the world kept (client entity N, server M) per crossing. The last one prints the exact id pair Phase 2 has to reconcile, for every real crossing, which is the measurement that phase would otherwise have to guess at.

Also corrected while checking: world borders do not differ between shards. lib/sharding.ts computes one border for the whole grid and ConduitSharding.applyBorders() sets that same width everywhere, so the border is not stale state that needs re-sending.

E0 findings — read from Velocity's source (pre-1.20.2 clients only — see the correction above)#

Velocity 3.4.0's own code answers the two questions the spike existed to answer. From proxy/.../connection/client/ClientPlaySessionHandler.java:

handleBackendJoinGame() is the whole server-switch path. For an already-spawned player it calls doFastClientServerSwitch(), which sends the client exactly two packets:

final RespawnPacket respawn = RespawnPacket.fromJoinGame(joinGame);
player.getConnection().delayedWrite(joinGame);   // ← the loading screen starts here
player.getConnection().delayedWrite(respawn);

Why JoinGame is there is the important part. It isn't laziness — the comment above it says so outright:

Most notably, by having the client accept the join game packet, we can work around the need to perform entity ID rewrites, eliminating potential issues from rewriting packets and improving compatibility with mods.

The destination server assigns the player a fresh entity id. Sending JoinGame makes the client adopt that id wholesale, so no packet ever has to be rewritten. Velocity chose the loading screen deliberately, to buy correctness and mod compatibility.

What removing it actually costs#

Suppressing JoinGame means taking on everything it was paying for:

  1. Entity-id rewriting on every packet — the client keeps its old id, so every packet from the new backend that references an entity has to be remapped, in both directions, forever. This is the BungeeCord approach Velocity explicitly moved away from.
  2. Re-sending JoinGame's payload as discrete packets — it carries 22 fields (gamemode, previous gamemode, difficulty, view distance, simulation distance, dimension data and registry, sea level, portal cooldown, secure-chat enforcement, …). Each needs an equivalent update packet, or the client silently keeps the old server's values.
  3. A fork, permanently — re-patched per Velocity release, re-validated per protocol bump, on the one component every player connects through, with no upstream support when it breaks.

The part that makes it tempting anyway#

For Conduit's actual case — shard strips on a shared seed — the client's existing chunks are already correct, because adjacent shards generate identical terrain and the player lands at the same coordinates. There is no terrain to re-download; the only reason the screen appears is that JoinGame tells the client to throw the world away. That's what makes a same-seed switch the one scenario where full seamlessness is genuinely within reach.

There is no cheap flag — checked#

The obvious hope is that Respawn already carries a "keep what you have" bit, so the switch could stay two packets and simply tell the client not to discard the world. It does have such a bit, and it does not do that. RespawnPacket has a dataToKeep byte (1.16+), and fromJoinGame hardcodes it to zero:

public static RespawnPacket fromJoinGame(JoinGamePacket joinGame) {
  return new RespawnPacket(joinGame.getDimension(), joinGame.getPartialHashedSeed(),
      joinGame.getDifficulty(), joinGame.getGamemode(), joinGame.getLevelType(),
      (byte) 0, joinGame.getDimensionInfo(),// ← dataToKeep

But the protocol defines that byte as 0x01 KEEP_ATTRIBUTES and 0x02 KEEP_ENTITY_DATA — player attributes and entity metadata. Neither keeps chunks. The client rebuilds its level on JoinGame and on Respawn either way, so setting the flag changes nothing about the loading screen. The only route to seamlessness is sending neither packet, which lands straight back on entity-id rewriting.

Worth writing down because it is the first thing anyone tries, it looks like it should work from the field name alone, and it costs an afternoon to discover otherwise. Verified against 4498f1e0 — Velocity 3.5.1, the current recommended release — so it holds on the line the fleet would actually run, not only on the 3.4.0 where the rest of this section was first read.

Verdict#

Technically viable, and the patch site is now known precisely — a seamless branch in handleBackendJoinGame() taken only when source and destination share a seed. Not recommended as a next step, because the cost is a permanent proxy fork plus entity-id rewriting, and E1's pre-warm already removes most of the delay without touching the proxy.

Build note: Velocity's Gradle build requires JDK 21; the workstation has 17, so building the fork needs a newer JDK installed first.

Recommendation: ship and measure E1 on a real crossing first. Revisit E2 only if the remaining flash is still unacceptable — and then as a deliberate, separately-owned fork, never a quiet patch.

Edit on GitHubdocs/seamless-transfer.md 20 min readUpdated