• 🏆 Texturing Contest #33 is OPEN! Contestants must re-texture a SD unit model found in-game (Warcraft 3 Classic), recreating the unit into a peaceful NPC version. 🔗Click here to enter!
  • It's time for the first HD Modeling Contest of 2024. Join the theme discussion for Hive's HD Modeling Contest #6! Click here to post your idea!

[Lua] SyncedTable

Level 20
Joined
Jul 10, 2009
Messages
478

Overview

Code

Restrictions

Description

In general, iterating through a Lua-table via pairs() is not multiplayer-safe, because iteration order is not guaranteed to be the same for different players. Thus, manipulating game state inside a pairs()-loop can lead to desyncs.

The SyncedTable-"library" (well, it only provides two functions) offers the possibility to create synchronous tables, for which the pairs()-iteration is guaranteed to have the same order for every player in a multiplayer game, no matter which keys you use.
Apart from the synchronized pairs-iteration, SyncedTables behave just as normal tables. They can contain any (key, value)-pair, you can write table[key] = nil to remove an element, table[key] = value to add or change an element, and you can use it in any function that takes a table, such as table.sort() or ipairs().

You can find the pseudo-solution orderedPairs on lua-users.org. It basically sorts all keys in the given table before every loop to make it synchronous. That "solution" doesn't only add much overhead to every loop (because it sorts before every loop), but it also doesn't work on tables using a keyset that can't be entirely sorted via the "<"-relation.
That means, it doesn't work for tables with mixed keys (e.g. {[1] = true, x = true}) and it also doesn't work, if you use keys other than strings and numbers. Unfortunately, this happens quite quickly in Wc3, when we use units, players, etc. as keys for a table.
Yes, the same website offers another multitype-compatible orderedKeys option, but that only works in singleplayer (where it guarantees to have the same iteration order in every iteration), but not in multiplayer (where it still desyncs, because the tostring()-function yields different results for different players).

Note that iterating over a SyncedTable is pretty fast, but the implementation adds a small overhead to adding, changing and removing elements instead. Thus, only use a SyncedTable, if you actually plan to iterate over it.

Installation

Just copy the code from the "Code" tab into your map and you are ready to go.

How to use

To create a SyncedTable, simply write <varName> = SyncedTable.create() or even shorter, <varName> = SyncedTable() .
After creation, the SyncedTable behaves just as any other table, apart from some minor restrictions, see "Restrictions" tab.
You can even specify to create the SyncedTable from an existing Table. This allows convenient table constructor usage, such as a = SyncedTable{'Hello World', [3] = true, n = 10}. However, using an existingTable requires all of its keys to be strings or numbers (or any object having the __lt-metamethod defined), i.e. anything with a natural order. If you want to add any other key to a SyncedTable, you have to do it after creation (and it will work just fine).

Secondly, the library offers the function SyncedTable.isSyncedTable(anyObject) -->true or false to check, if anyObject is a SyncedTable or not.

Example Code
Lua:
--Example 1:
local playersGold = SyncedTable.create() --create a SyncedTable. From now on, it can be used just like any other table.
for i = 0, 23 do
    if GetPlayerSlotState(Player(i)) == PLAYER_SLOT_STATE_PLAYING then
        playersGold[Player(i)] = 100
    end
end

for player, gold in pairs(playersGold) do --iteration order is the same for every player
    SetPlayerStateBJ( player, PLAYER_STATE_RESOURCE_GOLD, gold)
end

