Editor scripts

You can create custom menu items and editor lifecycle hooks using Lua files with special extension: .editor_script. Using this system, you can tweak editor to enhance your development workflow.

Editor script runtime

Editor scripts run inside an editor, in a Lua VM emulated by Java VM. All scripts share the same single environment, which means they can interact with each other. You can require Lua modules, just as with .script files, but Lua version that is running inside the editor is different, so make sure your shared code is compatible. Editor uses Lua version 5.2.x, more specifically luaj runtime, which is currently the only viable solution to run Lua on JVM. Besides that, there are some restrictions:

All editor extensions defined in editor scripts are loaded when you open a project. When you fetch libraries, extensions are reloaded, since there might be new editor scripts in a libraries you depend on. During this reload, no changes in your own editor scripts are picked up, since you might be in the middle of changing them. To reload them as well, you should run Project → Reload Editor Scripts command.

Anatomy of .editor_script

Every editor script should return a module, like that:

local M = {}

function M.get_commands()
  -- TODO - define editor commands
end

function M.get_language_servers()
  -- TODO - define language servers
end

function M.get_prefs_schema()
  -- TODO - define preferences
end

return M

Editor then collects all editor scripts defined in project and libraries, loads them into single Lua VM and calls into them when needed (more on that in commands and lifecycle hooks sections).

Editor API

You can interact with the editor using editor package that defines this API:

You can find the full editor API reference here.

Commands

If editor script module defines function get_commands, it will be called on extension reload, and returned commands will be available for use inside the editor in menu bar or in context menus in Assets and Outline panes. Example:

local M = {}

function M.get_commands()
  return {
    {
      label = "Remove Comments",
      locations = {"Edit", "Assets"},
      query = {
        selection = {type = "resource", cardinality = "one"}
      },
      active = function(opts)
        local path = editor.get(opts.selection, "path")
        return ends_with(path, ".lua") or ends_with(path, ".script")
      end,
      run = function(opts)
        local text = editor.get(opts.selection, "text")
        editor.transact({
          editor.tx.set(opts.selection, "text", strip_comments(text))
        })
      end
    },
    {
      label = "Minify JSON",
      locations = {"Assets"},
      query = {
        selection = {type = "resource", cardinality = "one"}
      },
      active = function(opts)
        return ends_with(editor.get(opts.selection, "path"), ".json")
      end,
      run = function(opts)
        local path = editor.get(opts.selection, "path")
        editor.execute("./scripts/minify-json.sh", path:sub(2))
      end
    }
  }
end

return M

Editor expects get_commands() to return an array of tables, each describing a separate command. Command description consists of:

Use commands to change the in-memory editor state

Inside the run handler, you can query and change the in-memory editor state. Querying is done using editor.get() function, where you can ask the editor about the current state of files and selection (if using query = {selection = ...}). You can get the "text" property of script files, and also some properties shown in the Properties view — hover over property name to see a tooltip with information about how this property is named in editor scripts. Changing the editor state is done using editor.transact(), where you bundle 1 or more modifications in a single undoable step. For example, if you want to be able to reset transform of a game object, you could write a command like that:

{
  label = "Reset transform",
  locations = {"Outline"},
  query = {selection = {type = "outline", cardinality = "one"}},
  active = function(opts)
    local node = opts.selection
    return editor.can_set(node, "position")
       and editor.can_set(node, "rotation")
       and editor.can_set(node, "scale")
  end,
  run = function(opts)
    local node = opts.selection
    editor.transact({
      editor.tx.set(node, "position", {0, 0, 0}),
      editor.tx.set(node, "rotation", {0, 0, 0}),
      editor.tx.set(node, "scale", {1, 1, 1})
    })
  end
}

Editing atlases

In addition to reading and writing properties of an atlas, you can read and modify atlas images and animations. Atlas defines images and animations node list properties, and animations define images node list property: you can use editor.tx.add, editor.tx.remove and editor.tx.clear transaction steps with these properties.

