Reverse Engineering on How the Stock System in Grow a Garden works

DISCLAIMER

This is one of the many ways that Grow a Garden might be using for their stock system. I cannot guarantee that this is identical to how Grow a Garden does it, but the overall simplicity in this approach makes it feasible compared to other approaches

I have tested this approach both theoretically and practically, and concluded that it is reliable enough

1. Brief Introduction to Grow a Garden’s Stock System

Grow a Garden has various kinds of shops that you can purchase items out of. However, all of those items have a limited stock, and will restock after 5 minutes. This is one of the main premise of Grow a Garden that makes it the biggest game of all time.

2. The Approaches

As i have pointed out, there are many ways you can approach this. So lets explore them one by one:
If you have other approaches in mind, feel free to post it!

  1. HttpService
    Reliable, controlled, but complex and could exhaust your HTTP quota
  2. MessagingService
    Unreliable-ish, not guaranteed, easily event-driven, but completely unneccessary for this case
  3. Using the global server time
    Reliable, simple, guaranteed

From those you would see where I’m going with this…

3. The Procedure

For the global server time, we’ll use Workspace:GetServerTimeNow(), which is shared across all servers.
To get when the next and the previous interval is:

next = (time + (interval - (time % interval)))
previous = time - (time % interval)

And to get if the current time is on the interval, we’ll just check if its divisible by the interval

time % interval == 0

To do the random but global stocks, we need a set seed. We can use the time for that, but that’ll make our stocks easily predictable. To make it harder to predict, we can simply use a hash cryptography function, for simplicity, below is a VSH hash:

local function hash(str: string): number
	local hash = 2166136261

	for i = 1, #str do
		local c = str:sub(i, i)
		local byte = string.byte(c)
		hash = bit32.bxor(hash, byte)
		hash = (hash * 16777619) % 2 ^ 32
	end

	return hash
end

Here’s a sample code I made in like 5 minutes based on my own code

local INTERVAL = 5 * 60 -- 5 minutes
local KEY = 'supersecretkey' -- The key to hash your seeds

-- Time Utilities
local function getNextIntervalTime(now: number, interval: number)
	return (now + (interval - (now % interval)))
end

local function getPreviousIntervalTime(now: number, interval: number)
	return now - (now % interval)
end

local function isOnInterval(time: number, interval: number) 
	return time % interval == 0
end

local function hash(str: string): number
	local hash = 2166136261

	for i = 1, #str do
		local c = str:sub(i, i)
		local byte = string.byte(c)
		hash = bit32.bxor(hash, byte)
		hash = (hash * 16777619) % 2 ^ 32
	end

	return hash
end

-- Runs every interval
local function onInterval(time: number)
	local rng = Random.new(hash(`{KEY}/{time}`))

	-- Then do your stocks rng...
	local stock1 = rng:NextInteger(0, 10)
	local stock2 = rng:NextInteger(0, 10)
	-- ...
end

local now = workspace:GetServerTimeNow()
local tickTime = if isOnInterval(now, INTERVAL)
			then now
			else getPreviousIntervalTime(now, INTERVAL)

onInterval(tickTime) -- Make sure new servers get the same deal

while true do
	now = workspace:GetServerTimeNow()

	local nextTime = getNextIntervalTime(now, INTERVAL)

	task.wait(nextTime - now)
	onInterval(nextTime)
end

The key serves as a way to secure the seed from being predictable, but it should be the same across all servers.

Footnotes

This was a rushed topic, please let me know if there’s anything wrong here

42 Likes

I made a quick flowchart to help visualize the process

9 Likes

This is so cool. Thanks for sharing :slight_smile:

yes but how would u share the stock globally

its just using the global server time (which is also used as the seed), which is the same across all servers, hence shared globally. is there something that yall just not understand?

i literally put a sample code that works out of the box

i understand tysm for tutorial

1 Like

do i change those long strings of numbers during hash?

1 Like

elaborate? you can change the key if you want, but don’t change the time. you can also change the hash format as long as its deterministic

if you’re talking about the 2166136261, you can leave it be. its part of the vsh hash

nice breakdown! reverse engineering things is the best imo

