Why every player needs their own copy of default data

One default table handed to every new player, or copied with table.clone, shares its inventory between them. How to build fresh defaults for each player.

criticalLuaudit check: shared-default-tables

What it looks like

New players get their data from one default table kept at the top of a script. The server hands out that table itself, or a table.clone of it, instead of building a fresh copy for each player. table.clone only copies the top level, so every list and table inside it, such as the inventory, is still the same table for every player who got the defaults.

Luau
local starter = { Coins = 100, Inventory = {} }

profiles[player] = if saved then saved else table.clone(starter)

What an exploiter could do

No exploit is needed, but any player can use it. Everyone who started from the defaults in this server shares one inventory, one list of unlocks or one settings table. An item one new player buys appears in every other new player's data, and when they leave each of them saves the shared table under their own key, so the items are copied into every account. A player can bring in new accounts to collect what others bought, and removing something from one player removes it from all of them.

How to fix it

  • Build defaults with a function that returns a new table literal every time it's called.
  • If the defaults must live in one table, deep copy it for each player, copying every nested table as well.
  • Fill missing fields from a fresh copy, never from the shared table itself.
  • Freezing the shared defaults with table.freeze doesn't make sharing safe, though it turns the mistake into an error you'll see: table.freeze is shallow too, so nested tables stay writable unless each one is frozen.

Vulnerable example

Vulnerable
local DataStoreService = game:GetService("DataStoreService")
local Players = game:GetService("Players")

local store = DataStoreService:GetDataStore("Pets")
local blank = {
	Pets = {},
	Equipped = {},
	Food = 10,
}
local data: { [Player]: typeof(blank) } = {}

Players.PlayerAdded:Connect(function(player)
	local ok, saved = pcall(store.GetAsync, store, tostring(player.UserId))
	if not ok or player.Parent ~= Players then
		return
	end
	data[player] = if typeof(saved) == "table" then saved else blank
end)

Fixed example

Fixed
local DataStoreService = game:GetService("DataStoreService")
local Players = game:GetService("Players")

type PetData = {
	Pets: { string },
	Equipped: { string },
	Food: number,
}

local store = DataStoreService:GetDataStore("Pets")
local data: { [Player]: PetData } = {}

local function blank(): PetData
	return {
		Pets = {},
		Equipped = {},
		Food = 10,
	}
end

Players.PlayerAdded:Connect(function(player)
	local ok, saved = pcall(store.GetAsync, store, tostring(player.UserId))
	if not ok or player.Parent ~= Players then
		return
	end
	data[player] = if typeof(saved) == "table" then saved else blank()
end)

How to spot this in your own scripts, and the cases that only look like it, are part of the Luaudit check your AI runs.