For example, to add an image to an atlas, execute the following code in the command’s run handler:

editor.transact({
    editor.tx.add("/main.atlas", "images", {image="/assets/hero.png"})
})

To find a set of all images in an atlas, execute the following code:

local all_images = {} ---@type table<string, true>
-- first, collect all "bare" images
local image_nodes = editor.get("/main.atlas", "images")
for i = 1, #image_nodes do
    all_images[editor.get(image_nodes[i], "image")] = true
end
-- second, collect all images used in animations
local animation_nodes = editor.get("/main.atlas", "animations")
for i = 1, #animation_nodes do
    local animation_image_nodes = editor.get(animation_nodes[i], "images")
    for j = 1, #animation_image_nodes do
        all_images[editor.get(animation_image_nodes[j], "image")] = true
    end
end
pprint(all_images)
-- {
--     ["/assets/hero.png"] = true,
--     ["/assets/enemy.png"] = true,
-- }}

To replace all animations in an atlas:

editor.transact({
    editor.tx.clear("/main.atlas", "animations"),
    editor.tx.add("/main.atlas", "animations", {
        id = "hero_run",
        images = {
            {image = "/assets/hero_run_1.png"},
            {image = "/assets/hero_run_2.png"},
            {image = "/assets/hero_run_3.png"},
            {image = "/assets/hero_run_4.png"}
        }
    })
})

Editing tilesources

In addition to outline properties, tilesources define the following properties:

For example, here is how you can setup a tilesource:

local tilesource = "/game/world.tilesource"
editor.transact({
    editor.tx.add(tilesource, "animations", {id = "idle", start_tile = 1, end_tile = 1}),
    editor.tx.add(tilesource, "animations", {id = "walk", start_tile = 2, end_tile = 6, fps = 10}),
    editor.tx.add(tilesource, "collision_groups", {id = "player"}),
    editor.tx.add(tilesource, "collision_groups", {id = "obstacle"}),
    editor.tx.set(tilesource, "tile_collision_groups", {
        [1] = "player",
        [7] = "obstacle",
        [8] = "obstacle"
    })
})

Editing tilemaps

Tilemaps define layers property, a node list of tilemap layers. Each layer also defines a tiles property that holds an unbounded 2d grid of tiles on this layer. This is different from the engine: tiles have no bounds and may be added anywhere, including negative coordinates. To edit tiles, the editor script API defines a tilemap.tiles module with the following functions:

For example, here is how you can print the contents of the whole tilemap:

local layers = editor.get("/level.tilemap", "layers")
for i = 1, #layers do
    local layer = layers[i]
    local id = editor.get(layer, "id")
    local tiles = editor.get(layer, "tiles")
    print("layer " .. id .. ": {")
    for x, y, tile in tilemap.tiles.iterator(tiles) do
        print("  [" .. x .. ", " .. y .. "] = " .. tile)
    end
    print("}")
end

Here is an example that shows how to add a layer with tiles to a tilemap:

local tiles = tilemap.tiles.new()
tilemap.tiles.set(tiles, 1, 1, 2)
editor.transact({
    editor.tx.add("/level.tilemap", "layers", {
        id = "new_layer",
        tiles = tiles
    })
})

Editing particlefx

You can edit particlefx using modifiers and emitters properties. For example, adding a circle emitter with acceleration modifier is done like this:

editor.transact({
    editor.tx.add("/fire.particlefx", "emitters", {
        type = "emitter-type-circle",
        modifiers = {
          {type = "modifier-type-acceleration"}
        }
    })
})

Many particlefx properties are curves or curve spreads (i.e. curve + some randomizer value). Curves are represented as a table with a non-empty list of points, where each point is a table with the following properties:

For example, setting a particle lifetime alpha curve for an already existing emitter might look like this:

