Tag and attribute replication occurs in the same frame, overriding the client's actions with both. How do I reliably sidestep this?

My game has an options menu which includes hiding a user’s trophies (attachable models to the character) and I am currently programming that option to work. Unfortunately, I’ve been toiling with it for several days despite it sounding really simple to accomplish, so I require help.

The main problem is that irrespective of the system I use, it seems the main problem comes down to how Roblox replicates tags and attributes. To give a rundown: both the server and the client can give any instance these. Client tags are overwritten by the server’s list before replication and the server’s value for an attribute overrides the client’s for an attribute before replication.

Now I thought that tag and attribute replication would be completely separate but they aren’t. The server is overriding attributes as well even though it does not ever configure attributes in this system, only the attachment of a new tag to register an active trophy. This leaves me at a standstill.

I won’t explain all the pipelines I’ve tried because they basically rehash each other with the same root problem at hand, which could tread on XY grounds. I’ll tell you what I am doing now though: waiting a frame before setting transparency. This works 75% of the time, the other 25% unworking times being that some parts are left unhidden when the user changes trophies.

Here is the code I am working with. Would appreciate any steps I could take to not wait a frame and instead have my module immediately and reliably be able to hide models when they’re tagged/enter the DataModel with specific conditions.

Specific problematic code:

--- Determine if new trophies should be hidden based on user settings
-- @param object the trophy object
local function onTrophyAdded(object)
	-- Wait a frame because of inbound server replication on attributes/tags
	-- Overrides the client immediately trying to set the tag, thus not hiding the trophy
	Stepped:Wait()
	
	-- In-line connection is required for object access
	object:GetAttributeChangedSignal(VISIBILITY_ATTRIBUTE):Connect(function ()
		setTrophyVisibility(object, object:GetAttribute(VISIBILITY_ATTRIBUTE))
	end)
	
	if (DataValues.Options.ShowTrophiesSelf == false and object:IsDescendantOf(Character)) or (DataValues.Options.ShowTrophiesOthers == false and not object:IsDescendantOf(Character)) then
		object:SetAttribute(VISIBILITY_ATTRIBUTE, false)
	end
end

Full TrophyVisibilityHandler module:

--- Independent system controlling the visibility of trophies.
-- Handled on a per-trophy basis by the client based on their attributes.
-- @module TrophyVisibilityHandler
-- @author colbert2677

local Players = game:GetService("Players")
local CollectionService = game:GetService("CollectionService")

local LocalPlayer = Players.LocalPlayer
local Character = LocalPlayer.Character or LocalPlayer.CharacterAdded:Wait()
local Stepped = game:GetService("RunService").Stepped

-- This is a table. If you need to create a repro in Studio,
-- create a module and change the require path. The module
-- should contain the code as linked below this code block.
local DataValues = require(script.Parent.Parent.DataValues)

local TrophyVisibilityHandler = {}

local STRING_SUB = string.sub
local STRING_LEN = string.len

local TROPHY_TAG = "Trophy"
local VISIBILITY_ATTRIBUTE = "TrophyVisible"
local TROPHY_OPTION_PREFIX = "ShowTrophies"

local REFRESH_INTERVAL = 3

local steppedTime = 0

--- Change visibility of trophy instances by class
-- @param trophy instance to act on
-- @param visible a boolean representing the visibility value
local function setTrophyVisibility(trophy, visible)
	for _, object in ipairs(trophy:GetDescendants()) do
		-- Crude class check if-chain to take advantage of IsA
		-- Class dictionary would require direct class checking which is not advantageous
		if object:IsA("BasePart") or object:IsA("Decal") then
			object.LocalTransparencyModifier = (visible == true and 0 or 1)
		elseif object:IsA("GuiBase3d") or object:IsA("Constraint") then
			object.Visible = visible
		elseif object:IsA("LayerCollector") or object:IsA("Beam") or object:IsA("ParticleEmitter") or object:IsA("Trail") or object:IsA("Light") then
			object.Enabled = visible
		elseif object:IsA("Fire") or object:IsA("Smoke") or object:IsA("Sparkles") then
			object.Enabled = visible
		end
	end
end

