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)
