Skip to content

DataDriver

Persists player data through atomic writes under a session lock, and fails the load or the save instead of substituting defaults or writing data it cannot read back.

Realm
Server
Require
ServerScriptService.ChloeKernelServer.Data
Since
v0.1.0
Source
Data/init.luau

DataDriver is the server’s persistence layer: one driver per store, one Profile per loaded key, and every write an atomic read-modify-write under a session lock. Its defining rule is to fail closed. A record it cannot read is never replaced with defaults, data it cannot store exactly is never written, and a key that another live server holds is never touched. Defaults overwriting a real record is how data wipes happen, and a load without a lock is how items get duplicated. The parts big enough for their own pages are lock handoff (fast rejoins across servers), version history (restoring corrupt records), backups, profile snapshots and the storage backends. Receipts and Transactions are built on its profiles.

Every stored record is an envelope, { Data, Meta }. Data is the game’s table, or a codec-packed blob of it. Meta is the driver’s bookkeeping and is never codec-encoded:

Meta field Meaning
SchemaVersion The schema the record was last written under. Versioning lives here; a codec carries no version byte.
CreatedAt, UpdatedAt First and latest write, from the driver’s Clock (default os.time).
Lock { JobId, Token, Timestamp, Presence? }, the session lock. Token is a fresh GUID for every acquisition. Presence = true means the holder beats presence for lock handoff. Absent after a release.
ReleasedToken The token of the release that cleared Lock. A release whose response was lost recognizes its own commit by it.
UserIds The record’s owners for Roblox’s GDPR erasure. The DataStore backend passes them to UpdateAsync.
DefaultPaths The reconcile ledger: every Defaults path this record has already received.
TransactionIds Replay ids of committed trades, the newest 64. See Transactions.
PendingTransaction { Key, Token }, a pointer to a trade journal while a trade is in flight or waiting for recovery.
BackupAt, BackupCounter The backup schedule. See Backups.
RestoredFromBackupAt, RestoredFromSlot Stamped when a load restores a backup slot or a version.

With a codec, Data is stored as { F = "Buf", P = buffer } on backends that accept buffers (DataStores) and { F = "B64", P = string } elsewhere. Codec = "Auto" records also carry A = 2 and S, the layout that wrote them (see Codecs).

Four rules hold on every path:

  1. Every write is an atomic read-modify-write. The driver only calls backend:update(key, transform), which is UpdateAsync on DataStores. The transform sees the stored envelope and returns a new one, or nil to abort with nothing written. A load is a write too, because it takes the lock.
  2. Ownership is checked inside the transform. A save needs the exact lock its profile acquired: the same JobId and the same Token. Only a load can take another server’s lock, and only when that lock is stale by age or its holder has stopped beating presence. The token works as a fencing token: a stale owner cannot overwrite its successor even when both run under the same JobId.
  3. A server runs one operation per key at a time. A load reserves its key until it finishes, and each profile runs one save at a time. Two overlapping retry ladders could otherwise land an older snapshot after a newer one, or re-lock a key that release() just unlocked.
  4. Data is checked before any I/O: serializability when there is no codec (UTF-8, finite numbers, cycles, key shapes), then Validate, then a codec read-back, then the 4 MB value limit. A save that fails any check is refused, and the stored data is left as it was.

attach(kernel) wires the whole per-player lifecycle. Games get this from python tools/scaffold.py data, which creates a DataService like this one:

src/Server/Game/Services/DataService.luau
--!strict
-- DataService: owns player persistence.
local ServerScriptService = game:GetService("ServerScriptService")
local DataDriver = require(ServerScriptService.ChloeKernelServer.Data)
local DataService = {
Name = "DataService",
}
function DataService.init(self: any, kernel: any)
self.Driver = DataDriver.new({
Name = "PlayerData",
Defaults = { Coins = 0, Level = 1, PlaySeconds = 0, Inventory = {}, Settings = { Music = true } },
SchemaVersion = 1,
-- Types and shape only. A game rule (Coins >= 0) that a bug can break would refuse every save.
Validate = function(data: any): boolean
return type(data.Coins) == "number" and type(data.Level) == "number" and type(data.Inventory) == "table"
end,
})
self.Driver:attach(kernel)
end
function DataService.start(_self: any) end
function DataService.stop(_self: any) end
return DataService