it sure is, pretty satisfying when you figure it out

1 Like

Sorry if I maybe missed this somewhere within the post, but could you explain the reasoning behind choosing Workspace:GetServerTimeNow() over other alternatives, say os.time() or some other ways? Is it due to more accuracy, or is it just preference?

I looked into it a little and couldn’t come to a straightforward conclusion. Some sources said that os.time() was cheaper to call but innacurate? While others said it was equally as good.


Otherwise, great post! Thanks :slightly_smiling_face:

This is actually what I chose before switching to GetServerTimeNow

To be honest, same! I was researching the best one, and it seemed DateTime was a tad bit better than GetServerTimeNow, I did come across an article in the official docs that said GetServerTimeNow is more expensive and os.time is kind of deprecated, GetServerTimeNow also works client-side while still synced to the server time

3 Likes

why not use MemoryStoreService

I tried to make similar thing with MemoryStoreService. It’s just overcomplicated and hard. In this scenario, you need to assign master server, track it, set shop items with it, make queue for handling further shop rolls if MSS is down, and much more.
I succeed, but this solution will be just much simpler, and will use a lot less resources.

2 Likes

Thanks, I was just wondering since I’m currently creating a similar system using MemoryStoreService and for me it seems to be a fine option.

MemoryStore implementation is needed if you want to make global shop, where there’s 1 million limited items for example, and when they all bought - none can be bought further. For simple per-client stock the system above is more than enough.

Race conditions and difficulty

1 Like

i think he confused shared globally with shared across clients

I asked ai how to utilize memorystores to make a global cross-server stock and this is crazy considering i didnt provide any detail:

local MemoryStoreService = game:GetService("MemoryStoreService")
local DataStoreService = game:GetService("DataStoreService")

local SHOP_STOCK_KEY = "GlobalShopStock"
local STOCK_HISTORY_KEY = "GlobalShopStockHistory"
local MAX_RETRIES = 5
local CACHE_EXPIRATION = 60 * 5 -- 5 Minutes
local RESTOCK_AMOUNT = 5 -- Number of items to stock each time

local ShopConfig = {
    Rarities = {
        Common = { weight = 60 },
        Uncommon = { weight = 30 },
        Rare = { weight = 8 },
        Legendary = { weight = 2 }
    },

    -- All possible items that can appear in the shop
    PossibleItems = {
        ["Sword"] = { cost = 100, rarity = "Common" },
        ["Shield"] = { cost = 150, rarity = "Uncommon" },
        ["DragonSlayer"] = { cost = 500, rarity = "Rare" },
        ["Excalibur"] = { cost = 2000, rarity = "Legendary" },
        ["Health Potion"] = { cost = 50, rarity = "Common" },
        ["Mana Potion"] = { cost = 75, rarity = "Common" },
        ["Iron Helmet"] = { cost = 200, rarity = "Uncommon" },
        ["Magic Ring"] = { cost = 800, rarity = "Rare" },
        ["Ancient Amulet"] = { cost = 1500, rarity = "Legendary" }
    },

    RestockInterval = 60 * 60 * 6, -- 6 hours
    MaxStockHistory = 10
}

-- Internal variables
local currentStock = nil
local lastCacheUpdate = 0
local memoryStore = MemoryStoreService:GetSortedMap(SHOP_STOCK_KEY)
local stockHistoryStore = DataStoreService:GetOrderedDataStore(STOCK_HISTORY_KEY)

local function getTotalWeight()
    local total = 0
    for _, rarity in pairs(ShopConfig.Rarities) do
        total += rarity.weight
    end
    return total
end

local function getItemsByRarity(rarity)
    local items = {}
    for itemName, itemData in pairs(ShopConfig.PossibleItems) do
        if itemData.rarity == rarity then
            table.insert(items, itemName)
        end
    end
    return items
end

