RemoteTable - Replicate tables over the network!

RemoteTable - Replicate tables over the network!
Download | Source

RemoteTable is a special type of network library which uses proxy tables to detect changes to a table and send only the changes over the network to reduce network usage. It uses Packet as the networking library to optimize data usage via buffers.


Features
  • Automatically replicate changes via proxy tables
  • Hash paths to reduce network and cpu usage
  • Batch updates and rate limit
  • Handle and optimize dynamic indexes created at runtime
  • Compatible with ProfileStore
  • Strictly Typed
  • Multi script support

Limitations
  • Not very efficient for relatively large (20-30) item arrays that needs to preserve order
  • Keys for tables can only be number or string and values can only be types Packet.Any supports
  • Can’t use table.insert and table.remove or other standard table library functionality
    [Instead use RemoteTable.Insert, RemoteTable.Remove, RemoteTable.FastRemove ]
  • Only 255 tokens can be registered at the same time. [This can be changed by editing Packets module]
  • Using ChildChanged signal on an ordered array is really unreliable as ChildChanged relies on paths getting destroyed or added. [Feel free to reach me out on why it is this way]

Quick Start
Setup
  1. Get the module here.
  2. Put it under replicated storage.

TIP: You can link your own Packet module to further benefit from batching.

Advanced Setup

You can edit you config file to further customize RemoteTable.
Config file is just a module that returns a table and is located at RemoteTable.Shared.Config.
Documentation for each table property:

Packet -> require(Path.To.Your.Packet)
UpdatesPerSecond -> How often changes are sent.
HashTableSize -> The hash table size for RemoteTable to operate on.

INFO: Each path gets a unique id from the hash table. Default value for the hash table space is 2000 and it is safe to use up to %70 (1400) of this hash table space. For most use cases 1400 paths will be enough for something like user data but incase there is an inventory system with a lot of items you can change this value using this formula max_paths * 1.4.

WARNING: This unique id is unfortunately per RemoteTable module NOT per created remote table. Fortunately players usually get the same user data template so each identical path can use the same id not adding to the total unique path count.

Server.lua

local RemoteTable = require(game.ReplicatedStorage.RemoteTable.Server)
-- or alternatively require(game.ReplicatedStorage.RemoteTable).Server

local ExampleTable = {
	Table = {
		Key1 = "Path of this value is 'Table\Key1' ",
	},
	Array = {"A","B","C"} -- number indexes
}

game.Players.PlayerAdded:Connect(function(player: Player)
	--Create a unique token for each player
	local token = "PlayerData"..player.UserId

	--Initialize a new remote table with the given token
	local remote_table = RemoteTable.ConnectTable(ExampleTable, token)

	--Give client permission to listen to the remote table
	remote_table:AddClient(player) --or RemoteTable.AddClient(token, player)

	task.wait(2) -- wait for client to test if changes replicate
	remote_table.Data.Table.Key1 = player.UserId -- should replicate
end)

game.Players.PlayerRemoving:Connect(function(player: Player)
	--Release token for future players
	local token = "PlayerData"..player.UserId
	RemoteTable.ReleaseToken(token)
end)

Client.lua

-- // Services
local Player = game:GetService("Players").LocalPlayer
local ReplicatedStorage = game:GetService("ReplicatedStorage")

-- // Modules
local RemoteTable = require(ReplicatedStorage.RemoteTable.Client)
-- or alternatively require(ReplicatedStorage.RemoteTable).Client

-- // Globals
local TOKEN = "PlayerData"..Player.UserId
local data = RemoteTable.WaitForTable(TOKEN)

-- Listen to Data.Table.Key1
RemoteTable.GetValueChangedSignal(TOKEN, {"Table","Key1"})
	:Connect(function(new, old)
		print("Value changed from", old, "to", new)
	end)

--TIP: Enable "Show Tables Expanded by Default" for a better visual feedback
while task.wait(0.2) do
	--Data changes automatically
	print(data)
end

TIP: It is usually recommended to have 1 remote table token per player but the library is designed in a way that is more flexible. Multiple players can listen to the same token.

Bonus: Example with ProfileStore
DataManager module
local DataManager = {}

-- // Services
local Players = game:GetService("Players")
local ReplicatedStorage = game:GetService("ReplicatedStorage")

-- // Modules
local ProfileStore = require(game.ServerScriptService.ProfileStore)
local RemoteTable = require(ReplicatedStorage.RemoteTable.Server)
local Signal = require(ReplicatedStorage.RemoteTable.Shared.GoodSignal)

-- // Globals
local PROFILE_TEMPLATE = {
	Cash = 0,
	Items = {},
}