local emitter = editor.get("/fire.particlefx", "emitters")[1]
editor.transact({
    editor.tx.set(emitter, "particle_key_alpha", { points = {
        {x = 0,   y = 0, tx = 0.1, ty = 1}, -- start at 0, go up quickly
        {x = 0.2, y = 1, tx = 1,   ty = 0}, -- reach 1 at 20% of a lifetime
        {x = 1,   y = 0, tx = 1,   ty = 0}  -- slowly go down to 0
    }})
})

Of course, it’s also possible to use the particle_key_alpha key in a table when creating an emitter. Additionally, you can use a single number instead to represent a “static” curve.

Editing collision objects

In addition to default outline properties, collision objects define shapes node list property. Adding new collision shapes is done like this:

editor.transact({
    editor.tx.add("/hero.collisionobject", "shapes", {
        type = "shape-type-box" -- or "shape-type-sphere", "shape-type-capsule"
    })
})

Shape’s type property is required during creation and cannot be changed after the shape is added. There are 3 shape types:

Editing GUI files

In addition to outline properties, GUI nodes defines the following properties:

It’s possible to edit GUI layers using editor layers property, e.g.:

editor.transact({
    editor.tx.add("/main.gui", "layers", {name = "foreground"}),
    editor.tx.add("/main.gui", "layers", {name = "background"})
})

Additionally, it’s possible to reorder layers:

local fg, bg = table.unpack(editor.get("/main.gui", "layers"))
editor.transact({
    editor.tx.reorder("/main.gui", "layers", {bg, fg})
})

Similarly, fonts, materials, textures, and particlefxs are edited using fonts, materials, textures, and particlefxs properties:

editor.transact({
    editor.tx.add("/main.gui", "fonts", {font = "/main.font"}),
    editor.tx.add("/main.gui", "materials", {name = "shine", material = "/shine.material"}),
    editor.tx.add("/main.gui", "particlefxs", {particlefx = "/confetti.material"}),
    editor.tx.add("/main.gui", "textures", {texture = "/ui.atlas"})
})

These properties don’t support reordering.

Finally, you can edit GUI nodes using nodes list property, e.g.:

editor.transact({
    editor.tx.add("/main.gui", "nodes", {
        type = "gui-node-type-box",
        position = {20, 20, 20}
    }),
    editor.tx.add("/main.gui", "nodes", {
        type = "gui-node-type-template",
        template = "/button.gui"
    }),
})

Built-in node types are:

If you are using spine extension, you can also use gui-node-type-spine node type.

If the GUI file defines layouts, you can get and set the values from layouts using layout:property syntax, e.g.:

local node = editor.get("/main.gui", "nodes")[1]

-- GET:
local position = editor.get(node, "position")
pprint(position) -- {20, 20, 20}
local landscape_position = editor.get(node, "Landscape:position")
pprint(landscape_position) -- {20, 20, 20}

-- SET:
editor.transact({
    editor.tx.set(node, "Landscape:position", {30, 30, 30})
})
pprint(editor.get(node, "Landscape:position")) -- {30, 30, 30}

Layout properties that were set can be reset to their default values using editor.tx.reset:

print(editor.can_reset(node, "Landscape:position")) -- true
editor.transact({
    editor.tx.reset(node, "Landscape:position")
})

Template node trees can be read, but not edited — you can only set node properties of the template node tree:

local template = editor.get("/main.gui", "nodes")[2]
print(editor.can_add(template, "nodes")) -- false
local node_in_template = editor.get(template, "nodes")[1]
editor.transact({
    editor.tx.set(node_in_template, "text", "Button text")
})
print(editor.can_reset(node_in_template, "text")) -- true (overrides a value in the template)

Editing game objects

It’s possible to edit components of a game object file using editor scripts. The components come in 2 flavors: referenced and embedded. Referenced components use type component-reference and act as references to other resources, only allowing overrides of go properties defined in scripts. Embedded components use types like sprite, label, etc., and allow editing of all properties defined in the component type, as well as adding sub-components like shapes of collision objects. For example, you can use the following code to set up a game object:

