Team Tag UI Drifts from Player Position at a Distance

Hey everyone,

I’m working on a client-sided team tag system, and I’m running into a frustrating positioning issue that I can’t seem to solve.
I want to create a 2D UI element (a small diamond icon with a text label for the player’s name) that accurately and smoothly tracks a teammate’s HumanoidRootPart in the 3D world. This tag should stick to the player’s position on-screen, regardless of their distance from the camera.
The system works reasonably well when the tracked player is close to the camera. However, as the player moves further away, the UI tag begins to drift and appears disconnected from the player’s actual model. It floats above or away from them, and the inaccuracy gets worse with distance.
Here’s a screenshot showing the problem. The white diamond tag should be positioned directly over the distant player character, but it has drifted significantly upwards and away.

The core problem is that the UI doesn’t feel “stuck” to the 3D world position as it should.
I’ve been tackling this for a while and have tried several common solutions found on the Creator Hub and other forums:

  • WorldToViewportPoint: My core logic uses Camera:WorldToViewportPoint() to convert the 3D world position to a 2D screen coordinate. This is updated every frame on RunService.RenderStepped.

  • Anchor Points: I’ve experimented extensively with the AnchorPoint of the UI elements. I’ve tried centering the main container (0.5, 0.5) and anchoring it to the top-center (0.5, 0) to align the diamond icon, but the drift issue persists in both cases.

  • Positioning Logic: I’ve refined the logic to handle cases where players are off-screen or behind the camera, but this hasn’t fixed the core positioning problem for on-screen players at a distance.

  • Roact Implementation: The UI is built with Roact. the position prop is passed down correctly and the component re-renders every frame with the new coordinates.

local Players = game:GetService("Players")
local RunService = game:GetService("RunService")
local UserInputService = game:GetService("UserInputService")
local ReplicatedStorage = game:GetService("ReplicatedStorage")
local TweenService = game:GetService("TweenService")

local Roact = require(ReplicatedStorage:WaitForChild("Roact"))
local config = require(ReplicatedStorage.SPH_Assets.GameConfig)

local player = Players.LocalPlayer
local camera = workspace.CurrentCamera
local character = player.Character or player.CharacterAdded:Wait()
local humanoid = character:WaitForChild("Humanoid")

-- Early exit if team tags are disabled
if not config.teamTagsEnabled then
    return
end

-- State management
local teamTags = {}
local isAiming = false

-- Utility functions
local function lerp(a, b, t)
    return a + (b - a) * t
end

local function getDistanceFromCenter(screenPos, viewportSize)
    local centerX, centerY = viewportSize.X / 2, viewportSize.Y / 2
    local distanceX = math.abs(screenPos.X - centerX) / centerX
    local distanceY = math.abs(screenPos.Y - centerY) / centerY
    return math.sqrt(distanceX^2 + distanceY^2)
end

local function isPlayerFriend(targetPlayer)
    if targetPlayer == player then return false end
    return player:IsFriendsWith(targetPlayer.UserId)
end

local function isPlayerTeammate(targetPlayer)
    if targetPlayer == player then return false end
    if not player.Team or not targetPlayer.Team then return false end
    return player.Team == targetPlayer.Team
end

local function worldToScreenPosition(worldPosition)
    local screenPos, onScreen = camera:WorldToViewportPoint(worldPosition)
    -- Simply return the raw data; the update logic will handle visibility.
    -- This prevents tags from sticking to the screen edge.
    return screenPos, onScreen and screenPos.Z > 0, camera.ViewportSize
end

-- Player Tag Component
local PlayerTagComponent = Roact.Component:extend("PlayerTagComponent")

function PlayerTagComponent:render()
    local props = self.props
    
    local isFriend = isPlayerFriend(props.player)
    local color = isFriend and config.teamTagFriendColor or config.teamTagTeammateColor
    
    -- Calculate transparency values
    local diamondTransparency = lerp(config.teamTagDiamondTransparencyRange[1], config.teamTagDiamondTransparencyRange[2], props.transparency)
    local textTransparency = lerp(config.teamTagTextTransparencyRange[1], config.teamTagTextTransparencyRange[2], props.transparency)
    
    return Roact.createElement("Frame", {
        -- Container anchored at top-center to ensure diamond sticks to the target position
        AnchorPoint = Vector2.new(0.5, 0),
        Position = props.position,
        Size = UDim2.new(0, 100, 0, 50), -- A generous container, children are positioned relative to top
        BackgroundTransparency = 1,
        BorderSizePixel = 0,
    }, {
        Diamond = props.visible and Roact.createElement("Frame", {
            Position = UDim2.new(0.5, 0, 0, 0), -- Positioned at the top-center of the container
            AnchorPoint = Vector2.new(0.5, 0.5), -- Center of diamond is at the position
            Size = UDim2.new(0, config.teamTagDiamondSize, 0, config.teamTagDiamondSize),
            BackgroundColor3 = color,
            BackgroundTransparency = diamondTransparency,
            BorderSizePixel = 0,
            Rotation = 45, -- Rotate 45 degrees to make diamond
        }) or nil,
        
        PlayerName = (not props.isAiming and props.visible) and Roact.createElement("TextLabel", {
            Position = UDim2.new(0.5, 0, 0, config.teamTagTextOffset), -- Positioned below the diamond
            AnchorPoint = Vector2.new(0.5, 0), -- Anchored from its own top-center
            Size = UDim2.new(0, 100, 0, config.teamTagTextSize),
            BackgroundTransparency = 1,
            Text = props.player.DisplayName or props.player.Name,
            TextColor3 = color,
            TextTransparency = textTransparency,
            TextSize = config.teamTagTextSize,
            TextScaled = false,
            Font = Enum.Font.SourceSans,
            TextStrokeTransparency = math.min(textTransparency + 0.3, 1),
            TextStrokeColor3 = Color3.new(0, 0, 0),
        }) or nil
    })
