Skip to content

Latest commit

History

4 Commits

Folders and files

NameName
Last commit message
Last commit date

Repository files navigation

KNDataAPI

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)

Layout

Built with Rojo 7.7.0. The server modules live in ServerScriptService.DataAPI:

ModuleRole
APITop-level, player-centric entry point. The one you require from your own scripts.
CoreThin public layer over Stores; exposes store + entry operations and load state.
StoresOwns every datastore wrapper (Store objects), the real Get/Set/Remove logic, read caching and request coalescing.
SchemaSchema lookup, deepMerge, served/gamemode flags, instance↔table conversion for stores.
InstanceMapperRecursive conversion of Lua tables ↔ Folder/Value instance hierarchies.
ManagerPlayer join/leave lifecycle: creates the Data folder on join, saves on leave / shutdown, retries, leaderstats, old-data migration.
Types / ErrorTypes / DebugShared 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 and the Store class

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:

FunctionDescription
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 / setNestedEntryTable-level helpers (single-level and path-based).
isLoaded() / isGamemodeLoaded() / onGamemodeStoresLoaded(cb)Load state and gamemode-store callbacks.

The Store object

Stores.createStore builds a wrapper around a real DataStore/OrderedDataStore. A Store has name, meta, the raw DataStore, and methods:

MethodDescription
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 as NumberValues.
  • Flat — entries are single dictionaries (e.g. PlayerInfo, Preferences, Flags); mirrored as a Folder of value children.
  • Layered — entries are nested dictionaries/arrays (e.g. Inventory); mirrored as nested Folder hierarchies.
  • Cold — persisted only via datastore calls, never mirrored into the player's folder; read/written through the *TableEntry* API helpers.

Reading, caching, and rate limiting

Stores.luau does the real I/O and guards the datastore API limits:

  • Read cache — successful GetAsync results are cached per storeName:key for CACHE_TTL (30s), so burst reads inside a session hit memory instead of the datastore.
  • Request coalescing — if two threads request the same store:key while a read is in flight, the second yields and reuses the first result instead of issuing a duplicate GetAsync.
  • Write invalidation — every SetEntry/RemoveEntry invalidates 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.

Startup

Stores.load() runs in two phases:

  1. Non-gamemode stores are created immediately so the API is usable fast.
  2. Gamemode-specific stores (schema _uniquePerGamemode = true) wait for the workspace.Gamemode attribute, then are created with a suffixed name (e.g. PlayerInfo_Survival), and onGamemodeStoresLoaded callbacks fire so Manager can supplement already-joined players.

DATASTORE_VERSION (-0.1v) is appended to every datastore name; bumping it effectively resets all stored data.


Schemas

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.
  • _servedfalse makes the store cold (datastore-only, no live instances).
  • _uniquePerGamemodetrue gives the store a gamemode-suffixed name when workspace.Gamemode is set.

How schemas are used

  • On load, Manager reads the datastore entry and runs Schema.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 valueInstance
    stringStringValue
    numberNumberValue
    booleanBoolValue
    dictionary {...}Folder with children named by key
    array {...}Folder with children named "1", "2", …
  • On save, Schema.instancesToTable walks the folders/values back into a Lua table, which is written with Store: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)

API usage

The general entry point is requiring API from ServerScriptService:

localDataAPI=game:GetService("ServerScriptService").DataAPIlocalAPI=require(DataAPI.API)

Manager wires its join/leave/save connections when it is required, so make sure something requires it at server startup (e.g. a bootstrap that requires both API and Manager).

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.

"Newest entry" behavior

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).

Player info (flat store)

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")

Preferences & flags (flat stores)

API.getAllPlayerPreferences(player)
API.getPlayerPreference(player, "CameraSway")
API.setPlayerPreference(player, "CameraSway", false)
API.getAllPlayerFlags(player)
API.getPlayerFlag(player, "IsVeteran")
API.setPlayerFlag(player, "IsVeteran", true)

Kill count (ordered store)

API.getPlayerKillCount(player)
API.setPlayerKillCount(player, 42) -- also syncs PlayerInfo.KillsAPI.incrementKillCount(player) -- +1 by default, returns new amount

Inventory (layered store)

API.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)

Table helpers (cold / non-served stores)

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)

Raw store access

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)

Client access

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.


Saving behavior

  • On leaveManager clones the player's Data folder, then persists every store entry from the instances with up to SAVE_ATTEMPTS (20) exponential-backoff retries. The clone means a player who vanished mid-save can't corrupt the save.
  • On shutdownBindToClose saves 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 the savePlayerData remote.
  • On joinManager seeds any missing store entries from their schemas (ordered stores to 0), 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 safetyPlayerRemoving marks players as saved so shutdown/removal can't double-save.

Extending

To add a new store:

  1. Add its name to the right list in src/server/Stores.luau (FLAT_STORE_NAMES, ORDERED_STORE_NAMES, LAYERED_STORE_NAMES, or COLD_STORE_NAMES) and to Types.ValidStores.
  2. Create a schema module in src/server/Schemes/ with the same name.
  3. Optionally add meta info in DEFAULT_META_INFO.
  4. Add high-level helpers to API.luau (or just use the raw store / table helpers).

About

Data API used to run KILL NPCS's data. Uses in-game instances to quickly update data without sending datastore calls, with multiple store types loadable. You're free to somehow make this work with your game!

Topics

Resources

Stars

0 stars

Watchers

0 watching

Forks

Contributors

Languages