editor.transact({
    editor.tx.add("/npc.go", "components", {
        type = "sprite",
        id = "view"
    }),
    editor.tx.add("/npc.go", "components", {
        type = "collisionobject",
        id = "collision",
        shapes = {
            {
                type = "shape-type-box",
                dimensions = {32, 32, 32}
            }
        }
    }),
    editor.tx.add("/npc.go", "components", {
        type = "component-reference",
        path = "/npc.script"
        id = "controller",
        __hp = 100 -- set a go property defined in the script
    })
})

Editing collections

It’s possible to edit collections using editor scripts. You can add game objects (embedded or referenced) and collections (referenced). For example:

local coll = "/char.collection"
editor.transact({
    editor.tx.add(coll, "children", {
        -- embbedded game object
        type = "go",
        id = "root",
        children = {
            {
                -- referenced game object
                type = "go-reference",
                path = "/char-view.go"
                id = "view"
            },
            {
                -- referenced collection
                type = "collection-reference",
                path = "/body-attachments.collection"
                id = "attachments"
            }
        },
        -- embedded gos can also have components
        components = {
            {
                type = "collisionobject",
                id = "collision",
                shapes = {
                    {type = "shape-type-box", dimensions = {2.5, 2.5, 2.5}}
                }
            },
            {
                type = "component-reference",
                id = "controller",
                path = "/char.script",
                __hp = 100 -- set a go property defined in the script
            }
        }
    })
})

Like in the editor, referenced collections can only be added to the root of the edited collection, and game objects can only be added to embedded or referenced game objects, but not to referenced collections or game objects within these referenced collections.

Use shell commands

Inside the run handler, you can write to files (using io module) and execute shell commands (using editor.execute() command). When executing shell commands, it’s possible to capture the output of a shell command as a string and then use it in code. For example, if you want to make a command for formatting JSON that shells out to globally installed jq, you can write the following command:

{
  label = "Format JSON",
  locations = {"Assets"},
  query = {selection = {type = "resource", cardinality = "one"}},
  action = function(opts)
    local path = editor.get(opts.selection, "path")
    return path:match(".json$") ~= nil
  end,
  run = function(opts)
    local text = editor.get(opts.selection, "text")
    local new_text = editor.execute("jq", "-n", "--argjson", "data", text, "$data", {
      reload_resources = false, -- don't reload resources since jq does not touch disk
      out = "capture" -- return text output instead of nothing
    })
    editor.transact({ editor.tx.set(opts.selection, "text", new_text) })
  end
}

Since this command invokes shell program in a read-only way (and notifies the editor about it using reload_resources = false), you get the benefit of making this action undoable.

If you want to distribute your editor script as a library, you might want to bundle the binary program for editor platforms within the dependency. See Editor scripts in libraries for more details on how to do it.

Lifecycle hooks

There is a specially treated editor script file: hooks.editor_script, located in a root of your project, in the same directory as game.project. This and only this editor script will receive lifecycle events from the editor. Example of such file:

local M = {}

function M.on_build_started(opts)
  local file = io.open("assets/build.json", "w")
  file:write('{"build_time": "' .. os.date() .. '"}')
  file:close()
end

return M

We decided to limit lifecycle hooks to single editor script file because order in which build hooks happen is more important than how easy it is to add another build step. Commands are independent from each other, so it does not really matter in what order they are shown in the menu, in the end user executes a particular command they selected. If it was possible to specify build hooks in different editor scripts, it would create a problem: in which order do hooks execute? You probably want to create a checksums of content after you compress it… And having a single file that establishes order of build steps by calling each step function explicitly is a way to solve this problem.

Existing lifecycle hooks that /hooks.editor_script may specify:

Please note that lifecycle hooks currently are an editor-only feature, and they are not executed by Bob when bundling from command line.

Language servers

