Tower Defense Data Transfer

Hi! I’m working on a Tower Defense game in Roblox Studio and I’m facing an issue with transferring data between the lobby and the match.

Here’s my problem:
:arrow_right: How do I transfer the selected towers (loadout) from the lobby into the match, so the player has the same loadout when the game starts?

Additionally, I want to apply this rule:

  • If the player does NOT own the gamepass, they should have a maximum of 3 tower slots in their loadout.
  • If the player owns the gamepass, they can have up to 5 tower slots.

This gamepass feature only exists in lobby.
My quesions are:

  • How to pass this data to the server of the match after teleporting
  • How to apply that loadout to the player in the match

Are you not already storing the player’s loadout in datastores so it saves if they leave and rejoin a lobby? You can use the same datastores in different places as long as they are all under one experience.

1 Like

Right now, the player selects their loadout in the Lobby GUI. There is a LoadoutFrame that shows the selected towers. Each selected tower is cloned as a template frame inside that LoadoutFrame (like a list of selected towers).

The number of towers a player can add depends on whether they own a Gamepass:

  • If they don’t own the Gamepass, they can select up to 3 towers
  • If they own the Gamepass, they can select up to 5 towers

So far, the towers are only visually shown in the lobby, inside a Frame. I haven’t stored them yet in a DataStore or anywhere else.

Brother are you using entirely different experiences instead of places?

When using ANY datastores that need to “transfer between games” what you’re actually looking for is Places.


When using Places and not experiences, datastores are fully shared across them, so all it takes to get the loadout of a player in a match is to run the exact same code you run to get their data in the lobby.

Yeah sorry to be the bearer of bad news but using datastores there is kinda required. There’s no way to “save” the loadout without them, unless if you think having the user CONSTANTLY re-input their loadout each time they go to the lobby is a good idea.

If you think the latter is a good idea, then you can simply use TeleportData, however for reasons I already stated, Datastores are 100% what you SHOULD be using.

Adding on, before actually allowing a user to equip something, you should check if they are actually supposed to on the server (avoid cheating), and I’d recommend using a RemoteFunction for this, so you can properly load the UI if the item was actually equipped.

Again, this problem is entirely fixed when using places, although it shouldn’t even be a problem to begin with since you can just run UserOwnsGamepassAsync on any gamepass in any game.
So, I could check if I own a gamepass from Welcome to Bloxburg in one of my own games, and it should return the correct value.

1 Like

To clarify my situation:

  • I am using Places inside one Experience, not different Experiences.
  • I have a Lobby Place where players select their towers using a GUI (a LoadoutFrame where each tower is shown as a cloned template).
  • When players step into a lift, they are teleported using a TeleporterHandlerModule to the Match Place (which is part of the same experience).

Now here’s my goal:

When the player gets teleported to the Match Place, I want their LoadoutFrame to already contain the exact same towers they selected in the lobby — same order, same amount, depending on whether they own the gamepass (3 or 5 towers).

One more quick question:

In the Lobby, I have a DataStoreHandler ModuleScript that handles saving the loadout (as a table) to the DataStore. Do I need to duplicate or copy this same DataStoreHandler into the Match Place too?

Or should I just create a separate script in the Match Place that loads the data again using the same keys?

Basically:
Does the DataStoreHandler module from the Lobby need to exist in the Match Place too, or can I just write a new script in the Match Place that uses the same DataStore logic?

This is my Lobby DataStoreHandler:

local DataStoreService = game:GetService("DataStoreService")
local Players = game:GetService("Players")
local DataStore = DataStoreService:GetDataStore("TowerDefensePlayerData")

local DataStoreHandler = {}
DataStoreHandler.__index = DataStoreHandler
local DEFAULT_DATA = {
     Cash = 0,
     Crystals = 0,
     DailyLoginDay = 0,
     LastLoginDate = "1970-01-01",
     TotalPlaytime = 0,
     DailyPlaytime = 0,
     LastPlayDate = "1970-01-01",
     LastClaimTimestamp = 0,

  
     OwnedTowers = {},       
     Loadout = {},           
     BuyButtonStates = {},   
}

local playerDataCache = {}
local lastSaveTimes = {}
local SAVE_INTERVAL = 30 
local MAX_SAVE_ATTEMPTS = 3
local SAVE_DELAY = 1 

local function getDateString()
     local now = os.time()
     local utcDate = os.date("!*t", now)
     return string.format("%04d-%02d-%02d", utcDate.year, utcDate.month, utcDate.day)
end

