Data Storage & Persistence
Storing player data, world state, and configuration persistently is crucial for add-ons. This guide covers the two main storage options available in Bedrock scripting and how to build a reusable database layer on top of them.
The Challenge
Minecraft loads/unloads chunks, players disconnect, servers restart. You need data to survive all of this. There are two real storage options available in Bedrock scripting:
- Dynamic Properties - Simple key/value, attached to the world or a player
- Scoreboards - Numeric scores, but can be abused to store serialized strings as participant display names
Each has tradeoffs. A good persistence layer abstracts both behind a single API so the rest of your codebase doesn't care which one is used under the hood.
Option 1: Dynamic Properties
The simplest approach. The world object and players expose getDynamicProperty / setDynamicProperty.
import { world } from "@minecraft/server";
// world.setDynamicProperty stores a key/value pair directly on the world object.
// The key is a namespaced string (e.g. "my_addon:warps") to avoid collisions with
// other add-ons. The value must be a string, so we use JSON.stringify() to convert
// our JavaScript array/object into a storable string format.
world.setDynamicProperty("my_addon:warps", JSON.stringify([
{ name: "spawn", x: 0, y: 64, z: 0 }
]));
// To retrieve the value, we call getDynamicProperty with the same key.
// This returns the raw string we stored, or undefined if it doesn't exist yet.
// We use JSON.parse() to convert that string back into a usable JavaScript array.
// The "?? []" (nullish coalescing) means: if raw is undefined/null, default to an empty array
// instead of passing undefined into JSON.parse(), which would throw an error.
const raw = world.getDynamicProperty("my_addon:warps");
const warps = raw ? JSON.parse(raw) : [];
// Dynamic properties also work per-player. Here we store a boolean on the player object
// directly, so this data belongs to that specific player rather than the whole world.
// Again we must JSON.stringify() it — even booleans must be stored as strings.
player.setDynamicProperty("my_addon:tpa_enabled", JSON.stringify(true));
// Retrieving a player property works the same way as world properties.
// The "?? \"true\"" default means: if the property doesn't exist yet (new player),
// fall back to the string "true" before parsing — so tpa defaults to enabled.
const tpaEnabled = JSON.parse(player.getDynamicProperty("my_addon:tpa_enabled") ?? "true");
Limitations:
- No querying - you must know the exact key
- Gets messy fast without a wrapper
Option 2: Scoreboard String Storage
Scoreboards normally store numbers, but you can store arbitrary strings by encoding data into a fake player's displayName. The score itself is always set to 0 and ignored - the display name is the actual payload.
import { world, ScoreboardIdentityType } from "@minecraft/server";
// We need a scoreboard objective to act as our "table". getObjective() tries to find
// an existing one by name. If it doesn't exist yet (first time the add-on runs),
// the ?? operator falls through to addObjective(), which creates it.
// The second argument to addObjective() is the display name — we reuse the ID for simplicity.
const objective = world.scoreboard.getObjective("my_store")
?? world.scoreboard.addObjective("my_store", "my_store");
// This separator string is used to split the key from the value inside a single display name.
// It's deliberately unusual (newline + backtick + word + backtick + newline) so it's
// extremely unlikely to appear naturally in any key or value you'd store.
const SPLIT = "\n_`Split`_\n";
// writeEntry() stores a key/value pair as a "fake player" in the scoreboard.
// Fake players are scoreboard entries with arbitrary display names — Minecraft doesn't
// require them to be real players. We abuse this by encoding our data into the display name.
function writeEntry(key, value) {
// Before writing, scan all existing participants to find and remove any entry
// that already uses this key. Without this step, you'd end up with duplicate keys.
// We check p.type === FakePlayer to skip real players or entities on the scoreboard.
// startsWith(key + SPLIT) identifies the matching entry by its key prefix.
for (const p of objective.getParticipants()) {
if (p.type === ScoreboardIdentityType.FakePlayer &&
p.displayName.startsWith(key + SPLIT)) {
objective.removeParticipant(p);
break; // Only one entry per key, so we can stop searching after finding it
}
}
// Now write the new entry. The "fake player name" is the full encoded string:
// "myKey\n_`Split`_\n{...json...}"
// The score value is always 0 — we don't use it, it's just required by the API.
objective.setScore(`${key}${SPLIT}${JSON.stringify(value)}`, 0);
}
// readEntry() retrieves a value by scanning all fake player entries for a matching key.
// There's no direct lookup — we have to iterate every participant and check manually.
function readEntry(key) {
for (const p of objective.getParticipants()) {
// Skip any entry that isn't a fake player (could be a real player or entity)
if (p.type !== ScoreboardIdentityType.FakePlayer) continue;
// Find where the SPLIT separator appears in the display name.
// If it's not there, this entry wasn't written by our system — skip it.
const splitIndex = p.displayName.indexOf(SPLIT);
if (splitIndex === -1) continue;
// Everything before the SPLIT is the key, everything after is the JSON value.
// substring(0, splitIndex) extracts the key portion.
const storedKey = p.displayName.substring(0, splitIndex);
if (storedKey === key) {
// Key matched! Now extract the value portion: everything after the separator.
// splitIndex + SPLIT.length skips past the separator itself to get to the JSON.
// JSON.parse() converts the stored string back into the original JavaScript value.
return JSON.parse(p.displayName.substring(splitIndex + SPLIT.length));
}
}
// No matching entry found — return null to signal "key doesn't exist"
return null;
}
// deleteEntry() removes a key/value entry from the scoreboard entirely.
// Removing a participant from the objective deletes that fake player entry.
function deleteEntry(key) {
for (const p of objective.getParticipants()) {
if (p.type === ScoreboardIdentityType.FakePlayer &&
p.displayName.startsWith(key + SPLIT)) {
objective.removeParticipant(p);
return; // Done — only one entry per key
}
}
}
Limitations:
- Each entry (key + serialized value) can't exceed ~30,000 characters
- Reads require scanning all participants - keep datasets small or cache aggressively
- Scoreboard objectives must exist before use
Building a Reusable Database Class
Rather than calling these low-level functions everywhere, wrap them in a class that can be instantiated per feature and shared across files.
// core/Database.js
import { world, ScoreboardIdentityType, system } from "@minecraft/server";
const SPLIT = "\n_`Split`_\n";
// Hard limit on how long a single encoded entry (key + SPLIT + JSON value) can be.
// Scoreboard display names cap out around 32,767 chars; 30,000 gives a safe buffer.
const MAX_LENGTH = 30000;
export class Database {
// Private fields (# prefix) — these can't be accessed or modified from outside the class.
#objective = null; // The scoreboard objective backing this database; null until first use
#name; // The full objective name (e.g. "db:warps")
#cache = new Map(); // In-memory cache: key -> { value, participant }
#loaded = false; // Whether we've done the initial scan of scoreboard participants yet
constructor(name) {
// Prefix with "db:" to namespace our objectives and avoid name collisions
// with other scoreboards in the world (e.g. kill counters, etc.)
this.#name = `db:${name}`;
}
// Makes sure the scoreboard objective exists before we try to use it.
// Returns true if ready, false if the world isn't available yet (e.g. during startup).
// We lazy-initialize here rather than in the constructor because the world/scoreboard
// API may not be ready at the time the Database object is first created.
#ensureObjective() {
if (this.#objective) return true; // Already initialized, nothing to do
try {
this.#objective = world.scoreboard.getObjective(this.#name)
?? world.scoreboard.addObjective(this.#name, this.#name);
return true;
} catch {
// The scoreboard API throws if the world isn't fully loaded yet.
// Returning false lets the caller decide whether to retry later.
return false;
}
}
// Reads all existing participants from the scoreboard into the in-memory cache.
// This is only done once (guarded by #loaded) to avoid redundant scanning.
// All subsequent reads come from the cache, which is much faster than re-scanning.
#loadAll() {
if (this.#loaded) return; // Already loaded, skip
if (!this.#ensureObjective()) return; // World not ready yet, can't load
for (const p of this.#objective.getParticipants()) {
if (p.type !== ScoreboardIdentityType.FakePlayer) continue;
const splitIndex = p.displayName.indexOf(SPLIT);
if (splitIndex === -1) continue; // Not one of our entries, skip
const key = p.displayName.substring(0, splitIndex);
try {
// Store both the parsed value AND the participant reference in the cache.
// We keep the participant reference so that set() and delete() can call
// removeParticipant() directly without having to scan the scoreboard again.
this.#cache.set(key, {
value: JSON.parse(p.displayName.substring(splitIndex + SPLIT.length)),
participant: p
});
} catch {
// If JSON.parse fails (corrupted entry), warn and skip rather than crashing
console.warn(`[Database:${this.#name}] Failed to parse key "${key}"`);
}
}
this.#loaded = true;
}
// Stores a value under a key. Overwrites any existing value for that key.
// Returns true on success, false if the world isn't ready or the value is too large.
set(key, value) {
this.#loadAll(); // Ensure cache is populated before writing
if (!this.#ensureObjective()) return false;
// Build the full encoded string first so we can check its length before writing.
// If it's too long, reject it early rather than letting Minecraft truncate/error silently.
const encoded = `${key}${SPLIT}${JSON.stringify(value)}`;
if (encoded.length > MAX_LENGTH) {
console.error(`[Database] Value too large for key "${key}" (${encoded.length} chars)`);
return false;
}
// If an entry already exists for this key, remove the old scoreboard participant first.
// We can't update a fake player's display name in place — we have to delete and re-add.
const existing = this.#cache.get(key);
if (existing) this.#objective.removeParticipant(existing.participant);
// Write the new fake player entry with the encoded string as its "name".
// Score is always 0 — the number is irrelevant, the display name is the payload.
this.#objective.setScore(encoded, 0);
// Find the newly created participant so we can store its reference in the cache.
// We have to scan because setScore() doesn't return the participant object.
for (const p of this.#objective.getParticipants()) {
if (p.type === ScoreboardIdentityType.FakePlayer && p.displayName === encoded) {
this.#cache.set(key, { value, participant: p });
break;
}
}
return true;
}
// Retrieves a value by key. Returns defaultValue if the key doesn't exist.
// Because #loadAll() populates the cache up front, this is just a Map lookup —
// no scoreboard scanning needed after the first call.
get(key, defaultValue = null) {
this.#loadAll();
return this.#cache.has(key) ? this.#cache.get(key).value : defaultValue;
}
// Returns true if the key exists in the database, false otherwise.
// Useful for checking existence without caring about the actual value.
has(key) {
this.#loadAll();
return this.#cache.has(key);
}
// Removes a key/value pair from both the scoreboard and the cache.
// Returns true if the key existed and was deleted, false if it wasn't found.
delete(key) {
this.#loadAll();
const existing = this.#cache.get(key);
if (!existing) return false; // Key doesn't exist, nothing to delete
// Remove from the scoreboard (permanent storage) and from our in-memory cache
this.#objective.removeParticipant(existing.participant);
this.#cache.delete(key);
return true;
}
}
Sharing a Database Across Files
The simplest pattern is a singleton module - create the instance once and import it wherever needed.
// core/databases.js
// Each Database instance gets its own scoreboard objective (e.g. "db:warps", "db:shops").
// By creating them all here and exporting them, every file that imports WarpsDB
// gets the exact same object in memory — JavaScript modules are cached after first load,
// so there's no risk of two files creating duplicate instances with out-of-sync caches.
import { Database } from "./Database.js";
export const WarpsDB = new Database("warps");
export const ShopsDB = new Database("shops");
export const ModerationDB = new Database("moderation");
// features/warps.js
import { WarpsDB } from "../core/databases.js";
export function addWarp(name, x, y, z) {
// Retrieve the current list of warps (defaulting to [] if none saved yet),
// push the new warp onto it, then write the whole updated array back.
// This is the standard read-modify-write pattern for array values in this system.
const warps = WarpsDB.get("list", []);
warps.push({ name, x, y, z });
WarpsDB.set("list", warps);
}
export function getWarp(name) {
// Load the full warp list and search for a warp matching the given name.
// Array.find() returns the first match, or undefined if none found.
// The "?? null" converts undefined to null for a cleaner "not found" signal.
const warps = WarpsDB.get("list", []);
return warps.find(w => w.name === name) ?? null;
}
// features/commands.js
import { getWarp } from "./warps.js";
export function handleWarpCommand(player, args) {
// args[0] is the warp name the player typed. getWarp() searches the stored list for it.
// If null is returned, the warp doesn't exist — send an error and stop.
const warp = getWarp(args[0]);
if (!warp) return player.sendMessage("§cWarp not found.");
// Warp found — teleport the player to the stored coordinates.
// Because both warps.js and commands.js import from the same databases.js module,
// they share the same WarpsDB instance and its cache — no duplication or sync issues.
player.teleport({ x: warp.x, y: warp.y, z: warp.z });
}
Because JavaScript modules are singletons by default, WarpsDB in warps.js and commands.js is the exact same object in memory - no coordination needed.
Per-Player Storage
For player-specific data, key by player.id:
// core/PlayerData.js
import { Database } from "./Database.js";
// A single database instance handles all player data.
// Player data is kept separate from other databases so it doesn't pollute a shared objective
// and so it can be inspected or cleared independently if needed.
const db = new Database("player_data");
// player.id is a stable, unique identifier for each player — it doesn't change if they
// rename their account. We combine it with the key to create a unique per-player key,
// e.g. "abc123:tpa_enabled". This lets us store multiple keys per player in one database.
export function setPlayerData(player, key, value) {
return db.set(`${player.id}:${key}`, value);
}
// Retrieves a player's stored value. If the player has never had this key set,
// defaultValue is returned so callers don't have to handle null themselves.
export function getPlayerData(player, key, defaultValue = null) {
return db.get(`${player.id}:${key}`, defaultValue);
}
export function deletePlayerData(player, key) {
return db.delete(`${player.id}:${key}`);
}
// features/tpa.js
import { setPlayerData, getPlayerData } from "../core/PlayerData.js";
export function setTpaEnabled(player, enabled) {
// Stores a boolean under this player's "tpa_enabled" key.
// Internally this becomes something like "abc123:tpa_enabled" in the scoreboard.
setPlayerData(player, "tpa_enabled", enabled);
}
export function isTpaEnabled(player) {
// Retrieves the player's preference. The third argument (true) is the default value —
// if this player has never set a preference, we treat TPA as enabled by default.
return getPlayerData(player, "tpa_enabled", true);
}
Choosing a Backend
| Dynamic Properties | Scoreboard Storage | |
|---|---|---|
| Value types | Strings, numbers, booleans | Any JSON-serializable value |
| Per-player | Yes (on player object) | Yes (key by player.id) |
| Global | Yes (on world object) | Yes |
| Querying | Key lookup only | Scan all participants |
| Complexity | Low | Medium |
Use dynamic properties for simple flags and small values. Use scoreboard storage when you need nested objects, arrays, or a unified API across many features.