local PlayerStore = ProfileStore.New("PlayerStore", PROFILE_TEMPLATE)
local PlayerDatas = {} :: {[Player]: {
	Profile: typeof(PlayerStore:StartSessionAsync()),
	Data: typeof(PROFILE_TEMPLATE),
}}

DataManager.PlayerDatas = PlayerDatas
DataManager.DataLoaded = Signal.new()

function DataManager.GetDataFromPlayer(player: Player): any
	local remote_table = RemoteTable.GetRemoteTable("PlayerData"..player.UserId)
	if not remote_table then return nil end
	
	return remote_table.Data
end

local function PlayerAdded(player: Player)
	local data_token = "PlayerData"..player.UserId
	
	local profile = PlayerStore:StartSessionAsync(`{player.UserId}`, {
		Cancel = function()
			return player.Parent ~= Players
		end,
	})

	if profile ~= nil then
		profile:AddUserId(player.UserId)
		profile:Reconcile()

		profile.OnSessionEnd:Connect(function()
			PlayerDatas[player] = nil
			player:Kick(`[{script.Name}]: Profile session end - Please rejoin`)
			
			--Release the token
			RemoteTable.ReleaseToken(data_token)
		end)

		if player:IsDescendantOf(Players) then
			-- Connect and add client right after player data loads
			local remote_table = RemoteTable.ConnectTable(profile.Data, data_token)
			remote_table:AddClient(player)
			
			-- Override profile.Data with the tracked read-only table
			-- THIS IS READ ONLY DO NOT EDIT
			profile.Data = remote_table.ReadOnlyData
			PlayerDatas[player] = {
				Profile = profile,
				Data = remote_table.Data -- Data safe to edit
			}
			print(`[{script.Name}]: Profile loaded for {player.DisplayName}!`)
			DataManager.DataLoaded:Fire(player, remote_table.Data)
		else
			profile:EndSession()
		end
	else
		player:Kick(`[{script.Name}]: Profile load fail - Please rejoin`)
	end
end

for _, player in Players:GetPlayers() do
	task.spawn(PlayerAdded, player)
end

Players.PlayerAdded:Connect(PlayerAdded)
Players.PlayerRemoving:Connect(function(player)
	local profile = PlayerDatas[player].Profile
	if profile ~= nil then
		profile:EndSession()
	end
end)

return DataManager
Server
-- // Services
local ServerScriptService = game:GetService("ServerScriptService")
local ReplicatedStorage = game:GetService("ReplicatedStorage")
local Players = game:GetService("Players")

-- // Modules
local DataManager = require(ServerScriptService.DataManager)
local RemoteTable = require(ReplicatedStorage.RemoteTable.Server)

DataManager.DataLoaded:Once(function(player: Player, data)
	-- Add 100 cash each join
	data.Cash += 100
	
	-- Grant a ticket to player everytime they join
	RemoteTable.Insert(data.Items, {Name = "Ticket", Value = 10})
end)
Client
-- // Services
local Player = game:GetService("Players").LocalPlayer
local ReplicatedStorage = game:GetService("ReplicatedStorage")

-- // Modules
local RemoteTable = require(ReplicatedStorage.RemoteTable.Client)

-- // Globals
local TOKEN = "PlayerData"..Player.UserId
local data = RemoteTable.WaitForTable(TOKEN)

--TIP: Enable "Show Tables Expanded by Default" for a better visual feedback
while task.wait(0.2) do
	--Data changes automatically
	print(data)
end

Documentation
Server
--Requiring the module
local RemoteTable = require(RemoteTable.Server) -- or require(RemoteTable).Server
RemoteTable.ConnectTable
	- Creates a new remote table and initializes it
	@param tbl: Table to be tracked
	@param token_alias: String token_alias for the token
	@param players: A player or a player array to be added to the remote table
	@return RemoteTable<T>: Newly created remote table object
RemoteTable.AddClient
	- Authorizes a client to listen to a token
	@param player: Client to be added
	@param token_alias: String token_alias for the token
RemoteTable.RemoveClient
	- Disconnects a client and removes permissions to listen for changes.
	@param player: Client to be added
	@param token_alias: String token_alias for the token
RemoteTable.ReleaseToken
	- Releases the token and disconnects the remote table associated with the token.
	@param token_alias: String token_alias for the token
RemoteTable.GetRemoteTable
	- Gets the remote table from token alias.
	@param token_alias: String alias of the token
	@return RemoteTable<T>?: returns nil if remote table does not exist
RemoteTable.GetProtectedTable
	- Gets the protected read-only data to be used as rvalue
	@param value V: ProxyTable to be used to retrieve the protected value
	@return value V: The read-only data
