Go from an empty folder to a running Basalt plugin, with TypeScript checking in your editor.
A Basalt plugin is a folder in plugins/ with a manifest.json and a script. It is laid out like the scripts of a Bedrock behaviour pack and written against the same API, so if you have written scripts for BDS this will look familiar.
This guide builds a small plugin that greets players, then covers how Basalt loads plugins and how to get type checking.
Before you start
Section titled “Before you start”Start Basalt once. On first start it creates the plugins/ folder beside basalt.toml and fills it with what your editor needs:
plugins/ tsconfig.json node_modules/ @minecraft/server/ @minecraft/server-ui/ @minecraft/server-net/ @basalt/menus/The node_modules folders hold TypeScript typings for every module Basalt provides. They are rewritten on every start so they always match the version of Basalt you are running. tsconfig.json is written once and is yours to change.
Create the plugin
Section titled “Create the plugin”Make a folder for the plugin with a scripts/ folder inside it:
plugins/ hello/ manifest.json scripts/ main.tsmanifest.json
Section titled “manifest.json”{ "format_version": 2, "header": { "name": "Hello", "description": "Greets players", "uuid": "b3a1f6c2-58d4-4e0b-9c37-1f2a6d8e4b90", "version": [1, 0, 0] }, "modules": [ { "type": "script", "language": "javascript", "uuid": "7e94c1d8-2a5b-4f63-8d10-c4b7a9e3f562", "version": [1, 0, 0], "entry": "scripts/main.ts" } ], "dependencies": [ { "module_name": "@minecraft/server", "version": "2.0.0" } ]}What Basalt reads from it:
| Field | Meaning |
|---|---|
header.name |
The plugin’s name in the log and in /status. Required. |
header.uuid |
Identifies the plugin’s saved data. Generate your own (for example with uuidgen) and never change it afterwards, or the plugin loses its dynamic properties. |
header.version |
Shown in the log. [1, 0, 0] or "1.0.0". |
modules |
Must contain one module with "type": "script". Its entry is the file run first, relative to the plugin folder. language, if present, must be "javascript", even when the entry is TypeScript. |
dependencies |
The modules the plugin uses. Basalt provides @minecraft/server, @minecraft/server-ui and @minecraft/server-net; a dependency on any other @minecraft/ module is logged as a warning. |
scripts/main.ts
Section titled “scripts/main.ts”import { world } from "@minecraft/server";
world.afterEvents.playerSpawn.subscribe((event) => { if (!event.initialSpawn) return; const { player } = event; player.sendMessage(`Welcome, ${player.name}!`); console.log(`${player.name} joined`);});playerSpawn fires every time a player spawns, including after dying; initialSpawn is true only for the spawn that follows joining.
Run it
Section titled “Run it”Start Basalt, or run /reload if it is already running. The log shows the plugin loading:
Enabling Hello v1.0.0 (script)Join, and you are greeted in chat. console.log output appears in Basalt’s log under the plugin’s name.
Add a second file
Section titled “Add a second file”Plugins are ES modules. Split code across files and import them by relative path:
export function greeting(name: string): string { return `Welcome, ${name}!`;}import { world } from "@minecraft/server";import { greeting } from "./greeting";
world.afterEvents.playerSpawn.subscribe(({ player, initialSpawn }) => { if (initialSpawn) player.sendMessage(greeting(player.name));});An import can be written as ./greeting, ./greeting.ts or ./greeting.js; all three find greeting.ts. Importing a folder finds its index.ts or index.js.
A plugin can import only:
- its own files, inside its own folder,
@minecraft/server,@minecraft/server-uiand@minecraft/server-net,@basalt/menus.
There is no npm. To use a library, copy its source into the plugin folder.
TypeScript and type checking
Section titled “TypeScript and type checking”Basalt runs .ts files directly by stripping the types as it loads them. It does not type-check: code with type errors still runs, and fails at runtime if the error is real. Plain .js files work the same way, without the types.
To get checking, open the plugins/ folder (not a single plugin) in your editor. The tsconfig.json there covers every */scripts/** file, and the typings in node_modules give you completion and documentation for the API.
To check from the command line, with TypeScript installed:
npx tsc -p pluginsThis checks every plugin and writes no files.
How plugins are loaded
Section titled “How plugins are loaded”- Every folder in
plugins/that contains amanifest.jsonis a plugin. Plugins load in folder name order. - Each plugin runs in its own JavaScript engine on its own thread. A plugin that throws, loops forever or runs out of memory takes only itself down.
- The entry file runs once, top to bottom. Top-level
awaitworks. Register your event handlers and commands there. /reloadunloads every plugin and loads them all again from disk. Event subscriptions, timers, custom commands and open forms are dropped, andsystem.beforeEvents.shutdownfires in each plugin first.
When something goes wrong
Section titled “When something goes wrong”Errors are logged under the plugin’s name with the file, line and column:
hello/scripts/main.ts:7:12- A syntax error or a failed import stops that plugin from loading. Other plugins are unaffected.
- An error thrown inside an event handler or timer is logged, and the plugin keeps running. An
InvalidEntityErrorfrom a player or entity that has left is the exception: an event handler stops quietly, and asystem.runIntervalcallback logs it once and is cleared. - Unhandled promise rejections are logged too.
/status lists every plugin with its memory use, subscription count and any warnings. A plugin shown as stopped can be restarted with /reload.
Timers and the tick
Section titled “Timers and the tick”Game time is counted in ticks, 20 a second. system schedules work on the tick:
import { system, world } from "@minecraft/server";
// Once, on the next tick.system.run(() => world.sendMessage("Next tick"));
// Once, after 5 seconds.system.runTimeout(() => world.sendMessage("Five seconds later"), 100);
// Every minute, until cleared.const id = system.runInterval(() => world.sendMessage("Remember to vote!"), 1200);system.clearRun(id);
// In an async function.async function countdown() { for (let left = 3; left > 0; left--) { world.sendMessage(`${left}...`); await system.waitTicks(20); } world.sendMessage("Go!");}setTimeout and setInterval also exist and count in milliseconds, independent of the tick.
Shipping content with a plugin
Section titled “Shipping content with a plugin”A plugin folder can also carry behaviour-pack content. Basalt reads items/, entities/ and loot_tables/ from the same folder, and sends a resource_pack/ folder to players. See Addons.
Next steps
Section titled “Next steps”- Events: react to what happens in the world, and stop it.
- Custom commands: add your own slash commands.
- Forms: show menus and input screens.
- Dynamic properties: save data.