Other services wait for the profile through Milestones, which replays profiles that loaded before the subscription. A cleanup bound after the load still runs before the final save:

src/Server/Game/Services/PlaytimeService.luau
--!strict
-- PlaytimeService: adds each visit's length to the profile.
local ServerScriptService = game:GetService("ServerScriptService")
local Milestones = require(ServerScriptService.ChloeKernelServer.Milestones)
local PlaytimeService = {
Name = "PlaytimeService",
Dependencies = { "DataService" },
}
function PlaytimeService.init(self: any, kernel: any)
self.Kernel = kernel
end
function PlaytimeService.start(self: any)
self.Loaded = Milestones.on(self.Kernel, "Kernel.ProfileLoaded", function(session, profile)
local JoinedAt = os.clock()
session:bind(function()
profile.Data.PlaySeconds += math.floor(os.clock() - JoinedAt)
end)
end)
end
function PlaytimeService.stop(self: any)
if self.Loaded then
self.Loaded:disconnect()
end
end
return PlaytimeService

What attach(kernel) does:

Moment What the driver does
Join Sets session.ProfilePending = true and loads Player_<UserId>, tagging the player’s UserId inside the load’s own write. If another server holds the lock, it waits up to LockWaitSeconds (default 15) while lock handoff asks that server to let go. On success it sets session.Profile, clears ProfilePending and publishes Kernel.ProfileLoaded(session, profile) as a milestone, plus Kernel.ProfileRestored(session, profile) when the load restored an older copy. On failure it warns and kicks the player with a message saying nothing was lost, unless KickOnLoadFailure = false. Defaults are never substituted.
Left during the load The profile is released at once, so it cannot stay locked with nobody saving it.
Leave The release save is bound with session:bindLast, so it runs after every session:bind cleanup and OnEnd listener, including cleanups bound in Kernel.ProfileLoaded handlers. Leave-time writes reach it.
Autosave Every AutosaveSeconds (default 60) a Background-priority Scheduler job starts a pass off the frame. The pass spreads its saves over a quarter of the interval and runs each profile’s save on its own thread, skipping any profile whose previous save is still running. The autosave is also every profile’s lock heartbeat, so one stuck request must not delay the rest. For a profile staged by restoreAt, the autosave is only that heartbeat and writes no data.
Shutdown Waits for the service stop phase, then drains every profile. See Leave and shutdown.
Lock lost A save, or the lock refresh of a staged restoreAt profile, that finds its lock taken ends the player’s session and publishes Data.LockLost.
Blocked trade A trade participant left blocked for recovery is kicked when Commerce.TransactionEnded fires, so the next load settles the trade.

While session.ProfilePending is true, write nothing that must persist. The kits already handle it: InventoryKit merges items granted during the load into the profile when it arrives, AchievementKit and QuestKit hold unlocks and completions until then, and CraftingKit refuses timed crafts with Loading.

Records that are not player-keyed, such as clan banks, use the same primitives directly:

local ClanData = DataDriver.new({
Name = "ClanData",
Defaults = { Vault = 0, Members = {} },
})
local function deposit(clanId: string, amount: number, depositorId: number): (boolean, string?)
local Bank, LoadErr = ClanData:load(`Clan_{clanId}`, { UserIds = { depositorId } })
if not Bank then
return false, LoadErr
end
Bank.Data.Vault += amount
-- release() is a final save that also removes the lock
return Bank:release()
end

load returns nil, err on every failure. A manually loaded profile is yours to release(); until you do, no other server can load the key. Manual profiles are not autosaved unless the driver is attached, in which case every active profile is included in the passes.

Everything DataDriver.new(config) reads:

Field Default Description
Name required Store name. Also the base of the default backup store ({Name}_Backups) and the name lock handoff routes requests by.
Defaults required Template for new keys and the reconcile source for every load.
Backend "DataStore" "DataStore", "Memory", "Http" or a backend instance. See Backends.
BackendConfig nil { Scope?, Store?, Service? } for DataStore, { BaseUrl, Headers?, Request? } for Http.
Versions { Fallback = true, MaxVersions = 10 } Restore from native DataStore version history. See Version history.
Backup nil Rotated copies in separate keys. See Backups.
Codec nil A Serde schema codec, or "Auto". nil stores plain tables.
SchemaVersion 1 The schema this build writes.
Migrations {} { [n] = function(data) -> data } upgrades a vN record to vN+1. Runs inside the transform and must not yield.
Validate nil (data) -> boolean. Checks every save and every load. Must not yield.
AutosaveSeconds 60 Autosave interval when attached. Every save refreshes the lock.
LockTtlSeconds 180 Age after which a lock counts as stale. Asserted to be at least twice AutosaveSeconds, because a TTL without headroom over the heartbeat makes live locks stealable mid-session.
LockSkewSeconds 60 Extra age allowed for clock differences between servers before a stale lock is taken.
LockWaitSeconds 15 How long a joining player waits for another server’s lock before the load fails.
LocalHandoffSeconds nil How long load() waits for a load or release of the same key on this server. Player sessions use LockWaitSeconds when unset; manual loads use 0.
Handoff on for live servers Cross-server lock handoff. On by default for live servers on a shared backend; false turns it off.
BackoffSeconds { 1, 2, 4, 8 } Retry ladder for every backend call. Attempts are #BackoffSeconds + 1; a 0 entry retries without yielding a frame.
KickOnLoadFailure true Attached lifecycle only: kick the player when the load fails.
JobId game.JobId Lock owner identity ("Studio" when game.JobId is empty). Setting it turns the default handoff off; tests use it to simulate servers.
Clock os.time Time source for locks and backup schedules. Injectable for tests.

driver:load(key, options?) reserves the key on this server, then runs one atomic update, retried up the ladder. Inside the transform, in order:

  1. A lock held by another server aborts the load. A lock counts as held until it is older than LockTtlSeconds + LockSkewSeconds (4 minutes by default). A stale lock whose holder beats presence is checked before it is taken, and a holder that still beats keeps it. See Lock handoff.
  2. A record with Meta.PendingTransaction aborts without writing. The driver settles the trade from its journal outside the transform (transaction recovery) and loads again.
  3. A missing record becomes a deep copy of Defaults at SchemaVersion.
  4. A malformed envelope, or a SchemaVersion that is not a positive integer, aborts. So does a record at a newer SchemaVersion than this build, checked before decoding because a newer layout may not decode with this codec.
  5. A codec record is decoded with the layout that wrote it. Data that will not decode, or is not a table, aborts.
  6. Migrations run in order from the stored version to SchemaVersion.
  7. Missing Defaults fields are filled in, following the ledger (see Reconciling defaults).
  8. Without a codec, the data must be serializable. Validate must accept it.
  9. UserIds from options.UserIds are merged into Meta.UserIds, the lock { JobId, Token, Timestamp, Presence } is written, and the envelope goes back in the same operation, re-encoded. A codec that cannot encode the migrated data fails at once, since retrying a deterministic failure cannot help.

Every failure is typed:

Failure Error Falls back to an older copy
Backend down through the whole ladder the backend’s error No: loading without a lock is a duplication path
Another server holds the lock profile is locked by another server No: the lock wins, even over a corrupt record
A trade journal that cannot be settled yet transaction recovery failed: ... No
Record written by a newer SchemaVersion stored record is schema vN, newer than this server's vM (rolled-back deploy?) No: old code must not run over newer data
A migration or Validate yielded migration vN yielded; ..., Validate yielded; ... No: a code defect fails the same way on older data
Validate rejects the live record validation rejected the stored data; not restoring an older version over it No
A migration is missing or throws on the live record no migration from schema vN to vN+1, migration vN failed: ... No
The migrated data does not fit the codec migrated data does not fit the codec: ... No
Key already loaded or loading on this server profile is already loaded on this server, profile is already loading on this server No
The driver is draining for shutdown driver is shutting down No
Malformed envelope, data that will not decode, data that is not a table, unserializable stored data stored data failed to decode: ... Yes: version history, then backups

Only the last row permits a fallback. The record exists and is writable but its bytes are unreadable, so an older copy is the best data there is. Every other failure means the rules or the code changed, or ownership is unclear, and an older copy would roll the player back. The driver never writes the record in those cases; fix the rule, add the migration or deploy the newer build and the same record loads.

Every load fills in Defaults fields the record lacks, deep-copied, and never overwrites a saved value. Each record keeps a ledger of the default paths it has received (Meta.DefaultPaths). A path in the ledger is never refilled, so an entry the game removed stays removed. Without the ledger, a starter item the player sold would come back on every load, which is a duplication path. Records written before the ledger existed get missing dictionary keys filled but never array entries refilled. Backups and version history candidates carry the ledger, so a restore keeps removed entries removed too. Before 0.9.1 a restore reconciled without it.