The editor supports a subset Language Server Protocol. While we aim to expand the editor’s support for LSP features in the future, currently it can only show diagnostics (i.e. lints) in the edited files and provide completions.

To define the language server, you need to edit your editor script’s get_language_servers function like so:

function M.get_language_servers()
  local command = 'build/plugins/my-ext/plugins/bin/' .. editor.platform .. '/lua-lsp'
  if editor.platform == 'x86_64-win32' then
    command = command .. '.exe'
  end
  return {
    {
      languages = {'lua'},
      watched_files = {
        { pattern = '**/.luacheckrc' }
      },
      command = {command, '--stdio'}
    }
  }
end

The editor will start the language server using the specified command, using the server process’s standard input and output for communication.

Language server definition table may specify:

HTTP server

Every running instance of the editor has an HTTP server running. The server can be extended using editor scripts. To extend the editor HTTP server, you need to add get_http_server_routes editor script function — it should return the additional routes:

print("My route: " .. http.server.url .. "/my-extension")

function M.get_http_server_routes()
  return {
    http.server.route("/my-extension", "GET", function(request)
      return http.server.response(200, "Hello world!")
    end)
  }
end

After reloading the editor scripts, you’ll see the following output in the console: My route: http://0.0.0.0:12345/my-extension. If you open this link in the browser, you’ll see your "Hello world!" message.

The input request argument is a simple Lua table with information about the request. It contains keys such as path (URL path segment that starts with /), request method (e.g. "GET"), headers (a table with lower-case header names), and optionally query (the query string) and body (if the route defines how to interpret the body). For example, if you want to make a route that accepts JSON body, you define it with a "json" converter parameter:

http.server.route("/my-extension/echo-request", "POST", "json", function(request)
  return http.server.json_response(request)
end)

You can test this endpoint in the command line using curl and jq:

curl 'http://0.0.0.0:12345/my-extension/echo-request?q=1' -X POST --data '{"input": "json"}' | jq
{
  "path": "/my-extension/echo-request",
  "method": "POST",
  "query": "q=1",
  "headers": {
    "host": "0.0.0.0:12345",
    "content-type": "application/x-www-form-urlencoded",
    "accept": "*/*",
    "user-agent": "curl/8.7.1",
    "content-length": "17"
  },
  "body": {
    "input": "json"
  }
}

The route path supports patterns that can be extracted from the request path and provided to the handler function as a part of the request, e.g.:

http.server.route("/my-extension/setting/{category}.{key}", function(request)
  return http.server.response(200, tostring(editor.get("/game.project", request.category .. "." .. request.key)))
end)

Now, if you open e.g. http://0.0.0.0:12345/my-extension/setting/project.title, you’ll see the title of your game taken from the /game.project file.

In addition to a single segment paths pattern, you can also match the rest of the URL path using {*name} syntax. For example, here is a simple file server endpoint that serves files from the project root:

http.server.route("/my-extension/files/{*file}", function(request)
  local attrs = editor.external_file_attributes(request.file)
  if attrs.is_file then
    return http.server.external_file_response(request.file)
  else
    return 404
  end
end)

Now, opening e.g. http://0.0.0.0:12345/my-extension/files/main/main.collection in the browser will display the contents of the main/main.collection file.

Editor scripts in libraries

You can publish libraries for other people to use that contain commands, and they will be automatically picked up by the editor. Hooks, on the other hand, can’t be picked up automatically, since they have to be defined in a file that is in a root folder of a project, but libraries expose only subfolders. This is intended to give more control over build process: you still can create lifecycle hooks as simple functions in .lua files, so users of your library can require and use them in their /hooks.editor_script.

Also note that although dependencies are shown in Assets view, they do not exist as files (they are entries in a zip archive). It’s possible to make the editor extract some files from the dependencies into build/plugins/ folder. To do it, you need to create ext.manifest file in your library folder, and then create plugins/bin/${platform} folder in the same folder where the ext.manifest file is located. Files in that folder will be automatically extracted to /build/plugins/${extension-path}/plugins/bin/${platform} folder, so your editor scripts can reference them.