RemoteTable.Insert
	- Same as table.insert
	@param tbl {V}: Table to insert to
	@param value V: Value to be inserted
RemoteTable.Remove
	- Same as table.remove
	- WARNING: Very expensive to execute and replicate to the client
	@param tbl {V}: Table to insert to
	@param pos number?: Position to be removed from
RemoteTable.FastRemove
	- Removes an element from the array and replaces it with the last one
	- NOTE: Recommended when order of elements does not matter
	@param tbl {V}: Table to insert to
	@param pos number?: Position to be removed from

Client
-- Requiring the module
local RemoteTable = require(RemoteTable.Client) -- or require(RemoteTable).Client
RemoteTable.WaitForTable
	- Returns the table if available, waits for it if not.
	@param token_alias: String alias of the token
	@param timeout: Timeout in seconds. Returns nil after timing out
	@return data: Ready-only replicated table.
RemoteTable.GetValueChangedSignal
	- Gets the signal that fires when value of the path changes.
	@param token_alias: String alias of the token
	@param path_list: A string array representing the desired path
	@return Signal: Signal that fires (new, old) data
RemoteTable.GetChildChangedSignal
	- Gets the signal that fires when a child is Added / Removed from the table.
	@param token_alias: String alias of the token
	@param path_list: A string array representing the desired path_list
	@return Signal: Signal that fires ("Added" | "Removed", key, value) data
RemoteTable.DisconnectValueChangedSignal
	- Stops listening to value changed events for that path.
	@param token_alias: String alias of the token
	@param path_list: A string array representing the desired path_list
RemoteTable.DisconnectChildChangedSignal
	- Stops listening to child changed events for that path.
	@param token_alias: String alias of the token
	@param path_list: A string array representing the desired path_list

Versions

1.0

  • Initial Release.
  • Added XXH32 as the main hashing algorithm. Thanks to @XoifaiI.

1.1

  • Fixed simple typo which allowed everyone to listen to any table.
  • Simplified netcode and client/server communication.
  • Added universal WaitForTable. Can be used from anywhere to get a remote table.
  • Fixed re-hashing for each connected client.
  • Fixed client not being able to listen to same path from different tokens.

1.2

  • Fixed release token trying to release the non-existent token with id 0.
  • Added ValueChanged and ChildChanged signals to listen to.

1.2a

  • Removed dependency to promise (custom timeout function).
  • .Changed events now .WaitForTable by default.

1.3

  • Fixed Remove, FastRemove and Insert to function properly with nested proxy tables. Thanks to @artspb111 for finding the bug!
  • Added .GetProtectedValue which allows users to get the protected value that RemoteTable works with under the hood.
  • Small fix to license. Same GPLv3 applies.

1.4

  • A bit of code refactor
  • Fixed timeout logic. Thanks to @taazereb for finding the issue
  • Fixed an issue where if player left while connection is being established it wouldn’t release the player properly for other listeners.
  • Few improvements to race conditions. More consistent .WaitForTable behavior.
  • Fixed .WaitForTable not being able to wait for data again.
  • Changed license to LGPLv3.
18 Likes

:fire:
will use in my next projects

Hold on, does it support instances? (Obviously the server won’t see client-sided instances but how does the module act?)
Also, except those and some data-types since I don’t know how it works with them, how different is it from sending a JSON encoded string over remotes?

Instances can be set as a value but keys don’t support instances. As I said

Keys for tables can only be number or string and values can only be types Packet.Any supports

Second thing. This module optimizes data usage up to 100x or more compared to sending over the json each time. Module only sends changes with 3 bytes to identify the token and the path. The values network cost is optimized by packet and can vary.

1 Like

This is extremely cool, but I would consider it the _G equivalent of networking where it is convenient, but is not a good habit to use for everything.

if this is being used for special datatypes (a chess board game maybe?) this could make programming 100x more convenient.

Great module :+1:

If you want a fast hash :eyes:

1 Like

This was originally made as an alternative to replica. I wanted something that was more automatic than manually entering path. It will be a lot better after v1.2 which will introduce subcription to paths. So you can use it directly in ui code. Very excited for it!

Thank you for the resource Ill look into it! I’m not really advanced with hashing and cryptography so I just chose the simplest one.

XXHash32 is the fastest hash because its not meant for security, think a facebook engineer made it so its perfect for hash tables or checksums
XXH32(Message: buffer, Seed?: number) -> number

  • Added XXH32 as the main hashing algorithm. Thanks to @XoifaiI

Thank you again! Very cool library you got.

3 Likes