local function selectRandomItem(rarity)
    local items = getItemsByRarity(rarity)
    if #items == 0 then return nil end
    return items[math.random(1, #items)]
end

local function generateRandomStock()
    local newStock = {}
    local totalWeight = getTotalWeight()
    
    for i = 1, RESTOCK_AMOUNT do
        local randomValue = math.random(1, totalWeight)
        local currentWeight = 0
        local selectedRarity = nil
        
        -- Select rarity based on weights
        for rarityName, rarityData in pairs(ShopConfig.Rarities) do
            currentWeight += rarityData.weight
            if randomValue <= currentWeight then
                selectedRarity = rarityName
                break
            end
        end
        
        -- Select a random item of that rarity
        if selectedRarity then
            local itemName = selectRandomItem(selectedRarity)
            if itemName and not newStock[itemName] then -- Ensure no duplicates
                newStock[itemName] = {
                    cost = ShopConfig.PossibleItems[itemName].cost,
                    rarity = selectedRarity,
                    restockTime = os.time()
                }
            else
                -- If duplicate or no item found, try again
                i = i - 1
            end
        end
    end
    
    return newStock
end

local function updateStockCache()
    local success, result = pcall(function()
        return memoryStore:GetAsync(SHOP_STOCK_KEY)
    end)

    if success and result then
        currentStock = result
        lastCacheUpdate = os.time()
    else
        warn("Failed to update stock cache:", result)
        currentStock = generateRandomStock()
    end
end

local function getCurrentStock()
    if not currentStock or os.time() - lastCacheUpdate > CACHE_EXPIRATION then
        updateStockCache()
    end
    return currentStock or generateRandomStock()
end

local function saveStockHistory(stockData)
    local timestamp = os.time()
    pcall(function()
        stockHistoryStore:SetAsync(tostring(timestamp), stockData)

        -- Clean up history if too large
        local success, pages = pcall(function()
            return stockHistoryStore:GetSortedAsync(false, ShopConfig.MaxStockHistory + 1)
        end)

        if success and pages then
            local data = pages:GetCurrentPage()
            if #data > ShopConfig.MaxStockHistory then
                local toRemove = data[#data].key
                stockHistoryStore:RemoveAsync(toRemove)
            end
        end
    end)
end

local function updateGlobalStock(newStock)
    local retries = 0
    local success = false

    while retries < MAX_RETRIES and not success do
        success = pcall(function()
            memoryStore:SetAsync(SHOP_STOCK_KEY, newStock)
        end)

        if not success then
            retries += 1
            task.wait(1)
        end
    end

    if success then
        currentStock = newStock
        lastCacheUpdate = os.time()
        saveStockHistory(newStock)
    end

    return success
end

local function purchaseItem(itemName)
    local currentStock = getCurrentStock()
    if not currentStock[itemName] then
        return false, "Item not in current stock"
    end

    -- Since we're not tracking individual quantities, just return success
    return true, "Purchase successful"
end

local function restockShop()
    local newStock = generateRandomStock()
    local success = updateGlobalStock(newStock)
    
    if success then
        return true, "Shop restocked with new items"
    else
        return false, "Failed to restock shop"
    end
end

local function getItemCost(itemName)
    local stock = getCurrentStock()
    return stock[itemName] and stock[itemName].cost or nil
end

local function getItemRarity(itemName)
    local stock = getCurrentStock()
    return stock[itemName] and stock[itemName].rarity or nil
end

local function addNewPossibleItem(itemName, cost, rarity)
    if not ShopConfig.Rarities[rarity] then
        return false, "Invalid rarity"
    end

    if ShopConfig.PossibleItems[itemName] then
        return false, "Item already exists in possible items"
    end

    ShopConfig.PossibleItems[itemName] = {
        cost = cost,
        rarity = rarity
    }

    return true, "Item added to possible items pool"
end

-- Restock loop
task.spawn(function()
    while true do
        restockShop()
        task.wait(ShopConfig.RestockInterval)
    end
end)

-- Return the API
return {
    getCurrentStock = getCurrentStock,
    purchaseItem = purchaseItem,
    getItemCost = getItemCost,
    getItemRarity = getItemRarity,
    addNewPossibleItem = addNewPossibleItem,
    restockShop = restockShop,
    manualRestock = restockShop, -- Alias for restockShop

    -- Configuration
    config = ShopConfig
}
1 Like

Yes its basic but you can edit it and use it in game