Option Description
UserIds { number } merged into Meta.UserIds inside the load’s own write, so even a brand-new record is covered by GDPR erasure. attach passes the player’s UserId.
HandoffSeconds How long to wait for a load or release of the same key on this server before giving up. Default LocalHandoffSeconds or 0.

profile:save() runs the same kind of atomic transform. Before anything reaches the backend, the data is snapshotted (later mutations during the yield do not leak into the write) and checked. A failed check refuses the save:

Check Error
Unserializable data (no codec): invalid UTF-8 in a string or key, NaN or infinite numbers, cycles, keys that are not strings or positive integers, mixed or sparse arrays data is not serializable: Data.Inventory.3 is not a finite number
Validate returned false or raised validation rejected the data; refusing to overwrite the stored record
The codec cannot encode the data data does not match the codec schema: ...
The codec would read back a different value the codec would store a different value at Data.Coins; refusing to overwrite the stored record
The envelope is over 4,194,304 bytes profile is N bytes, over the 4194304-byte DataStore value limit

Roblox serializers fail quietly on bad values (JSONEncode drops functions, DataStores reject NaN and mixed tables inconsistently), so the driver checks first and names the exact path.

A refused save publishes Data.SaveRefused(key, reason) and returns false, reason. The session lock stays fresh: when the lock is older than AutosaveSeconds, the driver writes a lock-only refresh. This server still holds newer data than the stored record, and letting the lock go stale would let another server load the older copy. The size check measures the envelope’s JSON size, counting a buffer at its base64 length, and the first time a profile passes 75% of the limit the driver warns once.

Every backend call runs through BackoffSeconds. With the default ladder that is 5 attempts over 15 s of waiting.

  • Throttling errors (a message containing “throttl”, or codes 301 to 306: ExperienceThrottled, GameServerThrottled, KeyThrottled and full throttle queues) mean the request budget is spent. Those retries wait 1.5 to 2.5 times the ladder’s delay, jittered so servers stop retrying in lockstep, and the total wait still never exceeds the ladder’s sum.
  • The first throttled error of an episode publishes Data.Throttled(key, errorText). The episode ends when any request through the driver succeeds.
  • Key name, value type and value size errors (codes 101 to 105) repeat on every attempt and are not retried.
  • While the DataStore reports no StandardRead or StandardWrite budget left, an ordinary autosave waits for the next pass, provided the lock would still be fresh by then. Leave saves and shutdown saves never wait. A warning counts the deferred saves.

If the transform finds a lock that is not this profile’s, the save writes nothing. That happens when another server took the lock, either by age (this server’s saves stalled past LockTtlSeconds + LockSkewSeconds and no presence beat protected the lock) or because this server’s presence beat stopped. The profile deactivates, the driver warns, publishes Data.LockLost(key) and kicks the player. The other server holds the record now, and the player continues on that copy. Only acquisition may take a stale lock; saves need exact ownership.

profile:release() is a save that also removes the lock and stamps Meta.ReleasedToken. When it succeeds the profile deactivates (Active = false) and any server waiting on a handoff is told the lock is free.

When the backend stays down through the whole ladder, the profile stays active and registered. That blocks a racing reload on this server and keeps the unsaved state. The release retries in the background after waits of 5, 15 and 30 s, then autosave passes and the shutdown drain keep attempting it. A player who rejoins this server meanwhile carries on with the retained profile. A release that committed but reported failure is recognized by its ReleasedToken: a retried release writes nothing, and a same-server rejoin retakes the lock because nobody has written since. A release requested while a trade holds the profile returns false, "profile is in a transaction" and runs when the trade ends.

The leave save runs last. The session calls every bind cleanup and OnEnd listener first, then the finalizers from bindLast, where the driver’s release sits. Before 0.9.0 the final save ran first and writes from later cleanups were lost.