--Example 2: Using standard table constructor
local alphabet = SyncedTable{'a', 'b', 'c', a = 1, b = 2, c = 3} --Creating a SyncedTable from an existingTable is allowed, when all keys are strings or numbers.
alphabet.d = 4 --You can still add more elements afterwards (of course, it's a table afterall...), and the keys can have any type.
alphabet[4] = 'd'

for k,v in pairs(alphabet) do --iteration order is the same for every player. Granted, the print function wouldn't desync even for asynchronous iteration ;)
    print(k,v)
end
Lua:
--SyncedTable v1.0 by Eikonium. https://www.hiveworkshop.com/threads/lua-syncedtable.332894/

do
    --comparison function that allows sorting a set of objects that already have a natural order.
    local function comparisonFunc(a,b)
        local t1,t2 = type(a), type(b)
        if t1 == t2 then
            return a<b
        else
            return t1 < t2
        end
    end

    ---@class SyncedTable
    SyncedTable = {}

    ---Creates a table with a multiplayer-synchronized pairs-function, i.e. you can iterate over it via pairs(table) without fearing desyncs.
    ---After creation, you can use it like any other table.
    ---The implementation adds overhead to creating the table, adding and removing elements, but keeps the loop itself very performant. So you obviously should only used syncedTables, if you plan to iterate over it.
    ---You are both allowed to add and remove elements during a pairs()-loop.
    ---Specifying an existing table as input parameter will convert that table to a syncedTable. This only works for input tables, where all keys are sortable via the "<"-relation, i.e. numbers, strings and objects listening to some __lt-metamethod.
    ---@param existingTable? table any lua table you want to convert to a syncedTable. The table is required to only contain keys that can be sorted via the '<'-relation. E.g. you might write SyncedTable.create{x = 10, y = 3}.
    ---@return SyncedTable
    function SyncedTable.create(existingTable)
        local new = {}
        local metatable = {class = SyncedTable}
        local data = {}
        --orderedKeys and keyToIndex don't need to be weak tables. They reference keys if and only if those keys are used in data.
        local orderedKeys = {} --array of all keys, defining loop order.
        local keyToIndex = {} --mirrored orderedKeys, i.e. keyToIndex[key] = int <=> orderedKeys[int] = key. This is used to speed up the process of removing (key, value)-pairs from the syncedTable (to prevent the need of searching the key in orderedKeys).
        local numKeys = 0

        --If existingTable was provided, register all keys from the existing table to the keyToIndex and orderedKeys help tables.
        if existingTable then
            --prepare orderedKeys array by sorting all existing keys
            for k,v in pairs(existingTable) do
                numKeys = numKeys + 1
                orderedKeys[numKeys] = k --> the resulting orderedKeys is asynchronous at this point
                data[k] = v
            end
            table.sort(orderedKeys, comparisonFunc) --result is synchronous for all players
            --fill keyToIndex accordingly
            for i = 1, numKeys do
                keyToIndex[orderedKeys[i]] = i
            end
        end

        --Catch read action
        metatable.__index = function(t, key)
            return data[key]
        end

        --Catch write action
        metatable.__newindex = function(t, key, value)
            --Case 1: User tries to remove an existing (key,value) pair by writing table[key] = nil.
            if data[key]~=nil and value == nil then
                --swap last element to the slot being removed (in the iteration order array)
                local i = keyToIndex[key] --slot of the key, which is getting removed
                keyToIndex[orderedKeys[numKeys]] = i --first set last slot to i
                keyToIndex[key] = nil --afterwards nil current key (has to be afterwards, when i == numKeys)
                orderedKeys[i] = orderedKeys[numKeys] --i refers to the old keyToIndex[key]
                orderedKeys[numKeys] = nil
                numKeys = numKeys - 1
            --Case 2: User tries to add a new key to the table (i.e. table[key] doesn't yet exist and both key and value are not nil)
            elseif data[key]==nil and key ~= nil and value ~= nil then
                numKeys = numKeys + 1
                keyToIndex[key] = numKeys
                orderedKeys[numKeys] = key
            end
            --Case 3: User tries to change an existing key to a different non-nil value (i.e. table[existingKey] = value ~= nil)
            -- -> no action necessary apart from the all cases line
            --Case 4: User tries to set table[nil]=value or table[key]=nil for a non-existent key (would be case 1 for an existent key)
            -- -> don't do anything.
            --In all cases, do the following:
            data[key] = value --doesn't have any effect for case 4.
        end

        --Define, how the pairs iteration works
        metatable.__pairs = function(t)
            local i = 0
            local latestKey
            return function()
                if latestKey == orderedKeys[i] then
                    i = i+1 --only increase i, when the last iterated key is still part of the table. Otherwise use the same i again. This allows the removal of (key,value)-pairs inside the pairs()-iteration.
                end
                latestKey = orderedKeys[i]
                return orderedKeys[i], data[orderedKeys[i]]
            end, t, nil
        end

        setmetatable(new, metatable)
        return new
    end

    ---Returns true, if the input argument is a SyncedTable, and false otherwise.
    ---@param anyObject any
    ---@return boolean isSyncedTable
    SyncedTable.isSyncedTable = function(anyObject)
        local metatable = getmetatable(anyObject)
        return metatable and metatable['class'] == SyncedTable
    end

    --Allows writing SyncedTable() instead of SyncedTable.create().
    setmetatable(SyncedTable, {__call = function(func, t)
        return SyncedTable.create(t)
    end})
end

The following minor limitations apply, when using SyncedTables:
  1. Setting a new metatable for any SyncedTable will stop it from working. You are however allowed to edit any SyncedTable's existing metatable, as long as you don't change __index, __newindex and __pairs.
  2. Removing elements from a SyncedTable inside a pairs()-loop via table[key] = nil is allowed as long as you only remove the current loopElement.
    Lua:
    --Let t be a SyncedTable with any elements.
    --Allowed:
    for k,v in pairs(t) do
        t[k] = nil
    end
    
    --Not Allowed:
    for k,v in pairs(t) do
        t[anyKey] = nil --where anyKey ~= k
    end
  3. Adding elements to a SyncedTable inside a pairs()-loop is allowed, but new elements will also be looped over. This might lead to an endless loop, if done poorly. Remember that the same rule holds for normal tables, so this is not really a restriction.
    Lua:
    --The following code will lead to an endless loop (well, it will maybe end at max integer range)
    t = SyncedTable{1}
    for k,v in pairs(t) do
        t[k+1] = true
    end
  4. Creating a SyncedTable from an existing table via <varName> = SyncedTable.create(existingTable) only works, if all keys in existingTable are either numbers or strings (or have the __lt-metamethod defined in their metatable). Keys of any other type have to be added after creation.
  5. You can't use next(S) on a SyncedTable S to check, whether or not it is empty. You can however use the iterator returned by pairs to achieve the same result. A SyncedTable is empty, if pairs(S)(S) == nil (yes, the double brackets look confusing, but it's absolutely fine this way. Note that for a normal table, pairs(T) just returns next, so the check next(T) == nil is equivalent to pairs(T)(T) == nil anyway).
 
Last edited:
Level 24
Joined
Jun 26, 2020
Messages
1,852
I tried something like this, its ok?
Lua:
---@param t table (Synced)
---@param i integer if is nil this will return the first value
---@return any,any
local function next(t,i)
    if t then
        if not i then for k,v in pairs(t) do return k,v end end
        local b=false
        for k,v in pairs(t) do
            if b then return k,v end
            if v==i then b=true end
        end
    end
    return nil
end
 
Last edited:
Level 20
Joined
Jul 10, 2009
Messages
478
what if I wanna start from a certain point?
SyncedTables do guarantee the same iteration order for every player, but they do not guarantee any specific iteration order. "Starting from a certain point" doesn't make sense for general tables anyway, as in general, keys don't have a natural order.

If you need to settle the iteration order, you have to use an array (i.e. a table, where all keys are positive integers) and loop over it via ipairs. Ipairs will start from key [1] and iterate in ascending order, until it finds a nil key.

Ipairs is always synchronous between players, so if that's your only goal, you don't even need SyncedTables.
 
Level 20
Joined
Jul 10, 2009
Messages
478
ipairs is used to iterate over arrays, i.e. tables, where all keys are positive integers. Ipairs starts to iterate at key 1, proceeds in ascending order (2,3,..) and stops at the first nil key. E.g. using ipairs to iterate over t = {[1] = 'A', [2] = 'B', [4] = 'D', [Player(0)] = 'E'} will only include (1,'A') and (2,'B') in the loop, but not (4,'D'), because t[3] is already nil. It will also not include (Player(0), 'E'), because it only deals with integer keys.
Advantage is, it has a fix and thus synchronous iteration order.

pairs is used to iterate over all other tables (except when you exactly know, how to do it better for a given table). In contrast to ipairs, it can deal with any keys in a table (so it will include all pairs in the table t above), but the iteration order is non-deterministic (you can't control it) and not necessarily synchronous between different players.

So if you want to iterate over tables with non-integer keys (or holes in it), better use SyncedTables.

table.insert, table.remove and #t are safe to use (i.e. they don't produce desyncs), but note that they aren't super performant and only work on arrays. Well, you might actually produce a desync, when using #t on a non-array table, but that's very unlikely and there is no reason to do that anyway.

For further explanation, I'd like to redirect you to a good introduction book for new Lua users: Progamming in Lua 4th Edition. It contains answers to all the questions you have asked and is definitely worth a read (except the last part, which contains knowledge for C-programmers).

If you have more questions regarding Lua standard stuff, I'd like to encourage you opening a new thread on hive. I'd like to keep this one exclusive to SyncedTable-related stuff.
 
Last edited:

Bribe

Code Moderator
Level 50
Joined
Sep 26, 2009
Messages
9,468
@Eikonium would using "pairs" without concern for the order of execution cause a desync? In the "vJass Struct" resource linked below, I have a couple of methods that don't do anything other than assign values based on keys, but no events are running and no objects are being generated in the process.


The two methods are "Deallocate" and "Delegate". Deallocate sets all items in the table to "nil" (though it's a method that doesn't really need to exist unless the garbage collector has totally imploded). Delegate takes all the keys and values from table B which are do not have existing keys in table A and assigns them as key/value pairs of table A. These two don't seem asynchronous to me, unless the simple act of invoking "pairs" automatically causes a desync.
 
Level 19
Joined
Jan 3, 2022
Messages
320
  1. .create should error if the table keys contain non-primitive value types such as wc3 types (units as key). So it's impossible to create a desyncable scenario. Currently the sorting function will happily sort whatever its given.
  2. I'd prefer if it also added a :syncedpairs() function to the tables via metatables. The reason is concise code, when I see pairs in the wild, I don't know if it's using SynchedTable or not. However if the default traversal method were table:syncedpairs(), it'd be obvious what's going on. The function is the same as pairs().
 
Level 20
Joined
Jul 10, 2009
Messages
478
Thanks for your feedback!

.create should error if the table keys contain non-primitive value types such as wc3 types (units as key). So it's impossible to create a desyncable scenario. Currently the sorting function will happily sort whatever its given.
Creating a SyncedTable from an existing table will in fact throw an error in desyncable scenarios.

Lua:
--Example 1: Sync scenario. Passing a table with only one non-primitive object per type
t = {}
S = SyncedTable.create( {1, bla = true, [Player(0)] = false, t = 5} )
--sorting function will yield same output for all players (because type(Player(0)) > type(t) > type('bla')  > type(1) )

--Example 2: Async scenario. Passing a table with more than one non-primitive object of the same type
S = SyncedTable.create( {1, [Player(0)] = false, [Player(1)] = true} )
--> will throw an error, because it attempts to evaluate Player(0) < Player(1)

If you see any scenario that might go wrong, please share :)

Honestly, I was even thinking of removing this "create-from-existing-table"-thing. I am using SyncedTable since more than a year now and have not seen a single use case in my own development. I always end up creating empty SyncedTable's to fill them up later, but maybe that's just me. :D

I'd prefer if it also added a :syncedpairs() function to the tables via metatables.

Calling the iterator on a SyncedTable S directly is not possible, because you would lose the ability of using S['syncedpairs'] as key (no matter if colon-notation or not).
I can however add the iterator to the class (SyncedTable.pairs = pairs), which would allow you to write for key, value in SyncedTable.pairs(S) do ....
I personally make a comment like --synced pairs next to my Synced iterations, but I absolutely agree that such a class-method is more beneficial. Thanks for the suggestion!
 
Level 19
Joined
Jan 3, 2022
Messages
320
1. Ah yes sorry, I conflated type() and tostring() in the sorting function. Your code is totally correct and works as I wanted.
2. I don't think it's a loss to use up the 'syncedpairs' key. SyncedTable.pairs = pairs wouldn't work the same way unless it also checked that the provided table is a SyncedTable. With the metatable key it's implicit, normal tables don't have it. The only downside with comments (a big one) is that they might not be kept up-to-date or forgotten. On the other hand this entire suggestion had the only goal of increasing clarity (and only in cases where you made pairs work on a normal table). Idk.
 
Level 20
Joined
Jul 10, 2009
Messages
478
On the other hand this entire suggestion had the only goal of increasing clarity
Exactly, it's only for clarity of later code revision. I'm not a fan of reserving a key for that purpose. The idea of SyncedTable.pairs(S) was to give you the option of "annotating" the synced behaviour for yourself, without technical implications. Synced iterations don't suddenly turn into non-synced iterations, so even comments would do the job (i.e. I don't see the purpose of keeping such a comment up-to-date).