1.1 is Here!

  • Fixed simple typo which allowed everyone to listen to any table
  • Simplified netcode and client/server communication
  • Added universal WaitForTable. Can be used from anywhere to get a remote table
  • Fixed re-hashing for each connected client
  • Fixed client not being able to listen to same path from different tokens
1 Like

1.2 is Here!

  • Fixed release token trying to release the non-existent token with id 0.
  • Added ValueChanged and ChildChanged signals to listen to.
2 Likes

1.2a is Here!

  • Removed dependency to promise (custom timeout function)
  • .Changed events now .WaitForTable by default
1 Like

Hello, i was wondering, is there any way to detect/listen tables that gets added or deleted? instead of a single key, for example

local collectedobjects = {
}

// after a certain interaction >

local collectedobjects = {
[“NewlyAddedObject”] = {
blablabla = 1
}
}

my use case for this is that i am doing a collecting game, where i need to make a gui that shows all stats/collected objects, and updates when you open that menu or when you collect something in real time, but i wouldn’t need it on a loop

Yes, this is exactly what GetChildChangedSignal is for.
Here’s how you would do it:

RemoteTable.GetChildChangedSignal(YOUR_TOKEN, {}) -- empty path to listen to root table
	:Connect(function(action, key, value)
		if action == "Added" then
			print("Added", key, value)
		elseif action == "Removed" then
			print("Removed", key, value)
		end
	end)
1 Like

Works good, but now is there a way that after detecting the added table, listen for a single value of it?

[“NewlyAddedObject”] = {
Count = 1
}

listen to Count, or maybe it only listens for when the table is added/rremoved, but not when a value inside it changes

RemoteTable.GetChildChangedSignal(TOKEN, {"FumoStats"}) -- empty path to listen to root table
	:Connect(function(action, key, value)
		if action == "Added" then
			print("Added", key, value)
		elseif action == "Removed" then
			print("Removed", key, value)
		end
	end)

After the table gets added i want to listen for its individual changes, a workaround for this i guess it could be storing new GetValueChangedSignals on a table, then when the table gets removed on GetChildChangedSignal, remove it from the connections table, but would this be a good way to go?

Something like this :

RemoteTable.GetChildChangedSignal(TOKEN, {"FumoStats"}) -- empty path to listen to root table
	:Connect(function(action, key, value)
		if action == "Added" then
			print("Added", key, value)
			if not individualValueChanges[key] then
				individualValueChanges[key] = RemoteTable.GetValueChangedSignal(TOKEN, {"Table","Key1"})
				:Connect(function(new, old)
					print("Value changed from", old, "to", new)
				end)
			end
		elseif action == "Removed" then
			print("Removed", key, value)
		end
	end)

edit : that didn’t work

edit 2 : It did work, i just had to specify a actual value, my final code :

RemoteTable.GetChildChangedSignal(TOKEN, {"FumoStats"}) -- empty path to listen to root table
	:Connect(function(action, key, value)
		if action == "Added" then
			print("Added", key, value)
			if not individualValueChanges[key] then
				individualValueChanges[key] = RemoteTable.GetValueChangedSignal(TOKEN, {"FumoStats", tostring(key), "Count"}):Connect(function(new, old)
					print("Value changed from", old, "to", new)
				end)
				warn(individualValueChanges[key])
			end
		elseif action == "Removed" then
			print("Removed", key, value)
			if individualValueChanges[key] then
				RemoteTable.DisconnectValueChangedSignal(TOKEN, {"FumoStats", tostring(key), "Count"})
				individualValueChanges[key] = nil
			end
		end
	end)

Fun fact. You can start listening to non existent paths right away! If you know what will be added into that table. Just start listening to them right away and it will start firing when that path is available.

Example:

RemoteTable.GetValueChangedSignal(TOKEN, {"This", "Path", "Doesnt", "Exist"})
	:Connect(function(new, old)
		print("Finally it exists!")
	end)
1 Like

1.3 is Here!

  • Fixed Remove, FastRemove and Insert to function properly with nested proxy tables. Thanks to @artspb111 for finding the bug!
  • Added .GetProtectedValue which allows users to get the protected value that RemoteTable works with under the hood.
  • Small fix to license. Same GPLv3 applies.
2 Likes

1.4 is Here!

  • A bit of code refactor
  • Fixed timeout logic. Thanks to @taazereb for finding the issue
  • Fixed an issue where if player left while connection is being established it wouldn’t release the player properly for other listeners.
  • Few improvements to race conditions. More consistent .WaitForTable behavior.
  • Fixed .WaitForTable not being able to wait for data again.
  • Changed license to LGPLv3.
1 Like