Inventory Safety/Accessibility Suggestions?

Hello, I hope you’re all having a good day :slightly_smiling_face:

I am planning to create an inventory and shop system for a game, I made those before and have average experience, but in those I had to be extreme cautious for safety, which was too much sometimes, causing latency in some areas. What I currently need is a fully safe and easily accessible inventory system.

Keeping this in mind;

  1. If I was going to use an object(For example, a folder) for inventory and shop keeping, where would you suggest me to put it? Accessing other people’s inventory information wouldn’t cause problems in my game, so should I use ReplicatedStorage for direct information from a LocalScript or use ServerStorage and use a RemoteFunction between?
  2. Keeping Data loss problems in mind, how should I approach to saving and loading system? This is not a game where players would have multiple save slots, so my only idea goes off. A fine topic about solving this problem would be enough if there is one, let’s don’t waste any extra time on it :slightly_smiling_face:
  3. Would you suggest ViewportFrame for new work?
  4. Do you have any extra suggestions and things to say? It would be lovely!

Thank you all for your time, suggestions and solutions already :slightly_smiling_face:

I don’t like using ValueBase instances, but they’re not too bad here since you don’t mind players seeing everyone else’s inventory. It’d make sense to keep the inventories inside a folder for them contained in game.ReplicatedStorage.

Make sure you’re never firing a RemoteFunction from server to client under any circumstances whatsoever, it is insecue. From client to server is fine, though.

Here’s major mistakes to be careful of when dealing with player data management:

  • Don’t save or load guest data. Guests still exist!
  • DataStore calls can error. Call them in protected mode.
  • Make sure to check the DataStore request budget before making a DataStore call unless you’re sure that throttling won’t affect your game logic.
  • Players may join a server with a newer data schema, play, then join a server with an older schema version afterwards. Make sure that you do not erase the data from the newer schema.
  • Do not save data if the loading data call failed!
  • Be careful of the evil and improperly implemented DataStore cache. See buildthomas’ guide.
  • Make sure to attempt to save everyone’s data when the game is going to shutdown if possible, using game:BindToClose().
  • Don’t save data in your data loading logic when the player has no data saved.
  • Autosave everyone’s data every couple of minutes. This is very important.

I use a lock system now. It’s not perfect, but it seems to work well and I haven’t encountered any issues with it. In short, when loading a player’s data, if their data is “locked”, I’d wait some amount of time before ignoring the lock and proceeding anyway. This would help in cases where the player is rejoining the game, to give time for the last server they were in to save their data. I think my solution is pretty robust, and haven’t encountered any complete data loss yet.

I also use DataStore:UpdateAsync() calls to load data because I can avoid the cache that way, even if it takes up more of my write budget. Per TheGamer101, DataStore:UpdateAsync() will only cache-bust if you modify the data it returns, which is why I store last access information.

local player_data_preparer = {}

local services = {
    server_script = game:GetService("ServerScriptService"),
}

local server_import_root = services.server_script.server_code
local imports = {
    server = {
        player_informant = require(server_import_root.player_informant),
        transient_player_data = require(server_import_root.transient_player_data),
    },
}

local function attempt_load_persistence(player)
    local success = false

    local lock_success = imports.server.transient_player_data.lock_unlocked_persistence(player)

    if lock_success == false then
        return success
    end

    local persistence_fetch_success = imports.server.transient_player_data.load_persistence(player)

    if persistence_fetch_success == false then
        return success
    end

    if imports.server.player_informant.is_in_game(player) == false then
        return success
    end

    success = true

    return success
end

function player_data_preparer.prepare(player)
    local success = false

    imports.server.transient_player_data.create(player)

    if imports.server.player_informant.is_guest(player) == false then
        success = attempt_load_persistence(player)
    end

    imports.server.transient_player_data.export(player)
    
    return success
end

return player_data_preparer
local transient_player_data = {}

local services = {
    data_store = game:GetService("DataStoreService"),
    replicated_storage = game:GetService("ReplicatedStorage"),
    server_script = game:GetService("ServerScriptService"),
}

local core_import_root = services.replicated_storage.core_code
local server_import_root = services.server_script.server_code
local imports = {
    core = {
        broadcaster = require(core_import_root.broadcaster),
        table_utility = require(core_import_root.table_utility),
    },
    server = {
        broadcaster_channels = require(server_import_root.broadcaster_channels),
        player_informant = require(server_import_root.player_informant),
        player_readiness = require(server_import_root.player_readiness),
    },
}

local configuration = {}
configuration.schema_version = 3
configuration.data_schema = {
    metadata = {
        last_access = {
            global_timestamp = 0,
            local_timestamp = 0,
            game_job_identifier = "",
        },
        schema_version = configuration.schema_version,
    },
    data = {
        inventory = {
            backpack = {
                normal_sword = 50,
            },
            equipment = {},
        },
    },
}
configuration.persistence_lock_bypass_delay = 6
configuration.data_store_same_key_write_delay = 6

