Skip to main content

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

ExportTypeDescription
idstringUnique identifier, e.g. "PLUGIN_MY_CUSTOM_EVENT"
namestringDisplay name shown in the event menu
groupsstring[]Event groups, typically ["EVENT_GROUP_PLUGINS"]
fieldsobject[]UI fields shown in the editor sidebar
compilefunctionCompilation function (input, helpers) => void

Optional Exports

ExportTypeDescription
asyncEventbooleanSet 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 args and children from the event)
  • helpers — an object with all available compiler methods and properties

Available Helpers

Code Emission

HelperDescription
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

HelperDescription
registerCDefine(symbol)Registers a C preprocessor define (e.g. "NEEDS_DIALOGUE")
registerSubScript(...)Registers a sub-script for compilation

Properties

PropertyDescription
helpers.pcCurrent program counter — capture it to create loop-back points
helpers.depthCurrent indentation depth (rarely needed with IR nodes)
eventThe 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;
// ...
};
BuilderDescriptionExample
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 IDvariable("myVar")
call(fn, ...args)Function callcall("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 indexindex(sym("data"), lit(0))
cast(type, operand)Type castcast("u16", sym("val"))
prop(target, property)Property accessprop("player", "x")
engineField(key)Engine field accessengineField("gravity")
direction(value)Direction valuedirection("left")
raw(code)Raw code string (avoid when possible)raw("customCode()")

Statement Nodes (Stmt)

Statements represent actions. Also available from helpers:

BuilderDescriptionExample
stmtExpr(expr)Expression as statementstmtExpr(call("fn"))
stmtCall(fn, ...args)Function call statement (shorthand)stmtCall("TBCE_Init", lit(1))
stmtAssign(target, value)AssignmentstmtAssign(sym("x"), lit(0))
stmtAssignOp(target, op, value)Compound assignment (+=, -=, etc.)stmtAssignOp(sym("x"), "+", lit(1))
stmtReturn(value)Return statementstmtReturn(false)
stmtIf(cond, body, elseBody?)If / elsestmtIf(sym("done"), [stmtReturn(true)])
stmtWhile(cond, body)While loopstmtWhile(sym("running"), [...])
stmtFor(init, cond, step, body)For loopstmtFor(...)
stmtForInfinite(body)Infinite loopstmtForInfinite([...])
stmtSwitch(expr, cases, default?)Switch statementstmtSwitch(sym("mode"), [...])
stmtComment(text)CommentstmtComment("setup")
stmtBlock(...stmts)Statement blockstmtBlock(stmt1, stmt2)
stmtRaw(code)Raw code (avoid when possible)stmtRaw("custom;")
stmtBlank()Blank linestmtBlank()
stmtGoto(label)GotostmtGoto("loop")
stmtLabel(name)Label definitionstmtLabel("loop")
stmtBreak()BreakstmtBreak()

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).

warning

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:

PropertyTypeDescription
keystringThe key used to access this value in input
labelstringDisplay label (also used for info-only text fields with no key)
typestringField type: "number", "text", "checkbox", "select", "variable", "actor", "scene", "direction", "sprite"
defaultValueanyDefault value for the field
minnumberMinimum value (for number fields)
maxnumberMaximum 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:

  1. Create the plugin folder structure — Inside your project's plugins/ directory, create a folder for your plugin (e.g. MyFirstPlugin/), then add an events/ subfolder inside it.

  2. Define the event — Create a JavaScript file in events/ (e.g. eventSayHello.js). The filename must start with event.

  3. 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 simple compile function that emits a comment or a single function call.

  4. 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.