Writing Plugins
Plugins let you add custom events to MD Engine. A plugin is a folder inside your project's plugins/ directory containing an events/ subfolder with one or more JavaScript files.
Folder Structure
your-project/
plugins/
MyPlugin/
events/
eventMyCustomEvent.js
engine/ (optional — custom C engine code)
src/
engine.json
Event File Structure
Each event file exports a module with the following shape:
const id = "PLUGIN_MY_CUSTOM_EVENT";
const groups = ["EVENT_GROUP_PLUGINS"];
const name = "My Custom Event";
const fields = [
{
key: "someValue",
label: "Some Value",
type: "number",
defaultValue: 10,
},
];
const compile = (input, helpers) => {
// Your compilation logic here
};
module.exports = {
id,
name,
groups,
fields,
compile,
};
Required Exports
| Export | Type | Description |
|---|---|---|
id | string | Unique identifier, e.g. "PLUGIN_MY_CUSTOM_EVENT" |
name | string | Display name shown in the event menu |
groups | string[] | Event groups, typically ["EVENT_GROUP_PLUGINS"] |
fields | object[] | UI fields shown in the editor sidebar |
compile | function | Compilation function (input, helpers) => void |
Optional Exports
| Export | Type | Description |
|---|---|---|
asyncEvent | boolean | Set to true if the event uses async helpers (emitAsyncModal, emitAsyncWait) |
The Compile Function
The compile function receives two arguments:
- input — the user-configured field values (merged
argsandchildrenfrom the event) - helpers — an object with all available compiler methods and properties
Available Helpers
Code Emission
| Helper | Description |
|---|---|
emitStmt(stmt) | Emits a statement IR node to the output |
cmnt(text) | Emits a comment line |
startLine() | Begins a new statement block (increments the program counter) |
endLine(async?) | Ends the current statement block. Pass true to add an async resume label |
emitAsyncModal(expr) | Emits an async modal check: if (!expr) { return false; } with an async label. The expr should be the function call — the negation and if-wrapping is handled automatically |
emitAsyncWait(setup, cond) | Emits a two-step async wait: executes setup, then loops with return false while cond is true |
renderExpr(expr) | Renders an Expr node to a string (useful for comments) |
Registration
| Helper | Description |
|---|---|
registerCDefine(symbol) | Registers a C preprocessor define (e.g. "NEEDS_DIALOGUE") |
registerSubScript(...) | Registers a sub-script for compilation |
Properties
| Property | Description |
|---|---|
helpers.pc | Current program counter — capture it to create loop-back points |
helpers.depth | Current indentation depth (rarely needed with IR nodes) |
event | The current event object being compiled |
IR Node System
The MD Engine compiler uses an intermediate representation (IR) to generate target-agnostic code. Instead of writing raw C strings, plugins build IR nodes — plain JavaScript objects that the compiler converts to the target language.
Expression Nodes (Expr)
Expressions represent values. All IR builders are available directly from the helpers parameter — just destructure what you need:
const compile = (input, helpers) => {
const { lit, sym, call, binop, unary, member, index, emitStmt, stmtCall } = helpers;
// ...
};
| Builder | Description | Example |
|---|---|---|
lit(value) | Literal (number, string, boolean) | lit(10), lit("hello") |
sym(name) | Symbol reference (variable, constant, enum) | sym("BUTTON_A") |
variable(id) | Variable reference by ID | variable("myVar") |
call(fn, ...args) | Function call | call("TBCE_TextShow", lit(1)) |
binop(op, left, right) | Binary operator (+, -, <, ==, |, etc.) | binop("<", sym("i"), lit(10)) |
unary(op, operand) | Unary operator (!, -, ~) | unary("!", sym("done")) |
member(obj, field, acc) | Member access (. or ->, defaults to .) | member(sym("ctx"), "pc", "->") |
index(array, idx) | Array index | index(sym("data"), lit(0)) |
cast(type, operand) | Type cast | cast("u16", sym("val")) |
prop(target, property) | Property access | prop("player", "x") |
engineField(key) | Engine field access | engineField("gravity") |
direction(value) | Direction value | direction("left") |
raw(code) | Raw code string (avoid when possible) | raw("customCode()") |
Statement Nodes (Stmt)
Statements represent actions. Also available from helpers:
| Builder | Description | Example |
|---|---|---|
stmtExpr(expr) | Expression as statement | stmtExpr(call("fn")) |
stmtCall(fn, ...args) | Function call statement (shorthand) | stmtCall("TBCE_Init", lit(1)) |
stmtAssign(target, value) | Assignment | stmtAssign(sym("x"), lit(0)) |
stmtAssignOp(target, op, value) | Compound assignment (+=, -=, etc.) | stmtAssignOp(sym("x"), "+", lit(1)) |
stmtReturn(value) | Return statement | stmtReturn(false) |
stmtIf(cond, body, elseBody?) | If / else | stmtIf(sym("done"), [stmtReturn(true)]) |
stmtWhile(cond, body) | While loop | stmtWhile(sym("running"), [...]) |
stmtFor(init, cond, step, body) | For loop | stmtFor(...) |
stmtForInfinite(body) | Infinite loop | stmtForInfinite([...]) |
stmtSwitch(expr, cases, default?) | Switch statement | stmtSwitch(sym("mode"), [...]) |
stmtComment(text) | Comment | stmtComment("setup") |
stmtBlock(...stmts) | Statement block | stmtBlock(stmt1, stmt2) |
stmtRaw(code) | Raw code (avoid when possible) | stmtRaw("custom;") |
stmtBlank() | Blank line | stmtBlank() |
stmtGoto(label) | Goto | stmtGoto("loop") |
stmtLabel(name) | Label definition | stmtLabel("loop") |
stmtBreak() | Break | stmtBreak() |
Why IR Nodes?
IR nodes are target-agnostic. The same plugin code works regardless of whether the compiler outputs C, 68k assembly, or any future target. The compiler's emitter handles all formatting details (braces, semicolons, indentation, register allocation).
Avoid using raw() in new plugins. It embeds target-specific strings that will break if the output target changes. Use the structured IR nodes instead.
Complete Example
Here is a complete plugin that displays all text strings in the project sequentially:
const id = "PLUGIN_DISPLAY_ALL_TEXTS";
const groups = ["EVENT_GROUP_PLUGINS"];
const name = "Display All Texts";
const fields = [
{ label: "Loops through all texts in the project, displaying each one." },
];
const compile = (input, helpers) => {
const {
cmnt, startLine, endLine,
emitAsyncModal, emitStmt, registerCDefine,
lit, sym, call, member, index, binop, unary,
stmtAssign, stmtAssignOp, stmtCall, stmtIf, stmtReturn,
} = helpers;
const waitHelper = member(sym("ctx"), "waitHelper", "->");
// Enable the dialogue module
registerCDefine("NEEDS_DIALOGUE");
// Async label setup
startLine();
cmnt("Pre ASync event Label");
endLine(true);
// Start the text display system
cmnt("Start the show text module");
emitAsyncModal(call("TBCE_TextShowStart", lit(1), sym("WINDOW")));
// Open a dialogue frame (bottom of screen, 40x8 tiles)
cmnt("Show Frame");
emitAsyncModal(
call("TBCE_TextShowOpenFrame", lit(0), lit(20), lit(40), lit(8), lit(3), lit(1))
);
// Initialize the loop counter to 0
startLine();
emitStmt(stmtAssign(waitHelper, lit(0)));
endLine(true);
// Capture the program counter for the loop-back point
const loopPC = helpers.pc;
// Show the current text
cmnt("Show Text");
emitAsyncModal(
call("TBCE_TextShow",
index(sym("currentLocale"), waitHelper),
lit(1), lit(21), lit(0), sym("BUTTON_A"))
);
// Wait until the player presses A, then consume the input
cmnt("Wait For Input BUTTON_A from Player 1");
startLine();
emitStmt(stmtIf(
unary("!", call("TBCE_MasterInputJustPressed", lit(0), sym("BUTTON_A"))),
[stmtReturn(false)],
[stmtCall("TBCE_InputUpdate")]
));
endLine(true);
// Increment counter and loop back if more texts remain
cmnt("Loop");
startLine();
emitStmt(stmtAssignOp(waitHelper, "+", lit(1)));
emitStmt(stmtIf(
binop("<", waitHelper, sym("TOTAL_TEXT_LINES")),
[
stmtAssign(member(sym("ctx"), "pc", "->"), lit(loopPC)),
stmtReturn(false),
]
));
endLine(true);
// Close the dialogue frame
cmnt("Close text frame");
emitAsyncModal(call("TBCE_TextShowCloseFrame", lit(3)));
// Finish the text display system
cmnt("Finish the show text module");
startLine();
emitStmt(stmtCall("TBCE_TextShowFinish", lit(1)));
endLine(true);
};
module.exports = {
id,
name,
groups,
fields,
compile,
asyncEvent: true,
};
Async Events
If your event needs to wait for something (user input, animation, timer), set asyncEvent: true in the module exports and use the async helpers.
emitAsyncModal
Used for operations that block until a condition is met. Pass the function call expression — the compiler wraps it in if (!call) { return false; } and inserts an async resume label.
// Wait for a text frame to open
emitAsyncModal(call("TBCE_TextShowOpenFrame", lit(0), lit(20), lit(40), lit(8), lit(3), lit(1)));
startLine / endLine
Wrap statement blocks that need an async resume point. Calling endLine(true) inserts a label that the script runner uses to resume execution on the next frame.
startLine();
emitStmt(stmtIf(
unary("!", call("TBCE_InputJustPressed", lit(0), sym("BUTTON_A"))),
[stmtReturn(false)]
));
endLine(true);
Loop-Back Pattern
Capture helpers.pc after an endLine(true) call to get a resume point, then assign it to ctx->pc and return false to jump back:
startLine();
cmnt("Setup");
endLine(true);
const loopPC = helpers.pc;
// ... do work ...
startLine();
emitStmt(stmtIf(
binop("<", counter, sym("MAX_COUNT")),
[
stmtAssign(member(sym("ctx"), "pc"), lit(loopPC)),
stmtReturn(false),
]
));
endLine(true);
Engine Plugins
If your plugin needs custom C engine code, add an engine/ folder with an engine.json manifest and a src/ directory:
MyPlugin/
events/
eventMyEvent.js
engine/
engine.json
src/
myModule.c
myModule.h
The engine.json must declare the engine version it was built for:
{
"version": "1.0.0"
}
Engine source files in src/ are copied into the build output and compiled alongside the main engine. If the plugin's engine version doesn't match the project's engine version, a warning is shown during compilation.
Field Types
The fields array defines the UI controls shown in the editor sidebar. Each field object supports:
| Property | Type | Description |
|---|---|---|
key | string | The key used to access this value in input |
label | string | Display label (also used for info-only text fields with no key) |
type | string | Field type: "number", "text", "checkbox", "select", "variable", "actor", "scene", "direction", "sprite" |
defaultValue | any | Default value for the field |
min | number | Minimum value (for number fields) |
max | number | Maximum value (for number fields) |
options | [value, label][] | Options for select fields |
Example Fields
const fields = [
{
key: "speed",
label: "Speed",
type: "number",
defaultValue: 3,
min: 1,
max: 10,
},
{
key: "direction",
label: "Direction",
type: "select",
defaultValue: "left",
options: [
["left", "Left"],
["right", "Right"],
["up", "Up"],
["down", "Down"],
],
},
{
key: "target",
label: "Target Actor",
type: "actor",
defaultValue: "$self$",
},
{
key: "enabled",
label: "Enabled",
type: "checkbox",
defaultValue: true,
},
];
Building Your First Plugin
Here is a step-by-step walkthrough for creating a simple plugin that adds a custom event:
-
Create the plugin folder structure — Inside your project's
plugins/directory, create a folder for your plugin (e.g.MyFirstPlugin/), then add anevents/subfolder inside it. -
Define the event — Create a JavaScript file in
events/(e.g.eventSayHello.js). The filename must start withevent. -
Write the event handler — Export the required fields (
id,name,groups,fields,compile) as shown in the Event File Structure section above. Start with a simplecompilefunction that emits a comment or a single function call. -
Test in the editor — Restart MD Engine (or reload the project). Your custom event should appear in the event menu under the Plugins group. Add it to a script, press Play, and verify it works.
C Engine Plugins
If your plugin includes custom C code in the engine/ subfolder, it will be compiled alongside the TBCE engine during the build process. This allows direct hardware access, custom assembly routines, and tight integration with the engine's internal systems. See the Engine Plugins section for the required folder structure and engine.json manifest.
Debugging Plugins
- Check the Build Log for compilation errors from plugin code — both JavaScript event errors and C engine errors appear here.
- Use the Debugger's Variables tab to inspect plugin variable values at runtime.
- Test with the Play Window before exporting to catch issues early.
- If your plugin uses
raw()expressions, verify the output in the generated C files to ensure correctness.
Publishing & Sharing
Plugins can be shared as ZIP files containing the plugin folder. Users install them by extracting the ZIP into their project's plugins/ directory. Make sure to include:
- All event files in
events/. - Any engine code in
engine/(if applicable). - A brief README or comment header explaining what the plugin does and how to use it.

