Skip to content

Editor Extensions

Editor Extensions let you write custom JavaScript tools that run inside the Balancy Editor. Use them to automate workflows, manipulate CMS data programmatically, and build team-specific utilities — all from within the browser.

Extensions run in a sandboxed Web Worker and communicate with the editor via RPC. All API methods are asynchronous — always use await.


Quick Start

Create a new JavaScript DataObject in the Balancy Editor with the following structure:

class MyExtension extends Balancy.Editor.BaseExtension {
    async onInit() {
        Balancy.Editor.Log.info("MyExtension loaded!");
    }

    // @menuItem { "path": "Tools/My Tool/Say Hello", "priority": 10 }
    async sayHello() {
        const items = await Balancy.CMS.getModels("SmartObjects.Item");
        Balancy.Editor.Log.info("Found " + items.length + " items");
    }
}

The extension will:

  1. Run onInit() when the editor loads.
  2. Register a Say Hello action under Tools > My Tool in the Extensions menu (puzzle icon in the header bar).
  3. When clicked, query all SmartObjects.Item documents and log the count.

Extension Lifecycle

BaseExtension

All extensions must extend Balancy.Editor.BaseExtension. The class provides default no-op implementations for all lifecycle methods.

class MyExtension extends Balancy.Editor.BaseExtension {
    // Override any lifecycle methods you need
}

The class name (e.g. MyExtension) is used as the extension's unique identifier. It appears in log entries and menu item IDs.

Lifecycle Methods

Method When Called Return Value
async onInit() Once, when the extension bundle is loaded and bootstrapped void
async onDispose() When extensions are reloaded or the editor is closed void
async preSave(ctx) Before a save operation (stub — not yet implemented) boolean
async postSave(ctx) After a save operation (stub — not yet implemented) void

Example:

class SetupExtension extends Balancy.Editor.BaseExtension {
    async onInit() {
        Balancy.Editor.Log.info("Extension initialized");
        // Pre-load data, set up state, etc.
    }

    async onDispose() {
        Balancy.Editor.Log.info("Extension disposed");
        // Clean up resources
    }
}

Annotate methods with // @menuItem { ... } to register them as clickable actions in the Extensions dropdown menu (puzzle icon in the header bar).

The annotation must be a single-line comment on the line immediately before the method definition.

Syntax:

// @menuItem { "path": "Tools/Category/Action Name", "priority": 10 }
async myMethod() {
    // ...
}

The JSON payload must be valid JSON with the following fields:

Field Type Required Default Description
path string Yes Slash-separated menu path. The last segment is the display label.
priority number No 0 Sorting order. Higher values appear first in the menu.

Path Hierarchy

The path field defines where the action appears in the menu hierarchy:

  • "Tools/Action" — appears directly in the dropdown as "Action"
  • "Tools/Category/Action" — appears under a "Category" submenu
  • "Tools/Category/SubCategory/Action" — nested submenu under "Category > SubCategory"

The leading Tools/ segment is stripped automatically (the puzzle icon already represents "Tools"). So "Tools/Test/Log Info" becomes Test > Log Info in the menu.

If you omit the Tools/ prefix (e.g. "MyCategory/Action"), it still works — MyCategory becomes a top-level submenu.

Priority

Items with higher priority values appear at the top of their group. Items with equal priority are shown in the order they were registered.

// @menuItem { "path": "Tools/Important Action", "priority": 100 }
async importantAction() { /* shown first */ }

// @menuItem { "path": "Tools/Less Important", "priority": 10 }
async lessImportant() { /* shown second */ }

// @menuItem { "path": "Tools/Default Priority" }
async defaultPriority() { /* shown last (priority 0) */ }

Logging API

Balancy.Editor.Log

Log messages appear in the Extensions Log panel (accessible from the puzzle icon dropdown).

Method Description
Balancy.Editor.Log.info(message, data?) Informational message (blue badge)
Balancy.Editor.Log.warn(message, data?) Warning message (yellow badge)
Balancy.Editor.Log.error(message, data?) Error message (red badge)

Parameters:

Parameter Type Required Description
message string Yes The log message text. Non-strings are coerced via String().
data any No Additional data displayed as expandable JSON in the log panel. Must be serializable (no Promises, Functions, DOM elements, etc.).