--- Update the trophy visibility routinely to catch visibility artifacts
-- Catches trophies that don't become fully invisible the first time
-- @see RunService.Heartbeat
local function updateStep(deltaTime)
	steppedTime += deltaTime
	
	if steppedTime >= REFRESH_INTERVAL then
		steppedTime = 0
		
		for _, trophy in ipairs(CollectionService:GetTagged(TROPHY_TAG)) do
			setTrophyVisibility(trophy, trophy:GetAttribute(VISIBILITY_ATTRIBUTE))
		end
	end
end

--- Refresh character variable
-- @param newCharacter incoming character
local function onCharacterAdded(newCharacter)
	Character = newCharacter
end

--- Call a method in TrophyVisibilityHandler based on parameters of OptionChanged
-- @see DataValues.OptionChanged
local function onOptionChanged(option, value)
	if STRING_SUB(option, 1, STRING_LEN(TROPHY_OPTION_PREFIX)) == TROPHY_OPTION_PREFIX then
		local mode = STRING_SUB(option, (STRING_LEN(TROPHY_OPTION_PREFIX) + 1))
		TrophyVisibilityHandler[value == true and "ShowTrophy" or "HideTrophy"](mode)
	end
end

--- Determine if new trophies should be hidden based on user settings
-- @param object the trophy object
local function onTrophyAdded(object)
	-- Wait a frame because of inbound server replication on attributes/tags
	-- Overrides the client immediately trying to set the tag, thus not hiding the trophy
	Stepped:Wait()
	
	-- In-line connection is required for object access
	object:GetAttributeChangedSignal(VISIBILITY_ATTRIBUTE):Connect(function ()
		setTrophyVisibility(object, object:GetAttribute(VISIBILITY_ATTRIBUTE))
	end)
	
	if (DataValues.Options.ShowTrophiesSelf == false and object:IsDescendantOf(Character)) or (DataValues.Options.ShowTrophiesOthers == false and not object:IsDescendantOf(Character)) then
		object:SetAttribute(VISIBILITY_ATTRIBUTE, false)
	end
end

--- Exposed method for showing trophies
-- @param mode act on the player's trophy or those of others
function TrophyVisibilityHandler.ShowTrophy(mode)
	if mode == "Self" then
		local equippedTrophy = Character:FindFirstChild("Trophy")
		if equippedTrophy then
			equippedTrophy:SetAttribute(VISIBILITY_ATTRIBUTE, true)
		end
	elseif mode == "Others" then
		for _, trophy in ipairs(CollectionService:GetTagged(TROPHY_TAG)) do
			if trophy:IsDescendantOf(Character) then continue end

			trophy:SetAttribute(VISIBILITY_ATTRIBUTE, true)
		end
	end
end

--- Exposed method for hiding trophies
-- @see TrophyVisibilityHandler.ShowTrophy
function TrophyVisibilityHandler.HideTrophy(mode)
	if mode == "Self" then
		local equippedTrophy = Character:FindFirstChild("Trophy")
		if equippedTrophy then
			equippedTrophy:SetAttribute(VISIBILITY_ATTRIBUTE, false)
		end
	elseif mode == "Others" then
		for _, trophy in ipairs(CollectionService:GetTagged(TROPHY_TAG)) do
			if trophy:IsDescendantOf(Character) then continue end

			trophy:SetAttribute(VISIBILITY_ATTRIBUTE, false)
		end
	end
end

LocalPlayer.CharacterAdded:Connect(onCharacterAdded)
DataValues.OptionChanged:Connect(onOptionChanged)
CollectionService:GetInstanceAddedSignal(TROPHY_TAG):Connect(onTrophyAdded)
Stepped:Connect(updateStep)

-- Only need to act initially if the user option is false
if DataValues.Options.ShowTrophiesSelf == false then
	TrophyVisibilityHandler.HideTrophy("Self")
end

if DataValues.Options.ShowTrophiesOthers == false then
	TrophyVisibilityHandler.HideTrophy("Others")
end

return TrophyVisibilityHandler

DataValues module code:

local optionChangedEvent = Instance.new("BindableEvent")

local DataValues = {
	UserOptions = {
		ShowTrophiesSelf = false,
		ShowTrophiesOthers = false,
	}
}