Preferences

Editor scripts can define and use preferences — persistent, uncommitted pieces of data stored on the user’s computer. These preferences have three key characteristics:

All preferences must be registered by defining their schema:

function M.get_prefs_schema()
  return {
    ["my_json_formatter.jq_path"] = editor.prefs.schema.string(),
    ["my_json_formatter.indent.size"] = editor.prefs.schema.integer({default = 2, scope = editor.prefs.SCOPE.PROJECT}),
    ["my_json_formatter.indent.type"] = editor.prefs.schema.enum({values = {"spaces", "tabs"}, scope = editor.prefs.SCOPE.PROJECT}),
  }
end

After such editor script is reloaded, the editor registers this schema. Then the editor script can get and set the preferences, e.g.:

-- Get a specific preference
editor.prefs.get("my_json_formatter.indent.type")
-- Returns: "spaces"

-- Get an entire preference group
editor.prefs.get("my_json_formatter")
-- Returns:
-- {
--   jq_path = "",
--   indent = {
--     size = 2,
--     type = "spaces"
--   }
-- }

-- Set multiple nested preferences at once
editor.prefs.set("my_json_formatter.indent", {
    type = "tabs",
    size = 1
})

Execution modes

The editor script runtime uses 2 execution modes that are mostly transparent to editor scripts: immediate and long-running.

Immediate mode is used when the editor needs to receive a response from the script as fast as possible. For instance, menu commands’ active callbacks are executed in immediate mode, because these checks are performed on the editors UI thread in response to user interacting with the editor, and should update the UI within the same frame.

Long-running mode is used when the editor doesn’t need an instantaneous response from the script. For example, menu commands’ run callbacks are executed in a long-running mode, allowing the script to take more time to complete its work.

Some of the functions that the editor scripts can use may take a lot of time to run. For example, editor.execute("git", "status", {reload_resources=false, out="capture"}) can take up to a second on sufficiently large projects. To maintain editor responsiveness and performance, functions that may be time-consuming are not allowed in contexts where the editor needs an immediate response. Attempting to use such a function in an immediate context will result in an error: Cannot use long-running editor function in immediate context. To resolve this error, avoid using such functions in immediate contexts.

The following functions are considered long-running and cannot be used in immediate mode:

The following code execution contexts use immediate mode:

Actions

Previously, the editor interacted with the Lua VM in a blocking way, so there was a hard requirement for editor scripts to not block, since some interactions have to be done from the editor UI thread. For that reason, there was e.g. no editor.execute() and editor.transact(). Executing scripts and changing the editor state was instead triggered by returning an array of “actions” from hooks and command run handlers.

Now the editor interacts with the Lua VM in a non-blocking way, so there is no need for these actions any more: using functions like editor.execute() is more convenient, concise, and powerful. The actions are now DEPRECATED, though we have no plans to remove them.

Editor scripts may return an array of actions from a command’s run function or from /hooks.editor_script’s hook functions. These actions will then be performed by the editor.

Action is a table describing what editor should do. Every action has an action key. Actions come in 2 flavors: undoable and non-undoable.

Undoable actions

Prefer using editor.transact().

Undoable action can be undone after it is executed. If a command returns multiple undoable actions, they are performed together, and get undone together. You should use undoable actions if you can. Their downside is that they are more limited.

Existing undoable actions:

Non-undoable actions

Prefer using editor.execute().

Non-undoable action clears undo history, so if you want to undo such action, you will have to use other means, such as version control.

Existing non-undoable actions:

Mixing actions and side effects

You can mix undoable and non-undoable actions. Actions are executed sequentially, hence depending on an order of actions you will end up losing ability to undo parts of that command.

Instead of returning actions from functions that expect them, you can just read and write to files directly using io.open(). This will trigger a resource reload that will clear undo history.