Example:

Balancy.Editor.Log.info("Processing started");
Balancy.Editor.Log.warn("Item count is low", { count: 2, threshold: 10 });
Balancy.Editor.Log.error("Failed to update", { unnyId: "40071", reason: "not found" });

Always await async results before logging

The data parameter is sent via postMessage and must be structured-clone-compatible. Do not pass Promises, Functions, or unresolved async results.

// WRONG — logs a Promise object, causes postMessage error
const result = Balancy.CMS.getModelByUnnyId("40071");
Balancy.Editor.Log.info("result", result);

// CORRECT
const result = await Balancy.CMS.getModelByUnnyId("40071");
Balancy.Editor.Log.info("result", result);

CMS API

The Balancy.CMS namespace provides methods to read and write CMS documents. All methods are asynchronous and return Promises — always use await.

Expanded Model Format

The CMS API uses an "expanded model" JSON format designed to be human-friendly:

  • $unnyId (string) — The document's user-visible ID (e.g. "40071"). Read-only in updates.
  • $type (string) — The template name (e.g. "SmartObjects.Item"). Required for creates, optional for updates (used for validation).
  • Field names match template parameter names exactly (e.g. Name, Level, Condition).
  • Primitive fields — stored as their native JS types: number, string, boolean.
  • Component fields — expanded inline as nested objects with their own $unnyId and $type.
  • Document references — stored as GUID strings (not expanded).
  • Lists of components — arrays of expanded component objects.
  • Lists of primitives — arrays of native values.

Example expanded model:

{
    "$unnyId": "40071",
    "$type": "SmartObjects.GameEvent",
    "Name": "Daily Login",
    "Priority": 5,
    "IsActive": true,
    "Condition": {
        "$unnyId": "40072",
        "$type": "Conditions.LevelCondition",
        "MinLevel": 10,
        "MaxLevel": 50
    },
    "Rewards": [
        {
            "$unnyId": "40073",
            "$type": "SmartObjects.Reward",
            "Amount": 100,
            "ItemId": "abc-def-123"
        }
    ]
}

Reading Data

getModelByUnnyId

Retrieve a single document by its ID, fully expanded.

const model = await Balancy.CMS.getModelByUnnyId(unnyId);
Parameter Type Description
unnyId string The document's user-visible ID (e.g. "40071")

Returns: object | null — The expanded model, or null if no entity with that ID exists.

Example:

const item = await Balancy.CMS.getModelByUnnyId("40071");
if (item == null) {
    Balancy.Editor.Log.warn("Item not found");
    return;
}
Balancy.Editor.Log.info("Item name: " + item.Name);

getModels

Retrieve all documents of a given template type, fully expanded.

const models = await Balancy.CMS.getModels(templateName, includeChildren?);
Parameter Type Default Description
templateName string Full template name (e.g. "SmartObjects.Item")
includeChildren boolean true If true, also returns entities of templates that inherit from the named template

Returns: object[] — Array of expanded models. Empty array if the template doesn't exist or has no entities.

Example:

// Get all items (including child template entities)
const allItems = await Balancy.CMS.getModels("SmartObjects.Item");

// Get only exact-type items (no child templates)
const exactItems = await Balancy.CMS.getModels("SmartObjects.Item", false);

Balancy.Editor.Log.info("Found " + allItems.length + " items");

getTemplateList

Retrieve template descriptors (schema metadata), optionally filtered by name. Useful for discovering available templates, their fields, and inheritance hierarchies.

const templates = await Balancy.CMS.getTemplateList(templateName?);
Parameter Type Default Description
templateName string Optional. Filter to this template and all templates that inherit from it. Omit for all templates.

Returns: Array<TemplateDescriptor> — Array of template descriptors. Empty array if the template name doesn't exist.

Each template descriptor contains:

