Multiplayer Desyncs and You

Welcome to this very special tutorial about the dangers of Lua.

Lua is a beautiful piece of machinery that, if used incorrectly, will cause an incredible amount of anger via Steam users, the size of which mob has never before been seen. With some bad luck and bad coding, you can cause a panic through the TW multiplayer campaign community – all six of ’em – and get a deathly riot at your doorstep.

But that’s where this guide you’re already reading comes in! By the end of this wall of text, you might even learn a thing or two.

Why and How Desync

Before we get into how to resolve the desyncs and keep our code from ever being volatile, we should talk about what a desync is, and how they happen.

If you’re one of the aforementioned six multiplayer campaign players who uses mods, you’re probably incredibly familiar with the concept of desyncs. Throughout the course of a multiplayer campaign, there will be an annoying popup box that says “Desync Detected – BEGINNING EXTERMINATION” (paraphrasing). At that point, the game will just kinda stop.

A desync is what happens when the two players’ PC’s decide that different things are happening at the same time – they are no long in sync (not since 2002!). In Total War, both PC’s are playing, technically, different instances of the game, and the game is sending messages back and forth between the two clients to make sure everything is the same on both PC’s. This stuff is usually handled pretty well by the C++ base-code – but, bad Lua coding can cause a desync!

A desync will happen if you cause an edit to the model on one computer without doing the same edit on the other computer. If conditions are different between the two computers, and one performs a model edit and the other doesn’t – kaput, no more synchronization.

Note: A “model” edit is something that makes an edit to the game itself, ie., spawning an army, applying an effect bundle. Doing something like outputting text to a .txt file, or making UI edits will not cause a desync.

Thankfully, there are two really common – and really simple to fix – situations where this might happen: making model edits based on cm:get_local_faction(true)‘s result, and making model edits based on “ComponentX” or “XSelected” events. I’m gonna cover each now and explain how to handle both!

get_local_faction

So, cm:get_local_faction() is a pretty simple function – literally all it does is read what the “local” player’s faction key is. By local player, I mean the player on that instance of the game – the player on that computer.

If I’m playing MP with my buddy, and I’m playing as Reikland, and they’re playing as Hexoatl, and we both run a script that has the following:

cm:add_first_tick_callback(function()
	out(cm:get_local_faction(true))
end)

It would print wh_main_emp_empire on my computer, and whatever_hex's_key_is on my buddy’s. And that’s the end of my explanation of that function.

If you read that value, and decide to make a model edit as a result of its return value, it will desync. See:

cm:add_first_tick_callback(function()
	if cm:get_local_faction(true) == "wh_main_emp_empire" then
    	-- spawn a big big army
    end
end)

In this case, my PC would try to spawn a big big army, but my friend’s PC will not want to spawn a big big army, because they aren’t playing as Da Emprah.

Thankfully – it’s incredibly simple to fix! There are two pretty fast ways.

1) Use cm:get_local_faction(false) or cm:get_local_faction() when you want stuff to not fire in MP, and cm:get_local_faction(true) only when you’re 100% certain it’s MP-safe (not a model edit).

The optional arg in get_local_faction() determines whether the function will be run in mp or not; if it’s set to false, or nil, then it’ll always return false, causing any if statements based on it to return false.

2) Instead of reading for cm:get_local_faction(true) == "faction_key", use cm:get_faction("faction_key"):is_human(). Instead of checking to see if this PC has this faction, just see if any PC has this faction, and it’s suddenly very MP safe!

You should only really be using cm:get_local_faction() for singleplayer stuff, or for making any sort of UI edits for only specific factions.

Speaking of UI!

The above section is probably gonna cover the vast, vast majority of you. If you’re one of the brave pioneers doing UI modding, this part is pertinent to you. It’s definitely significantly more complicated than the above, but it’s doable!

Essentially – the issue here is that the “ComponentX” events (such as “ComponentLClickUp”, “ComponentMouseOn”) and “XSelected” (such as “SettlementSelected” or “CharacterSelected”) only fire on the PC that the action was taken on. So if I, Emprah Franzipants, click on a component, it’ll only trigger an event on my PC and not my buddy’s. There are many good reasons for this which I don’t really want to get into now, so I won’t.

Say you have a button and you want to spawn a unit into an army with it. If all you listen for is the ComponentLClickUp event, the game will desync without batting an eyelash.

For a quick for-instance, in Return of the Lichemaster, I have the following code (cut down for simplicity):

core:add_listener(
	"LichemasterSpawnLegion",
	"ComponentLClickUp",
	function(context)
		return context.string == "lichemaster_spawn_button"
	end,
	function(context)
		local selected_cqi = lm:get_character_selected_cqi()
		local key = lm:get_selected_legion()
		if key == "" then
			return
		end
                    
		lm:spawn_ror_for_character(selected_cqi, key)
	end,
	false
)

On lm:spawn_ror_for_character(), I’ve gotta spawn a unit (defined by key, read by which UI component was previously clicked) into a specific army (led by the character defined by selected_cqi). But, since I’m reacting to a UI click, I can’t just directly spawn it – it would immediately desync. So, I do a thing and I send a message to the other PC:

CampaignUI.TriggerCampaignScriptEvent(selected_cqi, "lichemanager_ror|"..key)

This function definitely looks like a lot at first glance, but it’s really, really, really simple – all it does is send a message to both PC’s to react to with a traditional listener. The message contains one string and one number. By convention, that number should be a CQI, but sometimes I don’t even use the number while using this function, depending on need.

To react to these messages, you can use a listener for a “UITriggerScriptEvent”. The context of that event contains the string – context:trigger() – and the number – context:faction_cqi(). It’s called faction_cqi() by convention, since typically that’s how it’s been used by CA, but again, the number can be anything you want.

To finish out this section, I’ll show how I react to this specific event with a listener (again, cut down to keep it understandable):

core:add_listener(
	"LichemasterLegionOfUndeathSpawn",
	"UITriggerScriptEvent",
	function(context)
		return context:trigger():starts_with("lichemanager_ror|")
	end,
	function(context)
		local str = context:trigger()
		local char_cqi = context:faction_cqi()
		local regiment_key = string.gsub(str, "lichemanager_ror|", "")
    
        -- add the unit and charge the -5 NP
		cm:grant_unit_to_character("character_cqi:"..char_cqi, regiment_key)
        cm:faction_add_pooled_resource(legion, "necropower", "necropower_ror", -5)
    
	end,
	true
)

And that’s pretty much it! You can pass really big strings and pass a lot of data between the PC’s. There is a limit to how much data you can send this way, but it’s pretty high – Lua can understand and very large strings, you shouldn’t get close to hitting that string limit.

Conclusions

There are likely some other very rare cases where desyncs might happen, but if you avoid these two specific issues, you’re pretty assured to avoid almost all possible desyncs from your own Lua code. They’re the only two issues I’ve ever hit, and resolving these two issues has made basically all my stuff pretty MP compat!

Leave a Reply

Close Menu