end

-- Tag Management System
local function createTagForPlayer(targetPlayer)
    if teamTags[targetPlayer] then return end
    
    local tagGui = Instance.new("ScreenGui")
    tagGui.Name = "TeamTag_" .. targetPlayer.Name
    tagGui.Parent = player.PlayerGui
    tagGui.ResetOnSpawn = false
    
    local component = Roact.createElement(PlayerTagComponent, {
        player = targetPlayer,
        position = UDim2.new(10, 0, 10, 0),
        transparency = 1,
        visible = false,
        isAiming = false
    })
    
    local handle = Roact.mount(component, tagGui)
    
    teamTags[targetPlayer] = {
        gui = tagGui,
        handle = handle,
        character = nil,
        humanoidRootPart = nil,
        head = nil
    }
end

local function removeTagForPlayer(targetPlayer)
    local tagData = teamTags[targetPlayer]
    if not tagData then return end
    
    Roact.unmount(tagData.handle)
    tagData.gui:Destroy()
    teamTags[targetPlayer] = nil
end

local function updatePlayerTag(targetPlayer)
    local tagData = teamTags[targetPlayer]
    if not tagData then return end
    
    -- Update cached references if character changed
    if targetPlayer.Character ~= tagData.character then
        tagData.character = targetPlayer.Character
        if tagData.character then
            tagData.humanoidRootPart = tagData.character:FindFirstChild("HumanoidRootPart")
            tagData.head = tagData.character:FindFirstChild("Head")
        else
            tagData.humanoidRootPart = nil
            tagData.head = nil
        end
    end
    
    -- Default to off-screen
    local position = UDim2.new(10, 0, 10, 0)
    local visible = false
    local finalTransparency = 1
    
    local targetPart = tagData.head or tagData.humanoidRootPart
    if targetPart then
        local worldPosition = targetPart.Position + Vector3.new(0, tagData.head and 2.5 or 4, 0)
        local screenPos, onScreenTest = camera:WorldToViewportPoint(worldPosition)
        
        if onScreenTest and screenPos.Z > 0 then
            position = UDim2.new(0, screenPos.X, 0, screenPos.Y)
            
            -- Transparency calculations
            local viewportSize = camera.ViewportSize
            local screenCenter = viewportSize / 2
            local screenDist = (Vector2.new(screenPos.X, screenPos.Y) - screenCenter).Magnitude
            local fadeDistance = isAiming and 400 or 200
            local centerFadeRatio = 1 - math.clamp(screenDist / fadeDistance, 0, 1)

            local worldDist = (camera.CFrame.Position - targetPart.Position).Magnitude
            local worldFadeRatio = math.clamp(worldDist / config.teamTagMaxDistance, 0, 1)
            
            finalTransparency = math.max(worldFadeRatio, centerFadeRatio)
            
            visible = finalTransparency < 0.95
        end
    end
    
    -- Update the component with new state
    local newComponent = Roact.createElement(PlayerTagComponent, {
        player = targetPlayer,
        position = position,
        transparency = finalTransparency,
        visible = visible,
        isAiming = isAiming
    })
    
    tagData.handle = Roact.update(tagData.handle, newComponent)
end

local function updateAllTags()
    for targetPlayer, _ in pairs(teamTags) do
        updatePlayerTag(targetPlayer)
    end
end

local function onPlayerAdded(targetPlayer)
    if targetPlayer == player then return end
    
    local function onCharacterAdded()
        if isPlayerTeammate(targetPlayer) then
            createTagForPlayer(targetPlayer)
        end
    end
    
    targetPlayer.CharacterAdded:Connect(onCharacterAdded)
    if targetPlayer.Character then
        onCharacterAdded()
    end
end

local function onPlayerRemoving(targetPlayer)
    removeTagForPlayer(targetPlayer)
end

-- Team change detection
local function onTeamChanged()
    -- Remove all existing tags
    for targetPlayer, _ in pairs(teamTags) do
        removeTagForPlayer(targetPlayer)
    end
    
    -- Recreate tags for new teammates
    for _, targetPlayer in pairs(Players:GetPlayers()) do
        if targetPlayer ~= player and targetPlayer.Character and isPlayerTeammate(targetPlayer) then
            createTagForPlayer(targetPlayer)
        end
    end
end

-- Aiming state detection
local function updateAimingState()
    local newAimingState = character:GetAttribute("Aiming") or false
    if newAimingState ~= isAiming then
        isAiming = newAimingState
    end
end

-- Initialize system
local function initialize()
    -- Connect to player events
    Players.PlayerAdded:Connect(onPlayerAdded)
    Players.PlayerRemoving:Connect(onPlayerRemoving)
    
    -- Initialize existing players
    for _, targetPlayer in pairs(Players:GetPlayers()) do
        onPlayerAdded(targetPlayer)
    end
    
    -- Monitor team changes
    player:GetPropertyChangedSignal("Team"):Connect(onTeamChanged)
    
    -- Update loop - runs every frame for smooth tracking
    RunService.RenderStepped:Connect(function()
        updateAimingState()
        updateAllTags()
    end)
end

-- Start the system
initialize()

-- Cleanup on character respawn
game.Players.LocalPlayer.CharacterRemoving:Connect(function()
    for targetPlayer, _ in pairs(teamTags) do
        removeTagForPlayer(targetPlayer)
    end
    teamTags = {}
end)