{
    "name": "SmartObjects.Item",
    "displayName": "Item",
    "description": "A game item",
    "templateType": "Document",
    "parentTemplate": "SmartObjects.BaseItem",
    "fields": [
        {
            "name": "Name",
            "type": "String",
            "required": true,
            "description": null
        },
        {
            "name": "Rewards",
            "type": "List",
            "subType": "Component",
            "refTemplate": "SmartObjects.Reward",
            "required": false,
            "description": "Rewards given on use"
        }
    ]
}
Template descriptor fields
Field Type Description
name string Full template name (e.g. "SmartObjects.Item")
displayName string Human-readable display name
description string \| null Template description
templateType string One of: "Document", "Component", "Singleton", etc.
parentTemplate string \| null Parent template name (if using inheritance), or null
fields FieldDescriptor[] Array of field descriptors

Field descriptor fields:

Field Type Description
name string Parameter name
type string Data type (e.g. "Integer", "String", "LocalizedString", "List", "EntityRef")
subType string \| undefined For List types, the element type (e.g. "Component", "Primitive")
refTemplate string \| undefined For EntityRef and List of components, the referenced template name
required boolean Whether the field is required
description string \| null Field description

Example:

// Get all templates
const all = await Balancy.CMS.getTemplateList();
Balancy.Editor.Log.info("Total templates: " + all.length);

// Get a specific template and its children
const items = await Balancy.CMS.getTemplateList("SmartObjects.Item");
for (const tmpl of items) {
    Balancy.Editor.Log.info(tmpl.name + " has " + tmpl.fields.length + " fields");
}

// Discover fields of a template
const [tmpl] = await Balancy.CMS.getTemplateList("SmartObjects.Item");
if (tmpl) {
    const fieldNames = tmpl.fields.map(f => f.name + " (" + f.type + ")");
    Balancy.Editor.Log.info("Fields: " + fieldNames.join(", "));
}

Writing Data

All write operations are reflected in the editor immediately — a subsequent getModelByUnnyId returns the updated values. Changes are undoable via Ctrl+Z.

createModel

Create a new document from expanded JSON.

const created = await Balancy.CMS.createModel(expandedJson);
Parameter Type Description
expandedJson object Must contain $type with the template name. Other fields are optional.

Returns: object | null — The created expanded model with an auto-assigned $unnyId.

Throws:

  • If $type is missing or not a string
  • If the template name in $type doesn't exist
  • If a non-object is passed (with a hint if you accidentally pass a JSON string)

Example:

const newItem = await Balancy.CMS.createModel({
    $type: "SmartObjects.Item",
    Name: "New Sword",
    Price: 100,
    IsActive: true,
});
Balancy.Editor.Log.info("Created item with ID: " + newItem.$unnyId);

updateModel

Update a single document. Only fields present in the JSON are updated (partial update). Fields not included are left unchanged.

const updated = await Balancy.CMS.updateModel(unnyId, expandedJson);
Parameter Type Description
unnyId string The document's user-visible ID (e.g. "40071")
expandedJson object Partial expanded JSON. Should contain $type for validation. Only included fields are updated.

Returns: object | null — The updated expanded model, or null if the entity doesn't exist.

Throws:

  • If $type is provided and doesn't match the entity's actual template
  • If expandedJson is not an object
  • If unnyId is not a string

Example — update primitive fields:

const updated = await Balancy.CMS.updateModel("40071", {
    $type: "SmartObjects.Item",
    Name: "Updated Name",
    Price: 200,
});
Balancy.Editor.Log.info("Updated:", updated);

Example — update a component field:

const updated = await Balancy.CMS.updateModel("40071", {
    $type: "SmartObjects.GameEvent",
    Condition: {
        $type: "Conditions.LevelCondition",
        MinLevel: 20,  // only MinLevel is updated
    },
});

Example — update a list of components:

// Read the current document
const doc = await Balancy.CMS.getModelByUnnyId("40071");

// Modify the list (e.g., change a field, remove an element)
doc.Rewards[0].Amount = 999;
doc.Rewards.splice(2, 1); // remove 3rd element

// Write back the full list
const updated = await Balancy.CMS.updateModel(doc.$unnyId, {
    $type: doc.$type,
    Rewards: doc.Rewards,
});

How component lists work internally

  • Existing components are matched by $type — if a component with the same $type already exists, it is updated in place.
  • New components (with a $type not found in the existing list) are created automatically.
  • Components removed from the array are dropped from the list (orphaned entities are cleaned up on deploy).
  • The order of the array is preserved.

updateModels