The attached driver binds its own BindToClose and drains in two steps:

  1. It waits for the kernel’s close stop phase (Kernel.Stopped), up to 5 s. The server kernel runs service stop() in reverse boot order during close, and a stop() may still write profile data, such as an end-of-round payout. A player who leaves during the close waits for the same phase before their leave save.
  2. It calls releaseAll with a deadline 27 s after the close began (the engine allows 30). Every active profile’s release retries every 0.25 s until it lands or the deadline passes. Loads that finish during the drain are released too, and new loads are refused with driver is shutting down. A restoreAt staging that nobody saved or released is discarded instead: its lock is removed and the stored record stays as it was. Keys that have still not saved at the deadline are named in one warning.

driver:releaseAll(deadline?) takes an os.clock() deadline; without one it drains for 25 s.

Schema changes are explicit: bump SchemaVersion and add one migration per step.

local PlayerData = DataDriver.new({
Name = "PlayerData",
Defaults = { Wallet = { Coins = 0, Gems = 0 }, Inventory = {} },
SchemaVersion = 3,
Migrations = {
[1] = function(data) -- v1 to v2: Coins moves into Wallet
data.Wallet = { Coins = data.Coins or 0 }
data.Coins = nil
return data
end,
[2] = function(data) -- v2 to v3: Gems added
data.Wallet.Gems = 0
return data
end,
},
})

A v1 record runs [1] then [2] inside the transform and is written back at v3. With a codec, migrations run on the decoded data, so they are written against plain tables either way.

A migration that is missing or throws on the live record fails the load, and the record stays byte-identical. Deploy the fixed migration and it loads. A migration that yields fails the load once, by name, instead of repeating on every retry: a yield inside UpdateAsync fails the request, and every older copy would fail the same way.

Validate(data) -> boolean runs on every save and on every load. It exists to catch corrupt or foreign records, so it should check types and shape, never game rules. The driver never writes data Validate rejects, so a rule a game bug can break (negative coins) refuses every save for the rest of the session, and the player’s progress stays unsaved until the data passes again.

On load, the driver treats a live record Validate rejects as a sign that the rules changed or differ on this server. The load fails instead of restoring an older version over the record. Relax the rule or add a migration before deploying a stricter Validate.

By default Data is stored as a plain table. A Serde schema codec stores it as a packed buffer instead, which makes records much smaller:

local PlayerData = DataDriver.new({
Name = "PlayerData",
Defaults = { Coins = 0, Inventory = {}, Settings = { Music = true } },
Codec = Serde.schema({
Coins = "NumberU32",
Inventory = { "NumberU16" },
Settings = { Music = "Boolean8" },
}),
})

The driver makes sure a codec never changes the data it stores. At boot, an explicit codec must encode and decode Defaults unchanged, and DataDriver.new errors otherwise, naming the field: the codec does not hold Defaults.X; add it to the schema or enable Extras. On every save, the encoded payload is decoded again and compared with the data, so a clamped integer, a dropped field or a list with a hole refuses the save. Numbers match within about 1e-3 relative (what an F16 field keeps), so a schema’s deliberate precision passes, and engine values such as Vector3 match by type.

A plain-table record from before the codec still loads, and its next write stores it encoded. An existing game can adopt a codec without a migration.

Codec = "Auto" infers a schema from Defaults with Extras enabled, so fields added before the schema catches up still persist. Every inferred number is NumberF64, exact for integers up to 2^53. Each Auto record carries the layout that wrote it (A = 2 and the inferred spec in S), and a reader decodes with that layout. Adding a Defaults field keeps older records readable, and a server still on the previous Defaults reads a newer record correctly instead of misreading and rewriting it. Auto records saved before 0.9.0 have no marker; they decode with the legacy integer layout and the load’s own write upgrades them to the F64 layout. Do not downgrade the framework after that upgrade.

A player who rejoins within seconds, on this server or another, is handed over without a kick or a rollback. Same-server rejoins reuse the retained profile; cross-server rejoins ask the holder to release over MessagingService, and every server beats a MemoryStore presence key so a crashed server’s locks are taken in about 15 s. See Lock handoff.

On DataStores, a record that will not decode restores the newest readable native version before any backup, and driver:restoreAt(key, unixMs) stages the version current at a moment for support tooling. See Version history.

Config.Backup adds rotated copies in separate keys, written only by the lock holder, with the schedule stored in the record. See Backups for the schedule, the mirror slot, restore order and the support tooling (backupNow, listBackups, peekBackup).

profile:snapshot() and profile:rollback(id) keep in-memory undo points for one profile. See Profile snapshots.