local optionsAccessProxy = setmetatable({}, {
	__index = function(_, key)
		if key == "All" then
			return data.UserOptions
		else
			return data.UserOptions[key]
		end
	end,
	__newindex = function(_, key, value)
		data.UserOptions[key] = value
		optionChangedEvent:Fire(key, value)
	end
})

DataValues.Options = optionsAccessProxy
DataValues.OptionChanged = optionChangedEvent.Event

return DataValues

This is all client-side code. The server only has three responsibilities in this system: cloning, welding and tagging a model under any player character.


More explanation will be given on request. Please don’t reply if you aren’t sure what you’re talking about, I need substantiated answers. Thank you!

1 Like

I’ve resolved this by not using attributes. Somewhat unfortunate in case other scripts want to set trophy visibility directly by toggling the attributes, but in my use case those circumstances are probably little to none so I should be fine even by doing this.

I now only use CollectionService to register new trophies on the server’s end and the client listens for new trophies via GetInstanceAddedSignal. In all other cases, I’m now directly calling setTrophyVisibility instead of using attributes or any changed signals.

Wouldn’t consider it a reliable or elegant solution but a workable one and I’ll have to run with this for now until I can find a better way to use tags and attributes together without the replication overrides. An update is also coming up and I wouldn’t want this bug to affect our launch.

Leaving up for information. Also a bit of a PSA that attributes and tags do not work well together if your system is dynamic like this. A more static system might see better unity between the two.

Damn, I was looking forward to (ab)using attributes, but they weren’t looking forward to being used by me. First system with them already caused me trouble.

Answer changed because I may be stupid. It is my own code. I’ve also deleted my post on the announcement thread so I can keep all my brain explosion discoveries confined to this thread.

(No, removing attributes from the equation did not resolve my issue.)

There’s a certain pattern developers may typically use when expecting new instances to be added to a container, such as Players. First you connect to the added event for new objects, then run it on existing objects. Never occurred to me this pattern should be used for replication too.

I keep on making breakthroughs as I work through this bug and it turns out every time that it really is just my own silly oversights and not some issue with the engine. Every single iteration of this system I have ever written has been completely fine except for my lack of accounting of instances that may not be available when the model is due to replication. So I’m going to go fix that now.

I should be back with another update regarding tackling this replication issue and how I’ve worked around it. I will also, if that’s successful, try again with attributes and see if the overriding behaviour is true or incorrect information I have. If my system works in both ways, then I’ve been completely and wholly incorrect in the OP.

That did the trick. The information I had to start off of with was false. To begin with, I took my own speculations as true because of the occurring behaviours without deeper research. The misfortune of not taking my own advice to heart in the moment.

pre tl;dr My code was correct, but not correct enough. The cause of the code’s failure was failing to account for replication and descendants accessibility.

I reverted to my original attribute-based code but accounted for new descendants on trophy models and did a bit of configurations regarding initial state handling. Sure enough, these edits did the trick. The toggle now works perfectly, the user’s trophy visibility preference applies to new/swapped trophies and artifacts are gone (some parts aren’t left unhidden).

Here’s some notes:

  • Tag and attribute replication is not tied together, they can work dynamically perfectly. The server still has precedence over an attribute’s values or the list of tags on an object, but you can respect replication and have a beautiful unity with these two powerful features.

  • I need to follow my own advice more. This issue was born out of a lack of more extensive debugging and reaching the wrong conclusion from an incomplete test.

    • I did not think to print the number of descendants until I narrowed down my problem to the for loop not running despite the code being valid. When I did so and got 0, it clicked that the trophy’s descendants hadn’t arrived to the client yet.

    • As my code ran well for everything except attribute and tag management, I believed that the cause here was the server overriding the client’s attributes and tags during inbound replication but I hadn’t actually checked anything beyond that.

  • It’s good I fixed the attribute code and not the tags-only code. The issue of replication overrides was valid in the case of tags because they’re replicated as a list rather than individually, so the client was overridden when they immediately added a tag. Because the tag check came back false, I assumed the same case would hold for attributes. It doesn’t. tl;dr Tags replicated as a list, attributes replicated per attribute. Server does not touch attributes, therefore has no business replicating anything regarding attributes.

As I thought, attributes really are my friend. My brain fries early in the morning are not.

1 Like