But I can ofc build SyncedTable.pairs in a way that it first checks for whether the provided table is a SyncedTable, if you want.
It's mainly your use case, so you can decide ;)
 
Level 24
Joined
Jun 26, 2020
Messages
1,852
Hello, there is a case that I didn't notice until now, but how can I check if a SyncedTable is empty? Because to check a normal table you can just do:
Lua:
if next(table) == nil then
But if you do that with a SyncedTable the next function always returns nil because the values are not stored in that table, but in another, and using the pairs function to do that would be unpractical.
 
Level 24
Joined
Jun 26, 2020
Messages
1,852
You can use pairs(T)(T) == nil instead.
Only downside is, it creates a function on every cast, so it's not suuper efficient.

Other options would be:
1. You can count the number of objects yourself.
2. I can improve the resource.
For know what I did is just use pairs, since in my case anyways I iterate over it before checking if is empty:
Lua:
local empty = true
for k, v in pairs(generatorUnits) do
    if v == u then
        generatorUnits[k] = nil
        break
    end
    empty = false
end
if empty then
 
Level 24
Joined
Jun 26, 2020
Messages
1,852
That's a good solution.
I think though that you need to put empty = false above the if-statement, because your loop might otherwise break with empty=true despite not being empty.
My logic was "if I delete the entry then the table is actually empty so its good break the loop there, so the empty never gets to false", but that only is true if the table has just 1 element; and the other case was "if is not empty even after removing it then it sets to false in the first iteration, but when removing the entry, its still being false", but that is only true if the removed element is not the first element, how can I properly fix this?
The only solution I could think is using pairs again after removing the entry.
 
Level 20
Joined
Jul 10, 2009
Messages
478
Only break when you are sure about both u and the existence of other table entries.

Lua:
local empty, found = true, false
for k, v in pairs(generatorUnits) do
    if v == u then
        generatorUnits[k] = nil
        found = true
    else
        empty = false
    end
    if found and not empty then
        break
    end
end

Basically, the loop runs until it found u and at least one other element.
If u is the only element, the loop will end after one iteration and empty will stay true.
 
Top