ADR-072: Teleport View-Distance Clamp and Steady Restore¶
Status: Accepted Date: 2026-06-23
Context¶
The dominant cost of a random teleport is not selecting the coordinate (selection is asynchronous, pre-verified, and anvil-prefiltered) but the arrival: when the player is placed at the destination the server implicitly loads and begins tracking the per-player view-distance ring of chunks around the new position, frequently into a cold area. These are not loads RTP schedules - they happen under the hood inside the engine's placement and chunk-tracking path, and RTP gets no per-chunk callback with which to pace them. The number of chunks involved grows with the square of the view distance, so a player at view distance 10-12 triggers a several-hundred-chunk burst concentrated in the tick of arrival. On busy servers, and especially on weaker hosts (and on Folia, where the destination region thread absorbs this work), that synchronous burst is a measurable TPS/MSPT spike that can stall the tick.
A direct rate limiter on these implicit loads is not viable: the scheduling belongs to the server, not to RTP, there is no portable hook to pace it, and forcing a hold-back would mean blocking a tick/region thread (an S-005 violation). The only portable lever that reduces the implicit placement burst is the size of the tracked ring itself - i.e. the player's view distance at the moment of placement. Shrinking that ring before placement caps the burst; restoring it gradually afterward is the rate limit on the residual loads, spread across many ticks instead of one.
RTP already preloads a configurable ring around the destination via
performance.yml#viewDistanceTeleport (consumed at Region.java, ConfigCache.viewDistanceTeleport).
That key governs how many chunks RTP itself loads server-side before placing the player;
it does not affect how many chunks the server then delivers to the client. The two
concerns are complementary: one decides what is resident, the other decides how fast it
is shipped.
Per-player view distance is purely runtime server state. Player#setViewDistance(int)
(Paper/Spigot) and the Fabric/NeoForge chunk-tracking-view equivalents are not persisted
to player data or to any database; a clean server restart re-negotiates every player to
the world/server default. The valid range is [2, 32] chunks - clients cannot render
below 2 - so 2 is the hard floor for any clamp.
This ADR introduces a lever that smooths the arrival burst by temporarily clamping the player's delivery view distance at teleport time and then restoring it in single-chunk steps spread over a configured total duration.
Decision¶
Add an opt-in "teleport view-distance clamp and steady restore" behavior, gated and
configured through performance.yml with one new key, reusing the existing preload
radius as the initial clamp value.
Configuration surface:
viewDistanceTeleport(existing): preload radius AND the initial clamp value for the arrival. No new "clamp start" key is introduced.viewDistanceRestoreInterval(new): the total time, in server ticks, over which the player's view distance is ramped from the clamp value back up to the value it held immediately before the teleport. Default200ticks (10 seconds).0disables the feature entirely (no clamp is applied).
Behavior:
- Capture the player's effective pre-teleport view distance (per-player override if set, otherwise the world/server default) before applying any clamp. This captured value is the restore target. The clamp must be applied before the teleport call itself, not after arrival: the goal is to shrink the tracked ring the engine expands during placement, so a clamp applied after the player is already placed would miss the very burst the feature exists to smooth.
- Clamp value =
max(viewDistanceTeleport, MIN_VD)whereMIN_VD = 2(the game's minimum renderable distance). A configured preload radius of0still resolves to a one-chunk destination load server-side, but the delivered clamp is floored at 2 so the player is never sent an unrenderably small view. The exact platform floor is validated against the platform API at apply time rather than assumed. - Skip when there is nothing to gain: if the clamp value is greater than or equal to
the captured pre-teleport view distance (
steps <= 0), apply no clamp and schedule no restore task. This is a normal, expected no-op, not an error. - Ramp (total-time, chunk-cost-weighted semantics): the view distance is raised one
chunk at a time from
clampValueup tocapturedVDover the totalviewDistanceRestoreInterval, but the dwell between successive increments is not uniform. Because the chunk ring delivered at view distancergrows as(2r+1)^2, the marginal cost of the stepr -> r+1is proportional to(2(r+1)+1)^2 - (2r+1)^2 = 8r+8, i.e. it grows roughly linearly inrand the largest jumps are the last ones. A uniformstepIntervalwould therefore back-load the heaviest bursts into equal-length windows, re-concentrating cost exactly where it hurts most. Instead the scheduler allocates the total interval in proportion to each step's marginal chunk cost: the dwell granted to the stepr -> r+1isdwell(r) = max(1, round(viewDistanceRestoreInterval * (8r+8) / totalMarginalChunks))wheretotalMarginalChunks = (2*capturedVD+1)^2 - (2*clampValue+1)^2. This spends more ticks before the expensive late increments and dispatches the cheap early ones quickly, so the chunks-delivered-per-tick rate stays roughly flat across the whole window - the "logarithmic-feeling" release the cost curve calls for. The task self-cancels and releases when it reachescapturedVD. An interval shorter than the step count simply collapses to one increment per tick (fastest bounded ramp). - Defer to a larger current view distance. Each increment reads the player's current view distance and sets the target to the maximum of the ramp's next value and the current value; the ramp never shrinks a view distance that another tool (a render-distance plugin, an admin command, a resource-aware limiter) has set higher in the meantime. If the current value already meets or exceeds the ramp's final target, the ramp completes early and releases.
- Guaranteed teardown. The clamp/restore is session-scoped and tracked like a
MemoryTrackerallocation: registered on clamp, released on every exit path - normal completion, teleport failure, player disconnect mid-ramp, and shutdown//reloaddrain. Because per-player view distance is never persisted, a restart cannot leak a clamp; the teardown obligation is strictly within the live session.
Architecture placement:
- The clamp/restore lives behind a platform-neutral player-facing SPI pair
(
getViewDistance()/setViewDistance(int)) on the existing platform abstraction.rtp-coresees only the interface; each adapter (rtp-bukkit,rtp-paper,rtp-folia,rtp-fabric,rtp-neoforge) implements the actual call. A platform with no per-player view-distance API no-ops the feature. - The ramp task is scheduled exclusively through
RTP.schedulerin tick units. On Folia it targets the player's entity/region scheduler and is ownership-gated (Bukkit.isOwnedByCurrentRegion); no rawThreadorScheduledExecutorServiceis used. - No database persistence. Per-session runtime state only.
Alternatives Considered¶
| Alternative | Why Rejected |
|---|---|
Add three new keys (clampStart, restoreStep, restoreIntervalTicks) |
Over-configured. The preload radius is already the natural clamp start, and total-time semantics derive the step cadence, so one key suffices. |
viewDistanceRestoreInterval as a per-step interval |
A per-step value forces operators to reason about step count to predict total restore time; total-time semantics are more intuitive and let the step cadence adapt to the clamp delta automatically. |
| Persist pre-teleport view distance to the configured database | Per-player view distance is runtime-only and is re-negotiated to the server default on restart, so persistence buys nothing across restarts while adding DB I/O to the latency-sensitive teleport path. Reserved as a possible future enhancement only if a clamp is ever required to survive relogs by design. |
| Lower world/server view distance globally during teleports | Penalizes every player for one player's teleport; per-player clamp isolates the cost. |
| Clamp but restore instantly (single jump) | Re-introduces the very burst the feature exists to spread out, just delayed by the dwell time. |
| Always shrink to the ramp's scheduled value each step | Would fight other view-distance tools and could shrink a player below what an admin/plugin set; the "defer to larger current VD" rule avoids this. |
Uniform dwell per increment (stepInterval = interval / steps) |
Ignores that the chunk ring grows quadratically, so the last +1 increments deliver far more chunks than the first; equal time windows re-concentrate the heaviest bursts at the end. Chunk-cost-weighted dwell keeps chunks-per-tick roughly flat across the window. |
Consequences¶
- Positive: Caps and spreads the implicit placement/chunk-tracking burst the engine performs when the player is placed, across the restore window instead of a single tick, reducing teleport-induced TPS/MSPT stalls - most visibly on weaker hosts, on Folia destination region threads, and on high-population servers. This is the project's portable answer to "rate-limiting the post-teleport implicit loads," which cannot be throttled directly.
- Positive: One added config key with a sane default;
0disables. Reuses the existing preload radius, keeping the surface minimal and discoverable. - Positive: Session-scoped with no persistence keeps the feature platform-uniform - Fabric/NeoForge need not wire view distance into any persistence layer to benefit.
- Negative / Trade-offs: Players see render distance "grow in" over the restore window after arrival, a deliberate, visible UX trade for the performance gain.
- Negative / Trade-offs: Requires a per-platform
getViewDistance/setViewDistanceSPI implementation; platforms lacking a per-player API silently no-op. - Negative / Trade-offs: Adds a short-lived per-teleport scheduled task that must be reliably torn down on all exit paths to avoid a within-session clamp leak.
Addendum (2026-06-23): Send-View-Distance Pin to Eliminate the Arrival Flash¶
The original decision clamped and ramped a single per-player distance via
setViewDistance(int). In practice that distance is the value the client is told to
render, so every clamp and every ramp step sends the client a view-distance change; the
client re-evaluates its render ring and fog each time, producing a visible "flash" on
arrival and minor flicker during the ramp.
The fix separates the two concerns the platform actually exposes:
- The server-side tracking view distance (
setViewDistance(int)) governs what the server loads and tracks - this is what drives the arrival burst, so it is still what gets clamped and ramped. - The per-player send view distance (
setSendViewDistance(int)) governs what the client is told to render. Immediately before clamping the tracking distance, the helper pins the send distance to the captured pre-teleport value, so the client's negotiated render radius never changes while the tracking ring shrinks and grows. The pin is released (reset to follow the default, value-1) once the ramp reaches the target, defers to a larger external value, or the read fails; it is left untouched on disconnect so an offline player is never mutated.
SPI: a new platform-neutral pair getSendViewDistance() / setSendViewDistance(int) was
added to RTPPlayer (default no-op / -1 = unsupported). Paper-family adapters call
paper-api directly via a new PaperRTPPlayer (wired through an overridable wrapPlayer
factory on the Bukkit AbstractServerAccessor), and FoliaRTPPlayer calls folia-api
directly; both avoid reflection. On a platform without a separate send-view-distance API the
pin is a no-op and the feature degrades to the original tracking-only clamp behaviour (still
correct, just with the pre-fix flash).
References¶
rtp-core/.../configuration/enums/PerformanceKeys.java-viewDistanceTeleport,viewDistanceSelect.rtp-core/.../selection/region/Region.java- existingviewDistanceTeleportpreload consumption.rtp-core/.../tasks/teleport/TeleportPipelineTask.java- teleport completion hook point.helpers/StressTestRTP/.../ChunkLoadCounter.java- arrival-ring cost model used to quantify the burst.- ADR-008 (MemoryTracker active GC) - lifecycle-tracking pattern reused for clamp register/release.
- ADR-054 (RTPRunnable self-scheduling thread routing) - scheduler routing the ramp task relies on.
- Minimum renderable view distance of 2 chunks confirmed against Paper per-player view-distance range
[2, 32].