Minecraft Scripting API

Please note: Some examples on this page may be outdated or may not work as expected.

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:

  1. Dynamic Properties - Simple key/value, attached to the world or a player
  2. 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.

Navigation