function DataStoreHandler.LoadPlayerData(userId)
     if playerDataCache[userId] then
          return playerDataCache[userId]
     end

     local success, data = pcall(function()
          return DataStore:GetAsync("Player_"..userId)
     end)

     if success and data then
          for k,v in pairs(DEFAULT_DATA) do
               if data[k] == nil then
                    data[k] = v
               end
          end
          playerDataCache[userId] = data
          lastSaveTimes[userId] = os.time()
          return data
     else
          local defaultData = table.clone(DEFAULT_DATA)
          playerDataCache[userId] = defaultData
          lastSaveTimes[userId] = os.time()
          return defaultData
     end
end

function DataStoreHandler.SavePlayerData(userId, data, forceImmediate)
     if not data then return end

     playerDataCache[userId] = data

     local currentTime = os.time()
     local lastSave = lastSaveTimes[userId] or 0

     if not forceImmediate and (currentTime - lastSave) < SAVE_INTERVAL then
          return
     end

     local saveData = {
          Cash = math.max(0, math.floor(data.Cash or 0)),
          Crystals = math.max(0, math.floor(data.Crystals or 0)),
          DailyLoginDay = math.clamp(data.DailyLoginDay or 0, 0, 7),
          LastLoginDate = tostring(data.LastLoginDate or "1970-01-01"),
          TotalPlaytime = math.max(0, math.floor(data.TotalPlaytime or 0)),
          DailyPlaytime = math.max(0, math.floor(data.DailyPlaytime or 0)),
          LastPlayDate = tostring(data.LastPlayDate or "1970-01-01"),
          LastClaimTimestamp = math.max(0, math.floor(data.LastClaimTimestamp or 0)),

          OwnedTowers = data.OwnedTowers or {},
          Loadout = data.Loadout or {},
          BuyButtonStates = data.BuyButtonStates or {},
     }

     local attempts = 0
     local success = false

     while attempts < MAX_SAVE_ATTEMPTS and not success do
          attempts = attempts + 1

          if attempts > 1 then
               wait(SAVE_DELAY * attempts)
          end

          success = pcall(function()
               DataStore:SetAsync("Player_"..userId, saveData)
          end)

          if success then
               lastSaveTimes[userId] = currentTime
          elseif attempts == MAX_SAVE_ATTEMPTS then
               warn("Failed to save data for userId "..userId.." after "..MAX_SAVE_ATTEMPTS.." attempts")
          end
     end
end

function DataStoreHandler.GetPlayerData(userId)
     return playerDataCache[userId]
end

function DataStoreHandler.UpdatePlayerData(userId, updateFunction)
     local data = playerDataCache[userId]
     if data then
          updateFunction(data)
          DataStoreHandler.SavePlayerData(userId, data, false)
     end
end

function DataStoreHandler.SaveAllPlayers()
     for userId, data in pairs(playerDataCache) do
          spawn(function()
               DataStoreHandler.SavePlayerData(userId, data, true)
          end)
     end
end

function DataStoreHandler.PlayerLeaving(userId)
     local data = playerDataCache[userId]
     if data then
          DataStoreHandler.SavePlayerData(userId, data, true)
          spawn(function()
               wait(2)
               playerDataCache[userId] = nil
               lastSaveTimes[userId] = nil
          end)
     end
end

spawn(function()
     while true do
          wait(SAVE_INTERVAL)
          for userId, data in pairs(playerDataCache) do
               local player = Players:GetPlayerByUserId(userId)
               if player then
                    DataStoreHandler.SavePlayerData(userId, data, false)
                    wait(0.1)
               end
          end
     end
end)

game:BindToClose(function()
     print("Server shutting down, saving all player data...")
     local saveCount = 0
     for userId, data in pairs(playerDataCache) do
          spawn(function()
               DataStoreHandler.SavePlayerData(userId, data, true)
               saveCount = saveCount + 1
          end)
          wait(0.1)
     end

     local startTime = tick()
     while saveCount < #playerDataCache and (tick() - startTime) < 25 do
          wait(0.1)
     end

     print("Saved data for "..saveCount.." players")
end)

return DataStoreHandler

And this is the LoadoutManagerModule:

local LoadoutManager = {}


LoadoutManager.PlayerLoadouts = {}

function LoadoutManager:GetSlotCount(hasGamepass)
     if hasGamepass == nil then
          warn("hasGamepass is nil in GetSlotCount")
          return 3 
     end

     if hasGamepass then
          return 5
     else
          return 3
     end
end

function LoadoutManager:IsTowerAlreadyPlaced(playerLoadout, towerName)
     if not playerLoadout then
          return false
     end
     for _, tower in pairs(playerLoadout) do
          if tower == towerName then
               return true
          end
     end
     return false
end

function LoadoutManager:InitializeLoadout(playerUserId, slotCount)
     local loadout = {}
     for i = 1, slotCount do
          loadout[i] = nil 
     end
     LoadoutManager.PlayerLoadouts[playerUserId] = loadout
     return loadout