Batch-update multiple documents in a single undo/redo group. One Ctrl+Z undoes all changes.

const results = await Balancy.CMS.updateModels(updates);
Parameter Type Description
updates Array<{ unnyId: string, expandedJson: object }> Array of update objects

Returns: Array<object | null> — Array of updated expanded models (one per input). null for entities that don't exist.

Example:

const items = await Balancy.CMS.getModels("SmartObjects.Item");
if (items.length < 2) return;

const results = await Balancy.CMS.updateModels([
    {
        unnyId: items[0].$unnyId,
        expandedJson: { $type: items[0].$type, Name: "Batch A" },
    },
    {
        unnyId: items[1].$unnyId,
        expandedJson: { $type: items[1].$type, Name: "Batch B" },
    },
]);
Balancy.Editor.Log.info("Updated " + results.length + " items");

Localization API

The Balancy.Localization namespace provides methods to manage localization groups, keys, and translations. All methods are asynchronous — always use await.

Key Format: "GROUP/KEY"

All methods that identify a localization string use the "GROUP/KEY" format:

  • "UI/welcome_title" — key welcome_title in group UI
  • "DEFAULT/my_key" — key my_key in the DEFAULT group

Always specify the group. Bare keys without a group are not accepted.

In CMS write operations (createModel, updateModel), use the "GROUP/KEY" format directly for any LocalizedString field. The system resolves the key to its internal GUID and auto-creates the group and key if they don't exist.


Reading Localization Data

getGroups

Get all localization groups.

const groups = await Balancy.Localization.getGroups();
// [{ guid: "abc-123", path: "UI" }, { guid: "def-456", path: "DEFAULT" }, ...]

Returns: Array<{ guid: string, path: string }>


getStrings

Get all localization strings, optionally filtered by group.

// All strings
const all = await Balancy.Localization.getStrings();

// Strings in a specific group
const uiStrings = await Balancy.Localization.getStrings("UI");
Parameter Type Required Description
group string No Group path to filter by (e.g. "UI"). Omit for all strings.

Returns: Array<{ guid, key, group, description, translations, export }>

Each string object:

{
    "guid": "abc-123",
    "key": "welcome_title",
    "group": "UI",
    "description": "Title shown on the welcome screen",
    "translations": { "en": "Welcome!", "fr": "Bienvenue!" },
    "export": true
}

getStringByKey

Get a single localization string by "GROUP/KEY".

const str = await Balancy.Localization.getStringByKey("UI/welcome_title");
if (str != null) {
    Balancy.Editor.Log.info("English: " + str.translations.en);
}
Parameter Type Required Description
key string Yes "GROUP/KEY" format (e.g. "UI/welcome_title")

Returns: { guid, key, group, description, translations, export } | null


getLanguages

Get all languages configured in the project.

const langs = await Balancy.Localization.getLanguages();
// [{ guid: "...", code: "en", name: "English", localizedName: "English", enabled: true }, ...]

Returns: Array<{ guid, code, name, localizedName, enabled }>


Managing Groups

createGroup

Create a new localization group.

const group = await Balancy.Localization.createGroup("Game/Messages");
Balancy.Editor.Log.info("Created group: " + group.path);
Parameter Type Required Description
path string Yes Group path (e.g. "UI", "Game/Messages")

Returns: { guid, path }

Throws: If a group with the same path already exists.


deleteGroup

Delete a localization group. The group must have no strings — delete all strings first.

await Balancy.Localization.deleteGroup("Game/Messages");
Parameter Type Required Description
path string Yes Group path to delete

Returns: { success: true }

Throws: If the group has strings (delete them first) or if the group doesn't exist.


Managing Strings

createString

Create a new localization string. Auto-creates the group if it doesn't exist.

const str = await Balancy.Localization.createString({
    key: "welcome_title",
    group: "UI",
    translations: { en: "Welcome!", fr: "Bienvenue!" },
    description: "Welcome screen title",
});
Balancy.Editor.Log.info("Created: " + str.group + "/" + str.key);
Parameter Type Required Description
key string Yes Key name (without group prefix), e.g. "welcome_title"
group string Yes Group path, e.g. "UI"
translations Record<string, string> No Language translations, e.g. { en: "Hello", fr: "Bonjour" }
description string No Description of the string

