Skip to content

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

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.

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

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.

ServerScriptService/PlayerData.server.ts
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 playerRemoving handlers 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.

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.

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:

ServerScriptService/VisitCounter.server.ts
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 undefined to leave the value as it is.
  • update resolves with the value that’s saved now.
  • If the key keeps changing under it, update gives up after 5 tries and fails with data store update of 'key' kept conflicting; try again.
ServerStorage/Inventory.ts
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.

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:

ServerScriptService/BestTimes.server.ts
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.

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/catch inside task.spawn(async () => { … }) or an async handler;
  • 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.

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.