end

function LoadoutManager:SetTowerOnSlot(playerUserId, slotNumber, towerName)
     local loadout = LoadoutManager.PlayerLoadouts[playerUserId]
     if not loadout then
          warn("Loadout not initialized for player "..playerUserId)
          return false
     end

     if self:IsTowerAlreadyPlaced(loadout, towerName) then
          return false 
     end

     loadout[slotNumber] = towerName
     

     return true
end


function LoadoutManager:GetTowerOnSlot(playerUserId, slotNumber)
     local loadout = LoadoutManager.PlayerLoadouts[playerUserId]
     if loadout then
          return loadout[slotNumber]
     end
     return nil
end

function LoadoutManager:PlaceTowerOnSlot(selectedTowersFolder, slotNumber, towerName, TowersFolder)
     local template = selectedTowersFolder:FindFirstChild("Template")
     if not template then
          warn("Template not found in SelectedTowers folder")
          return
     end

     local slotName = "Slot"..slotNumber
     local existingSlot = selectedTowersFolder:FindFirstChild(slotName)
     if existingSlot then
          existingSlot:Destroy()
     end

     local clone = template:Clone()
     clone.Name = slotName
     clone.Visible = true
     clone.Parent = selectedTowersFolder

   
     local startX = 0.033
     local startY = 0.07
     local stepX = 0.205
     clone.Position = UDim2.new(startX + (slotNumber - 1) * stepX, 0, startY, 0)
     clone.Size = UDim2.new(0, 85, 0, 85)

    
     local towerNameLabel = clone:FindFirstChild("TowerName")
     if towerNameLabel then
          towerNameLabel.Text = towerName
     end

     local viewportFrame = clone:FindFirstChild("ViewportFrame")
     if viewportFrame then
          local towerModel = TowersFolder:FindFirstChild(towerName)
          if towerModel then
               viewportFrame:ClearAllChildren()
               local cloneModel = towerModel:Clone()
               cloneModel.Parent = viewportFrame

               local camera = Instance.new("Camera")
               camera.Parent = viewportFrame
               viewportFrame.CurrentCamera = camera

               local modelCFrame, modelSize = cloneModel:GetBoundingBox()
               local distance = math.max(modelSize.X, modelSize.Y, modelSize.Z) * 1.2
               local cameraPosition = modelCFrame.Position + Vector3.new(0, distance * 0.5, distance * 0.8)
               camera.CFrame = CFrame.new(cameraPosition, modelCFrame.Position)
          end
     end
end


return LoadoutManager

To achieve this, its really not hard. In UI, you could name (or use attributes) on the frames in order, or use they LayoutOrder if they’re in a UIList/UIGrid Layout.
Then, server-side with the towers, simply store them like this:

towers = {
    [1] = "Trooper", --// Slot 1 is "Trooper"
    [2] = nil, --// Slot 2 is empty
    [3] = "Factory", --// Slot 3 has "Factory"
    [4] = nil, --// Slot 4 is empty
    [5] = nil, --// Slot 5 is empty
}
local ownedTowers = {
    [1] = "Trooper"
    [2] = "Factory"
    [3] = "Tank"
}


--// To update it (table form)
game.ReplicatedStorage.swapTowerFunction.OnServerInvoke = function(player: Player, position: number, tower: string): boolean
    if not ownedTowers[tower] then return false end --// Return false if the user does not own the tower
    towers[position] = tower
    return true
end)

Of course, beyond that you’d need logic to unequip the same tower if in a different slot, changes to how it works to fit you specifically, etc, but it gets the point across.

All you need to do is run GetAsync with the same key, and you’ll get the same data.
In the case of your game, if you use string.format("Player_%i", userId) or "Player_" .. userId as the key, it should work.

As for loading it into UI, you have a few options.
Option 1: Use a remote event to send the data over to the client (Unreliable)

Option 2: Create instances under the Player which are then read from after the server tells the Player that their data has loaded (Reliable)

Option 3: Same as Option 2, but just make the client read changes and load the second it can. (Less optimal, but probably just as reliable)

But yeah, to load frames in identical order, all you gotta do is just to form your DataStores correctly.

Again, have checks on the server to see whether the user has the gamepass or not, and only allow them to equip items in those slots if they have the gamepass, and when loading data, double-check if they own the gamepass and set slot 4 and 5 to nil if they don’t.

As for client logic, its nearly the same. When the gamepass is bought, show the extra UI, if its already owned, show the extra UI, else don’t show the UI, or alternatively show the UI but give a visual indicator to being locked (such as a literal lock icon) and make it prompt the player to buy the gamepass when clicked.

1 Like

with teleportservice you can send teleport data and then get it with GetLocalPlayerTeleportData

maybe more efficient than datastore

1 Like