Returns: { guid, key, group, description, translations, export }

Throws: If the key already exists in the group.


updateString

Update a localization string's translations and/or metadata.

// Update translations
await Balancy.Localization.updateString({
    key: "UI/welcome_title",
    translations: { en: "Hello!", de: "Hallo!" },
});

// Rename and move
await Balancy.Localization.updateString({
    key: "UI/welcome_title",
    newKey: "greeting_title",
    newGroup: "Game",
});
Parameter Type Required Description
key string Yes "GROUP/KEY" to identify the string, e.g. "UI/welcome_title"
translations Record<string, string> No Language translations to update (partial — only listed languages are changed)
description string No New description
newKey string No Rename the key
newGroup string No Move to a different group (path, auto-created if missing)

Returns: { guid, key, group, description, translations, export }

Throws: If the key is not found. If a language code in translations doesn't exist.


deleteString

Delete a localization string by "GROUP/KEY".

await Balancy.Localization.deleteString("UI/welcome_title");
Parameter Type Required Description
key string Yes "GROUP/KEY" format, e.g. "UI/welcome_title"

Returns: { success: true }

Throws: If the key is not found.


Using Localization in CMS Operations

When creating or updating documents via Balancy.CMS, use "GROUP/KEY" format for any LocalizedString field. The system resolves the key to its internal GUID — and auto-creates the group and key if they don't exist.

const item = await Balancy.CMS.createModel({
    $type: "SmartObjects.Item",
    Name: "UI/item_sword_name",
    Description: "UI/item_sword_desc",
});

Strings must contain a / separator. Raw GUIDs are not accepted and will throw an error.

Reading: LocalizedString fields are returned in "GROUP/KEY" format (e.g. "UI/welcome_title"). Use Balancy.Localization.getStringByKey() to retrieve the full translation details including all languages.


Data Types Reference

How each Balancy data type is represented in the expanded model:

Data Type JS Type Notes
Integer number
Float number
Long number
String string
Boolean boolean
Enum number Enum integer value
Duration number Seconds
Date string ISO date string
Color string Hex color string
Localized String string \| null Read: "GROUP/KEY" format. Write: "GROUP/KEY" (auto-resolves/creates).
Entity Ref (Component) object Expanded inline as nested object with $unnyId, $type
Entity Ref (Document) string Entity GUID string
Template string \| null Template name (e.g. "SmartObjects.Item")
TemplateParam string \| null Parameter name
List (Primitive) array Array of primitive values
List (Component) array Array of expanded component objects
List (Document Ref) array Array of entity GUID strings
List (TemplateParam) string Dot-notation path (e.g. "GeneralInfo.Level")
DataObject string GUID
Product string GUID
BaseData string GUID

Error Handling

All CMS API errors are propagated as rejected Promises. If an error occurs inside a @menuItem method, it is automatically caught and logged to the Extensions Log as an error entry.

// @menuItem { "path": "Tools/Safe Action", "priority": 10 }
async safeAction() {
    try {
        const result = await Balancy.CMS.updateModel("99999", {
            $type: "SmartObjects.Item",
            Name: "Test",
        });
        if (result == null) {
            Balancy.Editor.Log.warn("Entity not found");
            return;
        }
        Balancy.Editor.Log.info("Updated!", result);
    } catch (e) {
        Balancy.Editor.Log.error("Update failed: " + e.message);
    }
}

Common errors:

Error Cause
"$type mismatch: expected X, got Y" The $type in your JSON doesn't match the entity's actual template
"Unknown template: X" The template name in $type doesn't exist in the game
"createEntity requires $type in expandedJson" Missing $type field in createModel
"expected unnyId to be a string, got ..." Wrong argument type passed to a method
"expected an object with $type, got string. Hint: ..." Passed a JSON string instead of a JS object