local player_data = {}
local player_persistence_load_successes = {}

local data_stores = {
    persistence = services.data_store:GetDataStore("player_persistence"),
    persistence_lock = services.data_store:GetDataStore("player_persistence_lock"),
}

local broadcast_channels = imports.server.broadcaster_channels.get_enumerated_names()

local function make_new_data()
    return imports.core.table_utility.get_deep_copy(configuration.data_schema)
end

local function make_last_access_data()
    return {
        global_timestamp = os.time(),
        local_timestamp = tick(),
        game_job_identifier = game.JobId,
    }
end

local function persistence_load_updater(persistence)
    if persistence == nil then
        persistence = make_new_data()
    end

    persistence.metadata.last_access_data = make_last_access_data()

    return persistence
end

local function get_persistence(player)
    return data_stores.persistence:UpdateAsync(player.UserId, persistence_load_updater)
end

local function merge_in_persistence(player, persistence)
    local is_persistence_schema_newer = persistence.metadata.schema_version > configuration.schema_version
    local merge_options = {
        source_overwriting = is_persistence_schema_newer,
    }
    local transient_data_of_player = player_data[player.UserId]
    local merged_data = imports.core.table_utility.get_deep_merge(transient_data_of_player.data, persistence.data, merge_options)
    
    transient_data_of_player.data = merged_data
end

local function set_persistence(player, data)
    local success = false

    local function persistence_updater()
       return data
    end

    local function update_persistence()
        data_stores.persistence:UpdateAsync(player.UserId, persistence_updater)
    end

    local update_success = pcall(update_persistence)

    success = update_success
    return success
end

local function activate_unactivated_lock(player)
    local success = false

    local function lock_container_activator(lock_container)
        local lock_container = {
            enabled = true,
        }

        lock_container.last_access_data = make_last_access_data()

        return lock_container
    end

    local function activate_lock()
        data_stores.persistence_lock:UpdateAsync(player.UserId, lock_container_activator)
    end

    local lock_activator_success = pcall(activate_lock)

    if lock_activator_success == false then
        return success
    end

    if imports.server.player_informant.is_in_game(player) == false then
        return success
    end

    success = true
    return success
end

local function deactivate_lock(player)
    local success = false

    local function lock_container_deactivator(lock_container)
        lock_container.enabled = false

        lock_container.last_access_data = make_last_access_data()

        return lock_container
    end
    
    local function lock_deactivator()
        data_stores.persistence_lock:UpdateAsync(player.UserId, lock_container_deactivator)
    end

    success = pcall(lock_deactivator)
    
    return success
end

function transient_player_data.is_created(player)
    return player_data[player.UserId] ~= nil
end

function transient_player_data.create(player)
    player_data[player.UserId] = make_new_data()
end

function transient_player_data.lock_unlocked_persistence(player)
    local success = false

    if services.data_store:GetRequestBudgetForRequestType(Enum.DataStoreRequestType.UpdateAsync) == 0 then
        return success
    end

    local last_lock = nil

    local function lock_container_updater(lock_container)
        if lock_container == nil then
            last_lock = false

            lock_container = {
                enabled = true,
            }
        else
            last_lock = lock_container.enabled

            if lock_container.enabled == false then
                lock_container.enabled = true
            end
        end

        lock_container.last_access_data =  make_last_access_data()

        return lock_container
    end

    local function update_lock_container()
        data_stores.persistence_lock:UpdateAsync(player.UserId, lock_container_updater)
    end

    local lock_update_success = pcall(update_lock_container)

    if lock_update_success == false then
        return success
    end

    if imports.server.player_informant.is_in_game(player) == false then
        return success
    end

    if last_lock == false then
        success = true
        return success
    end
    
    local lock_activation_delay = math.max(configuration.data_store_same_key_write_delay, configuration.persistence_lock_bypass_delay)
    wait(lock_activation_delay)

    if imports.server.player_informant.is_in_game(player) == false then
        return success
    end

    if services.data_store:GetRequestBudgetForRequestType(Enum.DataStoreRequestType.UpdateAsync) == 0 then
        return success
    end

    local lock_activate_success = activate_unactivated_lock(player)

    success = lock_activate_success
    return success
end

function transient_player_data.load_persistence(player)
    local success, persistence = pcall(get_persistence, player)

    if success == false then
        return success
    end

    merge_in_persistence(player, persistence)
    player_persistence_load_successes[player.UserId] = success

    return success
end

function transient_player_data.export(player)
    imports.server.player_readiness.set_readiness(player, true)

    local transient_data_of_player = player_data[player.UserId]
    imports.core.broadcaster.send(broadcast_channels.player_data_loaded, player, transient_data_of_player)
end

function transient_player_data.is_persistable(player)
    local persistability = false

    if player_persistence_load_successes[player.UserId] ~= true then
        return persistability
    end

    local readiness = imports.server.player_readiness.get_readiness(player)

    persistability = readiness
    return persistability
