# v1.1.0 Complete rewrite of the nametag spawn/mount system. Nametags are now driven by actual client-side entity packets instead of guessing with fixed tick delays, with major performance improvements for high-player-count servers. --- ## Added ### Packet-Driven Nametag Spawning The entire nametag lifecycle is now driven by intercepting `SPAWN_ENTITY` and `DESTROY_ENTITIES` packets rather than relying on fixed tick delays after events. **Before:** When a player teleported, changed worlds, or joined, the plugin would hide their nametag and blindly wait 20 ticks before respawning it — hoping the client had processed the entity by then. Too early and the mount failed silently; too late and there was a visible gap with no nametag. **After:** The plugin intercepts the actual `SPAWN_ENTITY` packet that the server sends when a player entity appears on a viewer's client. The nametag is spawned and mounted 2 ticks after the entity packet, guaranteeing the client has the player entity before the mount arrives. | Event | Old Behavior | New Behavior | |---|---|---| | Player joins | 20-tick delay + 10-tick re-mount | Immediate on `PlayerClientLoadedWorldEvent` | | Teleport (cross-range) | `hideForAll()` + 20-tick `scheduleRefresh` | `DESTROY_ENTITIES` → hide, `SPAWN_ENTITY` → show | | Teleport (in-range) | `hideForAll()` + 20-tick `scheduleRefresh` | No action needed — mount persists naturally | | World change | `hideForAll()` + 20-tick `scheduleRefresh` | `DESTROY_ENTITIES` → hide, `SPAWN_ENTITY` → show | | Respawn | 5-tick delay | `showToEligible()` after 2 ticks | | Re-enter tracking range | 20-tick delay from `DESTROY_ENTITIES` handler | `SPAWN_ENTITY` → show after 2 ticks | The `PlayerTeleportEvent` and `PlayerChangedWorldEvent` handlers have been removed entirely — the packet layer handles both cases with deterministic timing. --- ### GSit / Player Riding Compatibility Nametags now survive when a player is mounted as a passenger on another entity — such as sitting, laying, or crawling via [GSit](https://modrinth.com/plugin/gsit), or riding another player. When the server sends a `SET_PASSENGERS` packet that lists a nametag's player as a **passenger** (not the vehicle), Sign re-sends the nametag mount packet after a 2-tick delay. This forces the client to re-attach the display entities that it silently drops when processing the new passenger relationship. A new `remount(Player viewer)` method on `Nametag` handles this efficiently — it only re-sends the `SET_PASSENGERS` packet without respawning or updating metadata, so there is no visual flicker. --- ### `showToEligible()` Method A new method that shows the nametag to all eligible players who don't already see it. Unlike the old approach of calling `updateVisibilityForAll()` (which would spawn nametags even when the player entity might not exist on the viewer's client), `showToEligible()` is only called from contexts where the entity is guaranteed to exist: - After initial nametag creation (`NametagManager.create()`) - After respawn (entity was never destroyed for other viewers) - After leaving spectator mode (entity re-enters tracking) --- ## Fixed ### Scheduler No Longer Pre-Spawns Nametags The periodic scheduler (`updateVisibilityForAll()`) previously called `show()` for any player where `shouldSee()` returned true, even when the player entity hadn't been sent to the viewer's client yet. This caused a race condition: 1. Scheduler sees player in range → calls `show()` → spawns display + sends mount referencing a player entity ID that doesn't exist on the client → mount fails silently 2. `SPAWN_ENTITY` packet fires later → handler calls `show()` → blocked by the duplicate viewer guard → nametag stays unmounted The scheduler now **only** updates text and hides out-of-range viewers. It never spawns new nametags. The `show()` method also has a guard preventing double-spawning if both the packet handler and another code path attempt to show the same viewer. ### Duplicate Viewer Guard in `show()` `show()` now returns early if the viewer is already in the viewers set. This prevents double-spawning from concurrent show paths (scheduler, packet handler, event handler) without visual side effects — the vanilla nametag is still hidden even when the guard triggers. --- ## Changed ### Scheduler Performance Optimization The scheduler's `updateVisibilityForAll()` has been significantly optimized for high-player-count servers: **String-level dirty checking.** Text resolution is now split into two phases: 1. `resolveToStrings()` — placeholder substitution and condition evaluation (cheap string operations) 2. `parseComponents()` — MiniMessage deserialization (expensive, only called when strings actually changed) Previously, every scheduler tick resolved placeholders **and** parsed MiniMessage for every nametag, then compared the resulting `Component` objects. With 100 players, that was 100+ full MiniMessage parse cycles per tick — **~11% of the server thread** according to profiler data. Now, when text hasn't changed (the common case), only cheap string comparisons run. **Early exit for unwatched nametags.** If a nametag has zero viewers, the scheduler skips all resolution and parsing work entirely. **No more all-player iteration.** The scheduler now only iterates the existing `viewers` set instead of `Bukkit.getOnlinePlayers()`. With 100 players spread across the map where each nametag has ~10 viewers, this reduces per-nametag iteration from 100 to 10. **`sendMetadataUpdate()`.** When updating non-override viewers, the scheduler sends the current display state directly without re-resolving text. The displays already have the correct text from the dirty check phase. Override viewers still use `update()` which resolves their specific text (rare path). **Static `DecimalFormat`.** The `new DecimalFormat("#.##")` allocation that ran per line per nametag per tick is now a static constant. ### Removed `inRange` Tracking Set The `inRange` set that tracked players within visibility distance (separate from `viewers`) has been removed. It was used for a "re-entered range" respawn path that is now handled deterministically by `DESTROY_ENTITIES` → `SPAWN_ENTITY` packets. ### Removed Debounce Infrastructure The `pendingRefreshes`, `pendingTeleportPlayers`, `scheduleRefresh()`, `cancelPendingRefresh()`, and `hasPendingRefresh()` systems in `PlayerListener` have been removed. The packet-driven approach eliminates the need for debounced delayed tasks — each packet event is self-contained. --- ## Developer Notes ### Spawn Timing Guarantees Nametag spawning is now tied to the actual `SPAWN_ENTITY` packet the server sends to each viewer. If you're calling `show()` from your own code, ensure the player entity exists on the target viewer's client — otherwise the mount will fail silently. Use `showToEligible()` when you know the entity exists for nearby viewers (e.g., after creating a nametag for a player who is already in the world). ### `updateVisibilityForAll()` Behavior Change This method no longer spawns nametags for new viewers. It only: - Sends text/metadata updates to existing viewers when dirty - Hides viewers who are no longer eligible (dead, invisible, spectator, out of range, different world) If you were relying on `updateVisibilityForAll()` to show nametags to new viewers, use `showToEligible()` instead. ### `remount(Player viewer)` A new public method on `Nametag` (not in `INametag` API) that re-sends the mount packet without respawning display entities. Useful if you know a viewer's client has dropped the passenger relationship: ```java Nametag nametag = (Nametag) SignAPI.getNametagManager().get(player); nametag.remount(viewer); ``` --- ## Links - [Modrinth](https://modrinth.com/plugin/sign) - [Documentation](https://lode.gg/sign)