A Roblox datastore-wrapping system that saves and loads player data by mirroring every datastore entry into live Instance hierarchies (Folders / ValueBases parented to the Player). The game reads and writes these instances during a session (fast, zero datastore calls), and the datastore is only touched on join (load) and leave / manual save (persist). This keeps datastore API usage low while making player data as easy to manipulate as instances.
Schema (defaults + flags) → Store wrapper (real DataStore) → Manager (join/leave lifecycle)
↕ table ↔ instance ↕
InstanceMapper Player/Data folder (live working copy)
Built with Rojo 7.7.0. The server modules live in ServerScriptService.DataAPI:
| Module | Role |
|---|---|
API | Top-level, player-centric entry point. The one you require from your own scripts. |
Core | Thin public layer over Stores; exposes store + entry operations and load state. |
Stores | Owns every datastore wrapper (Store objects), the real Get/Set/Remove logic, read caching and request coalescing. |
Schema | Schema lookup, deepMerge, served/gamemode flags, instance↔table conversion for stores. |
InstanceMapper | Recursive conversion of Lua tables ↔ Folder/Value instance hierarchies. |
Manager | Player join/leave lifecycle: creates the Data folder on join, saves on leave / shutdown, retries, leaderstats, old-data migration. |
Types / ErrorTypes / Debug | Shared types, typed error objects, and logging helpers. |
Schemes/ | One schema module per store (PlayerInfo, Preferences, Flags, Inventory). |
On the client, src/client is a small bootstrapper that fires Datastore.LoadClientData and waits for DataLoaded. All client-facing reads go through ReplicatedStorage.Datastore remotes (see Client access).
Core (src/server/Core.luau) is the internal glue between your code and the datastores. It yields until the API has finished booting (Stores.load()) before doing anything, and wraps every call in pcall so failures degrade gracefully instead of erroring.
Exposed on Core:
| Function | Description |
|---|---|
getStore(name) | Returns the Store object for a store name. Returns a safe "Invalid Store" fallback instead of erroring if it isn't loaded. |
getStoreEntry(name, key) / updateStoreEntry(name, key, value) / removeStoreEntry(name, key) / storeEntryExists(name, key) | Raw entry operations by store name. |
getAllFlatStores() / getAllOrderedStores() / getAllColdStores() / getAllLayeredStores() | Lists of live store objects, grouped by kind. |
getTableEntry / setTableEntry / getNestedEntry / setNestedEntry | Table-level helpers (single-level and path-based). |
isLoaded() / isGamemodeLoaded() / onGamemodeStoresLoaded(cb) | Load state and gamemode-store callbacks. |
Stores.createStore builds a wrapper around a real DataStore/OrderedDataStore. A Store has name, meta, the raw DataStore, and methods:
| Method | Description |
|---|---|
GetEntry(key) | Read the whole entry at key. |
SetEntry(key, value) | Overwrite the entire entry at key (whole-record write). |
RemoveEntry(key) | Remove the entry. |
EntryExists(key) | Check an entry exists. |
GetMetaInfo() | Human-readable store description. |
GetTableEntry(key, tableKey) | Read one key of a single-level table entry. |
SetTableEntry(key, tableKey, value) / SetEntryData(...) | Write one key of a single-level table entry. |
GetNestedEntry(key, keyPath) | Read a nested value by path, e.g. { "Loadouts", "Default", "Guns", 1 }. |
SetNestedEntry(key, keyPath, value) | Write a nested leaf without clobbering the rest of the root table. |
Store kinds (configured in Stores.luau):
- Ordered — entries are plain numbers (e.g.
KillCount); mirrored asNumberValues. - Flat — entries are single dictionaries (e.g.
PlayerInfo,Preferences,Flags); mirrored as aFolderof value children. - Layered — entries are nested dictionaries/arrays (e.g.
Inventory); mirrored as nestedFolderhierarchies. - Cold — persisted only via datastore calls, never mirrored into the player's folder; read/written through the
*TableEntry*API helpers.
Stores.luau does the real I/O and guards the datastore API limits:
- Read cache — successful
GetAsyncresults are cached perstoreName:keyforCACHE_TTL(30s), so burst reads inside a session hit memory instead of the datastore. - Request coalescing — if two threads request the same
store:keywhile a read is in flight, the second yields and reuses the first result instead of issuing a duplicateGetAsync. - Write invalidation — every
SetEntry/RemoveEntryinvalidates the cache for that key and clears any in-flight request. - Session behavior — during normal play, values are edited through the live instances (see API), and the datastore is only written on save. This is what "fast data saving, limited API calls" means.
Stores.load() runs in two phases:
- Non-gamemode stores are created immediately so the API is usable fast.
- Gamemode-specific stores (schema
_uniquePerGamemode = true) wait for theworkspace.Gamemodeattribute, then are created with a suffixed name (e.g.PlayerInfo_Survival), andonGamemodeStoresLoadedcallbacks fire soManagercan supplement already-joined players.
DATASTORE_VERSION (-0.1v) is appended to every datastore name; bumping it effectively resets all stored data.
A schema is a plain module in src/server/Schemes/ named after its store. It defines the default shape of that store's entries, plus reserved meta keys:
-- Schemes/PlayerInfo.luaureturn {
_version="1.1.0", -- schema version; used for migration checks_served=true, -- mirror into the player's Data folder (hot-serve)_uniquePerGamemode=true, -- give the store a gamemode-suffixed datastore nameKills=0,
Deaths=0,
Level=0,
Experience=0,
Completion=0,
Credits=0,
Rank="None",
}_version— bumped when the schema changes; downstream code (e.g.ensureInventoryLoadoutVersion) compares it to decide whether to migrate/reset._served—falsemakes the store cold (datastore-only, no live instances)._uniquePerGamemode—truegives the store a gamemode-suffixed name whenworkspace.Gamemodeis set.
On load,
Managerreads the datastore entry and runsSchema.deepMerge(schema, storedEntry). Missing keys are filled with schema defaults (so a player from two versions ago still gets a complete entry); arrays are replaced, not merged; keys the player added that aren't in the schema are preserved.Instancing — the merged table is converted into a live hierarchy via
InstanceMapper.tableToFolder:Lua value Instance stringStringValuenumberNumberValuebooleanBoolValuedictionary {...}Folderwith children named by keyarray {...}Folderwith children named"1","2", …On save,
Schema.instancesToTablewalks the folders/values back into a Lua table, which is written withStore:SetEntry.
The resulting per-player layout is:
Player
└── Data
├── FlatData/ (PlayerInfo, Preferences, Flags …)
│ └── PlayerInfo (Folder)
│ ├── Kills (NumberValue)
│ ├── Rank (StringValue)
│ └── ...
├── OrderedData/ (KillCount …)
│ └── KillCount (NumberValue)
└── LayeredData/ (Inventory …)
└── Inventory (Folder)
├── OwnedItems (Folder: "1", "2", …)
├── Loadouts (Folder: Default, ...)
│ └── Default (Folder)
└── ActiveLoadout (StringValue)
The general entry point is requiring API from ServerScriptService:
localDataAPI=game:GetService("ServerScriptService").DataAPIlocalAPI=require(DataAPI.API)
Managerwires its join/leave/save connections when it is required, so make sure something requires it at server startup (e.g. a bootstrap that requires bothAPIandManager).
All player-facing functions accept a player, their UserId number, or their username string as the first argument. Calling API before the system has loaded will simply yield until it is ready.
The core idea of the API: if the player is in this server, read/write the live instances; otherwise, talk to the datastore directly. So getPlayerInfo(player) is instant and API-free for in-server players, and setPlayerInfo(player, ...) only mutates a NumberValue — the datastore is written later when the player leaves (or a manual save runs).
API.getAllPlayerInfo(player) -- whole tableAPI.getPlayerInfo(player, "Kills") -- one statAPI.setPlayerInfo(player, "Kills", 10)
API.incrementPlayerInfo(player, "Credits", 50)
API.getPlayerRank(player) /API.setPlayerRank(player, "Admin")API.getAllPlayerPreferences(player)
API.getPlayerPreference(player, "CameraSway")
API.setPlayerPreference(player, "CameraSway", false)
API.getAllPlayerFlags(player)
API.getPlayerFlag(player, "IsVeteran")
API.setPlayerFlag(player, "IsVeteran", true)API.getPlayerKillCount(player)
API.setPlayerKillCount(player, 42) -- also syncs PlayerInfo.KillsAPI.incrementKillCount(player) -- +1 by default, returns new amountAPI.getAllPlayerInventory(player) -- whole entry (validates/auto-resets version)API.getPlayerOwnedItems(player)
API.playerOwnsItem(player, "Sword")
API.addPlayerOwnedItem(player, "Sword")
API.removePlayerOwnedItem(player, "Sword")
API.removeAllOwnedItems(player)
API.getPlayerLoadouts(player)
API.getPlayerLoadout(player, "Default")
API.setPlayerLoadout(player, "MyLoadout", { "Sword", "Pistol" })
API.upsertPlayerLoadout(player, "MyLoadout", { "Sword", "Pistol" }) -- respects loadout limitAPI.removePlayerLoadout(player, "MyLoadout")
API.getPlayerActiveLoadout(player)
API.setPlayerActiveLoadout(player, "MyLoadout")
API.getPlayerLoadoutEntry(player, "Default", { 1 }) -- nested read inside a loadoutAPI.setPlayerLoadoutEntry(player, "Default", { 1 }, "Sword")
API.resetPlayerLoadoutsToDefault(player)For stores that are not mirrored into the player folder:
API.getPlayerTableEntry(player, "SomeColdStore")
API.setPlayerTableEntry(player, "SomeColdStore", { ... })
API.getPlayerTableEntryData(player, "SomeColdStore", "key")
API.setPlayerTableEntryData(player, "SomeColdStore", "key", value)
API.incrementPlayerTableEntryData(player, "SomeColdStore", "key", 5)If you need lower-level control:
localstore=API.getStore("PlayerInfo")
store:GetEntry(player.UserId)
store:SetNestedEntry(player.UserId, { "Loadouts", "Default", "Guns", 1 }, "Sword")
API.getStoreEntry("KillCount", player.UserId)
API.updateStoreEntry("Preferences", player.UserId, { ... })
API.removeStoreEntry("Flags", player.UserId)Clients cannot require the server API. They read data through remotes under ReplicatedStorage.Datastore (getAllPlayerInfo, getPlayerInfo, getPlayerInventory, getPlayerPreference, getAllPlayerFlags, getPlayerFlag, getPlayerKillCount, setPlayerPreference, setPlayerSeenLoadout, plus DataLoaded / PreferenceUpdated events). Writes are limited to preferences; everything else is server-authorized.
- On leave —
Managerclones the player'sDatafolder, then persists every store entry from the instances with up toSAVE_ATTEMPTS(20) exponential-backoff retries. The clone means a player who vanished mid-save can't corrupt the save. - On shutdown —
BindToClosesaves every remaining player (waits up to 30s). - Manual save — a server script can call
Manager.throttledSave(player)(rate-limited to one save per player every 120s), or clients invoke thesavePlayerDataremote. - On join —
Managerseeds any missing store entries from their schemas (ordered stores to0), then loads each entry, deep-merges with the schema defaults, and constructs the live folder. If the datastore fails, it retries (LOAD_ATTEMPTS) and finally kicks the player. - Save safety —
PlayerRemovingmarks players as saved so shutdown/removal can't double-save.
To add a new store:
- Add its name to the right list in
src/server/Stores.luau(FLAT_STORE_NAMES,ORDERED_STORE_NAMES,LAYERED_STORE_NAMES, orCOLD_STORE_NAMES) and toTypes.ValidStores. - Create a schema module in
src/server/Schemes/with the same name. - Optionally add meta info in
DEFAULT_META_INFO. - Add high-level helpers to
API.luau(or just use the raw store / table helpers).