Limitations

  • Execution environment: Scripts run in a Web Worker. No DOM access (document, window), no fetch, no localStorage.
  • No imports: Scripts cannot import external modules. All code must be self-contained.
  • Serialization: All data passed between the extension and the editor goes through postMessage (structured clone). Promises, Functions, DOM elements, and circular references cannot be transferred.
  • Single class per script: While multiple classes can technically exist, each script should define one extension class for clarity.
  • Save hooks: preSave and postSave are defined but not yet wired. They will be implemented in a future release.
  • Hot reload: Automatic reload on script change is not yet implemented. Use "Reload Extensions" from the menu.
  • Localized strings: Read operations return the "GROUP/KEY" format. Write operations accept "GROUP/KEY" strings only (auto-resolved/created; raw GUIDs are not accepted). Use Balancy.Localization for full CRUD on localization data.

Examples

Batch Rename Items

class BatchRenameExtension extends Balancy.Editor.BaseExtension {
    async onInit() {
        Balancy.Editor.Log.info("BatchRenameExtension ready");
    }

    // @menuItem { "path": "Tools/Batch/Add Prefix to All Items", "priority": 50 }
    async addPrefix() {
        const items = await Balancy.CMS.getModels("SmartObjects.Item");
        if (items.length === 0) {
            Balancy.Editor.Log.warn("No items found");
            return;
        }

        const updates = items.map(function(item) {
            return {
                unnyId: item.$unnyId,
                expandedJson: {
                    $type: item.$type,
                    Name: "[SALE] " + item.Name,
                },
            };
        });

        const results = await Balancy.CMS.updateModels(updates);
        Balancy.Editor.Log.info("Renamed " + results.length + " items");
    }
}

Create Item with Components

class ItemFactoryExtension extends Balancy.Editor.BaseExtension {
    async onInit() {
        Balancy.Editor.Log.info("ItemFactoryExtension ready");
    }

    // @menuItem { "path": "Tools/Factory/Create Test Item", "priority": 30 }
    async createTestItem() {
        const created = await Balancy.CMS.createModel({
            $type: "SmartObjects.Item",
            Name: "Auto-Created Item",
            Price: 42,
            IsActive: true,
        });

        if (created == null) {
            Balancy.Editor.Log.error("Failed to create item");
            return;
        }

        Balancy.Editor.Log.info("Created: " + created.$unnyId, created);
    }
}

Inspect and Modify a Document

class InspectorExtension extends Balancy.Editor.BaseExtension {
    async onInit() {
        Balancy.Editor.Log.info("InspectorExtension ready");
    }

    // @menuItem { "path": "Tools/Inspector/Log First Item", "priority": 20 }
    async logFirstItem() {
        const items = await Balancy.CMS.getModels("SmartObjects.Item");
        if (items.length === 0) {
            Balancy.Editor.Log.warn("No items");
            return;
        }
        Balancy.Editor.Log.info("First item:", items[0]);
    }

    // @menuItem { "path": "Tools/Inspector/Double First Item Price", "priority": 19 }
    async doublePrice() {
        const items = await Balancy.CMS.getModels("SmartObjects.Item");
        if (items.length === 0) {
            Balancy.Editor.Log.warn("No items");
            return;
        }

        const item = items[0];
        const currentPrice = item.Price || 0;
        const updated = await Balancy.CMS.updateModel(item.$unnyId, {
            $type: item.$type,
            Price: currentPrice * 2,
        });
        Balancy.Editor.Log.info(
            "Price changed: " + currentPrice + " -> " + updated.Price,
            updated
        );
    }
}

Working with Component Lists

class ComponentListExtension extends Balancy.Editor.BaseExtension {
    async onInit() {
        Balancy.Editor.Log.info("ComponentListExtension ready");
    }

    // @menuItem { "path": "Tools/Components/Modify List", "priority": 15 }
    async modifyComponentList() {
        const docs = await Balancy.CMS.getModels("MyTemplate");
        if (docs.length === 0) return;

        const doc = docs[0];
        Balancy.Editor.Log.info("Before:", doc);

        // Modify a component field in the list
        if (doc.MyComponents && doc.MyComponents.length > 0) {
            doc.MyComponents[0].SomeField = 999;
        }

        // Remove the last component from the list
        if (doc.MyComponents && doc.MyComponents.length > 1) {
            doc.MyComponents.pop();
        }

        // Write back
        const updated = await Balancy.CMS.updateModel(doc.$unnyId, {
            $type: doc.$type,
            MyComponents: doc.MyComponents,
        });
        Balancy.Editor.Log.info("After:", updated);
    }
}