Data stores
Game servers come and go: each one starts when players need it and stops after they leave.
DataStoreService keeps data that outlives them: coins, levels, inventories, best times, the
number of times your game has been played.
- It’s server-only. Use it from Scripts. On players’ devices every method throws; a device that needs saved data asks the server with a RemoteFunction.
- It belongs to your game. Every server of your game shares the same data, and it stays when you publish new versions. Other games can’t read it.
- Every method is
async. In a published game each call goes over the network, so any call can fail.
Where your data goes
Section titled “Where your data goes”| When the game runs in | Data stores |
|---|---|
| Play in Studio | Live in your browser’s memory, start empty, and are gone when you stop. |
| Test on a real game server | Start empty on each test server, and are thrown away when it stops. |
| Your published game | Saved by Rally. |
So you can test saving and loading freely: nothing you do in Studio touches your players’ real data. To see loading with existing data in a test, save something first in the same test session.
Get a data store
Section titled “Get a data store”import { DataStoreService } from '@rally/sdk';
const progress = DataStoreService.getDataStore('PlayerData');const bestTimes = DataStoreService.getOrderedDataStore('BestTimes');A store’s name is 1 to 50 characters. You can have as many stores as you like; each holds its own keys. An ordered data store is a data store that can also list its entries sorted by value, for leaderboards. A data store and an ordered data store with the same name are the same data.
Read and write
Section titled “Read and write”| Method | What it does |
|---|---|
get<T>(key) |
The saved value, or undefined if there’s none. |
set(key, value) |
Replaces the value. |
update<T>(key, fn) |
Reads the value, passes it to fn, and saves what fn returns. Safe when several servers change the same key; see Change data safely. |
increment(key, delta?) |
Adds delta (default 1) to a number and returns the new total. A missing key counts as 0. |
remove(key) |
Deletes the value. |
getSorted({ ascending?, limit? }) |
Ordered data stores only: the entries with number values, as { key, value }, highest first unless ascending is true. limit is 1 to 100 (default 10). |
A key is 1 to 50 characters. For anything per player, use player.userId: it’s the player’s Rally
account, and it never changes, even when they change their name.
Guests (people playing without an account, player.guest) have a throwaway userId that starts
with guest_ and is 42 characters long. Rally never saves a value whose key holds a guest’s id:
the server keeps it while it runs, so their progress works while they play, and then it’s gone.
Use the id as the key, or put at most 8 characters in front of it, so the key still fits. Data you
key some other way, like by name, is saved for guests too, so check player.guest first.
Values
Section titled “Values”Values are JSON, up to 64 KB once encoded: numbers, strings, booleans, null, arrays and plain
objects. Store vectors as arrays (position.toArray()) and colors as text (color.toHex()), and
turn them back into values when you load them (Vector3.from(array), Color3.fromHex(text)).
Keys and text values can’t contain the null character \u0000.
When you save an object, include a version number, like { v: 1, coins: 10 }. If you change what
you save later, the version tells your code which shape it’s reading.
To describe the shape in TypeScript, use a type, not an interface. The data store methods only
accept JSON-shaped values, and TypeScript can’t tell that an interface is one, so it reports an
error.
Save player data
Section titled “Save player data”The usual pattern: load when a player joins, save when they leave, and save every so often in
between. This script gives every player Coins and Level on the leaderboard, loads them from
the last visit, and saves them again.
import { createLeaderstats, DataStoreService, type NumberValue, type Player, Players, task, warn,} from '@rally/sdk';
/** What we save for each player. Change `v` if you change the shape. */type SaveData = { v: 1; coins: number; level: number;};
const store = DataStoreService.getDataStore('PlayerData');
interface Session { coins: NumberValue; level: NumberValue; /** Only save after a successful load, so a failed load never overwrites real data. */ loaded: boolean;}const sessions = new Map<Player, Session>();
async function load(player: Player): Promise<SaveData | undefined> { for (let attempt = 1; ; attempt++) { try { return await store.get<SaveData>(player.userId); } catch (e) { if (attempt === 3) throw e; await task.wait(attempt * 2); // wait a little longer before each retry } }}
async function save(player: Player): Promise<void> { const session = sessions.get(player); if (!session?.loaded) return; const data: SaveData = { v: 1, coins: session.coins.value, level: session.level.value }; try { await store.set(player.userId, data); } catch (e) { warn(`Couldn't save ${player.name}:`, e); }}
Players.playerAdded.connect((player) => { const stats = createLeaderstats(player, { Coins: 0, Level: 1 }); const session: Session = { coins: stats.Coins, level: stats.Level, loaded: false }; sessions.set(player, session); task.spawn(async () => { try { const saved = await load(player); if (saved) { session.coins.value = saved.coins; session.level.value = saved.level; } session.loaded = true; } catch (e) { warn(`Couldn't load ${player.name}, so this visit won't be saved:`, e); } });});
Players.playerRemoving.connect((player) => { task.spawn(async () => { await save(player); sessions.delete(player); });});
// Save everyone once a minute, so a crash never loses more than a minute.task.spawn(async () => { for (;;) { await task.wait(60); for (const player of Players.getPlayers()) await save(player); }});Because it saves whatever is in the player’s leaderstats, any other Script can award coins just by
changing leaderstats.Coins, and they’ll be saved.
Why it’s built this way:
- It waits for a successful load before it ever saves. If loading fails, saving the starting values would wipe the player’s real progress. Retrying a couple of times first handles brief hiccups.
- It saves on a timer as well as on leaving. When a game server stops while players are still
on it, your
playerRemovinghandlers get about two seconds to finish saving. If the server has to stop because a script ran too long or used too much memory, nothing more gets saved. See Sandbox and performance. - It doesn’t save on every change. Saving every coin would send a request for every coin. Save on a timer, and right away only for changes that matter, like a purchase.
Leaderstats aren’t saved by themselves
Section titled “Leaderstats aren’t saved by themselves”A leaderstats folder (made by createLeaderstats) only shows values on the in-game
leaderboard, the player list at the top right, sorted by the first value. Nothing in it is saved
unless your script saves it, as above. Load saved data into it when a player joins, and save from
it.
Change data safely
Section titled “Change data safely”Many servers can run your game at once, and each one can change the same key. If two servers each
get a value, change it and set it, one of the changes is lost. Two methods avoid that.
increment for counting. It adds on the server that stores the data, so no count is lost. This
script counts every visit to your game, across all servers, and shows the total to everyone:
import { DataStoreService, NumberValue, Players, ReplicatedStorage, warn } from '@rally/sdk';
const stats = DataStoreService.getDataStore('GameStats');const visits = new NumberValue();visits.name = 'TotalVisits';visits.parent = ReplicatedStorage; // every player can read it
Players.playerAdded.connect(async () => { try { visits.value = await stats.increment('visits'); } catch (e) { warn('Counting the visit failed:', e); }});increment only works on numbers: on a key that holds something else, it fails.
update for anything else. You give it a function that turns the current value into the new
one, and it saves the result only if nobody changed the value in the meantime. If someone did, it
reads the new value and calls your function again.
- Your function may run more than once, so it should only compute and return the new value: don’t change anything else in it.
- Return
undefinedto leave the value as it is. updateresolves with the value that’s saved now.- If the key keeps changing under it,
updategives up after 5 tries and fails withdata store update of 'key' kept conflicting; try again.
import { DataStoreService, type Player } from '@rally/sdk';
const inventories = DataStoreService.getDataStore('Inventory');
/** Adds an item to a player's saved inventory, once. Resolves with the whole inventory. */export async function grantItem(player: Player, item: string): Promise<string[]> { const saved = await inventories.update<string[]>(player.userId, (items = []) => items.includes(item) ? undefined : [...items, item], ); return saved ?? [];}Data stores don’t keep a history of old values, and there’s no locking: update and increment are
how you keep concurrent changes from overwriting each other.
Leaderboards
Section titled “Leaderboards”An ordered data store ranks its entries by value. getSorted returns up to 100 at a time, as
{ key, value } with the highest value first, or the lowest first with ascending: true. Entries
whose value isn’t a number are left out, and entries with equal values come in key order.
Keys are usually user ids, so keep display names in a second store. This script runs a race: it
times each run from spawn until the character touches a part named Finish, keeps each player’s
best time, and publishes the top ten in StringValues that every player can see:
import { BasePart, DataStoreService, Folder, getChild, type Player, Players, ReplicatedStorage, StringValue, task, warn, workspace,} from '@rally/sdk';
const best = DataStoreService.getOrderedDataStore('BestTimes');const names = DataStoreService.getDataStore('Names');const finish = getChild(workspace, 'Finish', BasePart);const ROWS = 10;
// The board: ten StringValues in ReplicatedStorage, ready for a LocalScript to show.const board = new Folder();board.name = 'Leaderboard';const rows: StringValue[] = [];for (let i = 1; i <= ROWS; i++) { const row = new StringValue(); row.name = `Row${i}`; row.parent = board; rows.push(row);}board.parent = ReplicatedStorage;
async function refreshBoard(): Promise<void> { const top = await best.getSorted({ ascending: true, limit: ROWS }); // fastest first for (let i = 0; i < ROWS; i++) { const entry = top[i]; const name = entry ? ((await names.get<string>(entry.key)) ?? 'Someone') : ''; rows[i]!.value = entry ? `${i + 1}. ${name}: ${entry.value.toFixed(2)}s` : ''; }}
const startedAt = new Map<Player, number>();Players.playerAdded.connect((player) => { player.characterAdded.connect(() => startedAt.set(player, task.time())); task.spawn(async () => { try { await names.set(player.userId, player.displayName); } catch (e) { warn(`Couldn't save ${player.name}'s name:`, e); } });});Players.playerRemoving.connect((player) => startedAt.delete(player));
finish.touched.connect((other) => { const player = Players.getPlayerFromCharacter(other); if (!player) return; const start = startedAt.get(player); if (start === undefined) return; startedAt.delete(player); // one finish per run const seconds = Math.round((task.time() - start) * 100) / 100; task.spawn(async () => { try { // Keep the fastest time, even if another server saved one in the meantime. await best.update<number>(player.userId, (old) => old === undefined || seconds < old ? seconds : undefined, ); await refreshBoard(); } catch (e) { warn('Saving a time failed:', e); } });});
// Refresh now and then, to pick up times from other servers.task.spawn(async () => { for (;;) { try { await refreshBoard(); } catch (e) { warn('Reading the leaderboard failed:', e); } await task.wait(60); }});Reading the board once a minute (and after a new time) is plenty. Don’t read it every frame. To
draw the board, a LocalScript reads ReplicatedStorage.Leaderboard and listens for changes; see
User interface.
When requests fail
Section titled “When requests fail”Data store calls can fail: the network can hiccup, a value can be too big, a key can be too long. A failed call rejects its promise, so:
- wrap calls in
try/catchinsidetask.spawn(async () => { … })or anasynchandler; - retry loads a few times before giving up, as the player data example does;
- never save default values over data you failed to load.
Each game server runs up to 16 data store requests at a time and queues up to 512 more. Past that,
new requests fail right away with too many data store requests at once. Save in batches, not in a
burst for every object in your game.
Limits
Section titled “Limits”| What | Limit |
|---|---|
| Store names | 1 to 50 characters. |
| Keys | 1 to 50 characters. |
| Values | JSON, up to 64 KB once encoded. |
The null character \u0000 |
Not allowed in keys or text values. |
getSorted |
Up to 100 entries per call (10 by default). Number values only. |
update |
Gives up after 5 conflicting tries. |
| Requests per game server | 16 at a time, up to 512 more waiting. |
All of Rally’s limits are listed in Limits.