end

function transient_player_data.save_to_persistence(player)
    local success = false

    if services.data_store:GetRequestBudgetForRequestType(Enum.DataStoreRequestType.UpdateAsync) == 0 then
        return success
    end

    local transient_data_of_player = player_data[player.UserId]
    local set_persistence_succeess = set_persistence(player, transient_data_of_player)

    success = set_persistence_succeess
    return success
end

function transient_player_data.unlock_persistence(player)
    local success = false

    if services.data_store:GetRequestBudgetForRequestType(Enum.DataStoreRequestType.UpdateAsync) == 0 then
        return success
    end

    success = deactivate_lock(player)

    return success
end

function transient_player_data.destroy(player)
    imports.server.player_readiness.set_readiness(player, nil)
    
    imports.core.broadcaster.send(broadcast_channels.player_data_destroying, player)

    player_data[player.UserId] = nil
    player_persistence_load_successes[player.UserId] = nil
end

return transient_player_data
local player_readiness = {}

local ready_players = {}

function player_readiness.get_readiness(player)
    return ready_players[player] == true
end

function player_readiness.set_readiness(player, readiness)
    ready_players[player] = readiness
end

return player_readiness
local player_informant = {}

local services = {
    run = game:GetService("RunService"),
}

function player_informant.is_guest(player)
    local guest_state = false

    if services.run:IsStudio() == true then
        return guest_state
    end

    if player.UserId < 1 then
        guest_state = true
    end

    return guest_state
end

function player_informant.is_in_game(player)
    return player.Parent ~= nil
end

return player_informant
7 Likes

To answer your questions…

  1. You could use objects to store them, but that only takes up delicious memory. Use Lua tables.
    A secure thing to do would be to invoke the server and have it reply with an array of inventory items and caching it for a moment. This is so the server doesn’t have to respond to every one of the 38 times I check my inventory in the span of a minute (i do it a lot)
    If inventories are publicly visible, you could simply add an optional player argument in your event. You could, of course, use objects. I’m just suggesting something else so I sound smart.
  2. As of now, Roblox DataStores are pretty corrupt from what I’ve heard. However, they’re working on a fix to this and they should be more than ideal for what you’re doing.
    However, if you’re really protective over your data, you could set up an external host. DigitalOcean Droplets are scalable, pretty cheap and their services are reliable. For more info on making effective DataStores, be sure to read Avigant’s above post [insert above button]
  3. I don’t see anything wrong with using ViewportFrames other than it killing mobile devices. You gotta use these things wisely or it’ll ruin your UX. Also, optimizing for mobile is pretty high priority since most of your income comes from previously mentioned. Still reccomended, though.
  4. In terms of accessibility, you should always design from the mind of a child; what’s the best way to design x to do y? Make it use the least amount of taps possible to do an action and prioritize based on how often you’d utilize those actions.
1 Like

You really shouldn’t be doing that, the client should have their own inventory copy.

2 Likes

Thanks for answers and suggestions, those answered most of the questions in my mind(And created new questions, some can be answered by trying but I’ll ask away the ones which I can’t answer :smiley:).

  • As I can see ValueBase and other objects are not really suggested for inventories, I can try and make a table version for inventory, but I guess I have to use objects for shop since I want it to be accessible from everywhere and also I want it to be changeable easily, for my teammates who wants to add, remove or edit items :slightly_smiling_face:
  • Kind of warnings about errors, data schema and request budget got me aware of those, will be more careful about those now.
  • Locking data sounds pretty awesome idea, absolutely going to try it, thanks!
  • An external host looks good but, I really can’t afford even the cheapest one, my goal is to keep a lot of data and I would require a lot of place. Thanks for suggestion though!
  • Thanks for optimization warning, I got the taste of the lag once because of meshes, won’t let it happen for ViewportFrames, maybe a 3D/2D Inventory option that player can choose, or changes according to the platform? I’ll see.

A new question in my mind is though, @Avigant how could one keep a copy of inventory in client, that is safe from exploiters?

1 Like

The client merely keeps the copy, the server has the authoritative version. Changes made on the server are sent to the client without replicating the full table each time. The server validates the client’s claims about the inventory in relevant network requests, there’s no way to completely stop exploiters from changing anything client-side.

1 Like

So the client copy is only to view, and each time player wants to make an action other than viewing, full table gets replicated? Or only related item(s) gets checked and updated and the rest stays upon an action?

1 Like

The client inventory can be modified if so desired. Full table is only replicated once, when the inventory is first loaded, and never again. On actions such as an inventory item being added server-side, you inform the clien that the item was added, and the client makes a change to their inventory based on that information.

When the client performs a network request affecting the inventory, the server will validate that the inventory copy the server has allows the action to be performed, and not trust that the client says they are able to perform it.

2 Likes

That’s all I can ask for now, thanks :smiley:

1 Like