Member Description
DataDriver.new(config) -> DataDriver See Configuration. Asserts Name, Defaults, LockTtlSeconds >= 2 * AutosaveSeconds, Backup.Keep >= 1, the backup store name length and, for an explicit codec, that it holds Defaults. Warns in Studio when the driver uses the live DataStore.
driver:attach(kernel) Wires the per-player lifecycle: load on join into session.Profile, release on leave, autosave, the shutdown drain, lock handoff and the blocked-trade kick.
driver:load(key, options?) -> (Profile?, err?) Lock-taking load with version and backup fallback. options: { UserIds?, HandoffSeconds? }. Never returns defaults for an existing record.
driver:restoreAt(key, unixMs, options?) -> (Profile?, err?) Stages the version current at a Unix time in milliseconds over the locked live record. save() or release() commits it and discard() drops it; autosaves only refresh its lock. See Version history.
driver:saveAll(spreadSeconds?) Saves every active profile, each on its own thread, spaced across spreadSeconds. Returns when every started save has finished. Ordinary saves wait while the request budget is spent; releases do not.
driver:releaseAll(deadline?) Releases every active profile, retrying until each lands or the os.clock() deadline (default 25 s from now) passes, and warns with the keys that did not save. A restoreAt staging nobody saved or released is discarded, not committed.
driver:backupNow(profile), driver:listBackups(key), driver:peekBackup(key, slot?) Backup tooling. See Backups.
Member Description
profile.Key The storage key.
profile.Data The persistent table. Read and write it synchronously; never keep it across a yield.
profile.Meta Driver bookkeeping (see Mental model). Treat it as read-only.
profile.Active false once released or once the lock was lost. An inactive profile never saves again.
profile.RestoredFromBackup true only on the load that restored a version or backup, and on a profile returned by restoreAt.
profile.LastSaveAt, profile.LastSaveError, profile.ConsecutiveSaveFailures Save diagnostics: time of the last successful save (driver Clock), the last error, and failures since the last success.
profile.TransactionOwner, profile.TransactionBlocked Set while a trade holds the profile, and after a trade that needs recovery. See Transactions.
profile:save() -> (ok, err?) Checked atomic write that refreshes the lock.
profile:release() -> (ok, err?) Final save plus lock removal. Keeps the profile active and retries when the backend is down.
profile:discard() -> (ok, err?) Drops a restoreAt staging: the lock is released and the stored record stays as it was. Returns false, "nothing staged: only a profile from restoreAt can be discarded" on any other profile, and true on an inactive one. When the release write fails, it returns false and the profile stays discarding: a later discard(), release() or autosave finishes it, and save() refuses with the staged restore was discarded; discard() or release() finishes it. Added in 0.9.1.
profile:snapshot(label?), profile:snapshots(), profile:rollback(id) In-memory undo points. See Profile snapshots.
Member Description
session.Profile The loaded profile, set by attach. nil until the load finishes.
session.ProfilePending true while attach is loading this player’s profile.
Topic Payload Direction
Kernel.ProfileLoaded session, profile Published when the attached lifecycle loads a profile. A milestone: Milestones.on replays it.
Kernel.ProfileRestored session, profile Published after Kernel.ProfileLoaded when the load restored a version or a backup.
Data.Throttled key, errorText Published once per throttling episode, until a request through the driver succeeds.
Data.SaveRefused key, reason Published when a save refuses to write. The lock stays fresh.
Data.LockLost key Published when a save, or a staged restore’s lock refresh, finds another server’s lock. The player is kicked.
Commerce.TransactionEnded profileA, profileB, ok Consumed: a participant of this driver left TransactionBlocked is kicked so the next load recovers the trade.
Kernel.Stopped reason Consumed: the shutdown drain waits for it, up to 5 s.
  • BackoffSeconds = {} means one attempt and no retries. It is useful in tests and dangerous in production.
  • Studio reads and writes the published game’s DataStores. A driver on "DataStore" warns once in Studio; the scaffold’s profile uses "Memory" there.
  • Keep profiles far under 4 MB. Storage above 500 MB + 1 MB x lifetime users is billed since April 2026, so store ids and counts instead of copies of catalog data.

ChloeKernel v0.9.1. Access to the framework is private and granted individually by Chloe. Games built with it credit CryptedChloes in their description.