Event Plugins

Custom events extend GB Studio's scripting system with new commands. Event plugins are written in JavaScript (Node.js) and run at compile time to generate GBVM assembly bytecode. Each plugin defines UI fields, compiles to GBVM via the compile() function, and appears in the script editor alongside built-in events.

Event Plugin File Structure

Event plugins live in plugins/[PluginName]/events/event[Name].js. Each file exports a module with required and optional properties:

const id = "MY_EVENT_ID";                    // SCREAMING_SNAKE_CASE, globally unique
const groups = ["EVENT_GROUP_ACTOR"];         // Category in the script editor UI
const name = "My Event Name";               // Display name in the event picker

const autoLabel = (fetchArg) => {            // Optional: dynamic label in the editor
  return `My Event: ${fetchArg("someField")}`;
};

const fields = [ /* field definitions */ ];

const compile = (input, helpers) => {
  // Generate GBVM bytecode using helpers API
};

module.exports = { id, name, groups, fields, compile, autoLabel };

Export Styles

Both CommonJS and ES6 exports work:

// CommonJS (Node.js style)
module.exports = { id, name, groups, fields, compile };

// ES6 named exports
export const id = "MY_EVENT_ID";
export const name = "My Event";
export const fields = [ ... ];
export const compile = (input, helpers) => { ... };

Standard Groups

Group StringCategory
"EVENT_GROUP_ACTOR"Actor operations
"EVENT_GROUP_SCREEN"Screen / display
"EVENT_GROUP_COLOR"Palette / color
"EVENT_GROUP_VARIABLES"Variable operations
"EVENT_GROUP_DIALOGUE"Dialogue / text
"EVENT_GROUP_MUSIC"Sound / music
"Custom Name"Creates a custom group (e.g., "Handheld Camera", "Tone Beep")

Field Definitions

The fields array defines the UI controls that appear in the GB Studio script editor. Each field is an object with a key, type, and optional layout/validation properties.

Field Object

{
  key: "fieldKey",              // Accessed as input.fieldKey in compile()
  label: "Display Label",      // Label shown in the editor
  description: "Help text",    // Tooltip description
  type: "fieldType",           // One of the types below
  defaultValue: value,         // Default value for this type
  width: "50%",                // Optional: layout width ("50%", "33%", etc.)
  optional: true,              // Optional: field is not required
  conditions: [                // Optional: show/hide based on other fields
    { key: "otherField", eq: "value" },  // Show when otherField === "value"
    { key: "otherField", ne: "value" },  // Show when otherField !== "value"
  ],
}

Field Types

TypeDefault ValueDescriptionExtra Props
number0Numeric inputmin, max
variable"LAST_VARIABLE"Variable picker
actor"$self$"Actor picker
scene"LAST_SCENE"Scene picker
value{type:"number",value:0}Number / variable / expression union
union{number:0, variable:"LAST_VARIABLE", property:"$self$:xpos"}Multi-type pickertypes, defaultType
select"optionValue"Dropdown menuoptions: [["val","Label"],...]
togglebuttons"value"Button groupoptions, allowNone
checkboxtrue / falseBoolean toggle
textarea""Text inputplaceholder, singleLine, multiple
soundEffect"beep"Sound effect picker
avatar""Portrait pickertoggleLabel
eventsChild event container
groupField container (visual grouping)fields: [...]
tabs"tabKey"Tabbed sectionvalues: {key:"Label"}
breakVisual separator line

Conditional Visibility

Use conditions to show or hide fields based on the value of other fields. All conditions must be true for the field to be visible:

{
  type: "group",
  conditions: [
    { key: "fadeType", eq: "2" },  // Only show when fadeType is "2"
  ],
  fields: [
    { key: "r", label: "Red (0-31)", type: "number", min: 0, max: 31, defaultValue: 0, width: "33%" },
    { key: "g", label: "Green (0-31)", type: "number", min: 0, max: 31, defaultValue: 0, width: "33%" },
    { key: "b", label: "Blue (0-31)", type: "number", min: 0, max: 31, defaultValue: 0, width: "33%" },
  ],
}

Union Fields

The union type lets users choose between a constant number, a variable reference, or an actor property at runtime:

{
  key: "r0",
  label: "Red",
  type: "union",
  types: ["number", "variable", "property"],  // Available input modes
  defaultType: "number",                       // Default mode
  min: 0,
  max: 31,
  defaultValue: {
    number: 29,                    // Default when "number" is selected
    variable: "LAST_VARIABLE",     // Default when "variable" is selected
    property: "$self$:xpos",       // Default when "property" is selected
  },
}

Compile Helpers API

The compile(input, helpers) function receives all field values in input and the full helpers API in helpers. Destructure what you need:

const compile = (input, helpers) => {
  const { _stackPushConst, _callNative, _stackPop, _addComment } = helpers;
  // ...
};

Stack Operations

_stackPushConst(value)     // Push INT16 constant onto GBVM stack (tracked)
_stackPush(variable)       // Push variable value onto stack (tracked)
_stackPop(count)           // Pop N values — adjusts tracker only, does NOT emit VM_POP

Native C Function Calls

_callNative("function_name")   // Call a BANKED C function

Push parameters in reverse order (last parameter first). The C function receives them via FN_ARG0 (top of stack) through FN_ARG7:

// C function: void my_func(INT16 a, INT16 b, INT16 c)
// a = FN_ARG0 (top), b = FN_ARG1, c = FN_ARG2
_stackPushConst(c);    // 3rd param pushed first (deepest)
_stackPushConst(b);    // 2nd param
_stackPushConst(a);    // 1st param pushed last (on top = FN_ARG0)
_callNative("my_func");
_stackPop(3);          // Clean up (adjusts tracker)

Local Variables

const tmp = _declareLocal("tmp_name", size, isTemporary)
// size       = number of INT16 slots to allocate
// isTemporary = true for auto-named temps, false for named locals
// Returns a STRING: ".LOCAL_TMP{N}_{NAME}" or ".LOCAL_{NAME}"
Warning: _declareLocal returns a STRING

_declareLocal returns a string like .LOCAL_TMP0_MY_VAR, not an object. This string is a stack-relative GBVM symbol. Use template interpolation (${localVar}) in appendRaw to reference it. The name is sanitized: uppercased, non-alphanumeric characters replaced with underscores.

Variable Operations

variableSetToScriptValue(variable, scriptValue)
// scriptValue examples:
//   {type: "number", value: 42}
//   {type: "variable", value: "VAR_UUID"}
//   {type: "expression", value: "$VAR_UUID$ + 1"}

variableSetToUnionValue(variable, unionValue)   // Set variable from union field
variableFromUnion(unionValue, tempVarName)       // Resolve union, return variable alias
getVariableAlias(variable)                       // Get GBVM heap symbol for a variable

Actor Operations

setActorId(variable, actorRef)   // Resolve actor UUID to index, store in variable
                                  // Stack-neutral (uses _setConst internally)

actorHide(actorRef)              // Hide actor
actorDeactivate(actorRef)        // Deactivate actor (remove from active list)
actorSetPositionToScriptValues(actorRef, x, y, unit)  // unit: "tiles" or "pixels"

RPN Builder

The RPN (Reverse Polish Notation) builder chains arithmetic and logic operations on an evaluation stack:

_rpn()
  .ref(variable)          // Push variable value onto eval stack
  .int8(value)            // Push INT8 constant
  .int16(value)           // Push INT16 constant
  .operator(".ADD")       // Apply binary operator
  .refSet(variable)       // Pop eval stack, store in variable
  .stop()                 // Finalize — remaining eval items push to VM stack

All RPN Operators

ArithmeticBitwiseComparisonLogical
.ADD, .SUB, .MUL, .DIV, .MOD .B_AND, .B_OR, .B_XOR, .SHL, .SHR .EQ, .NE, .LT, .GT, .LTE, .GTE .AND, .OR, .NOT

RPN Stack Neutrality

If .stop() is called with values remaining on the eval stack, they are pushed to the VM stack, changing the tracked stackPtr. Use .refSet() to consume all values for stack-neutral RPN blocks:

// Stack-neutral: all values consumed via refSet
_rpn()
  .ref(varA).ref(varB).operator(".ADD")
  .refSet(result)   // Pops result from eval stack into variable
  .stop();          // stackPtr unchanged

// NOT stack-neutral: one value left on eval stack
_rpn()
  .ref(varA).ref(varB).operator(".ADD")
  .stop();          // Pushes 1 value to VM stack (stackPtr += 1)

Labels & Control Flow

const labelId = getNextLabel()                   // Get a unique label ID
_label(labelId)                                  // Emit label definition
_jump(labelId)                                   // Unconditional jump
_ifConst(operator, ref, value, labelTrue, popN)  // Conditional jump, pops popN from stack

High-Level Helpers

ifVariableValue(variable, operator, value, thenFn, elseFn)
soundPlay(soundType, priority, effectIndex)
textDialogue(text, avatarId)
_compilePath(childEvents)       // Compile nested event array (from "events" field)
_idle()                         // Yield one frame (VM_IDLE)

Memory Access

_getMemInt8(variable, address)       // Read byte from address into variable
_setConstMemInt8(address, value)     // Write constant byte to address
_setMemInt8(address, variable)       // Write variable value to address

Raw Assembly

appendRaw(asmString)           // Insert raw GBVM assembly lines (untracked)
_addCmd(command, ...args)      // Add single GBVM command (tracked, adjusts stack refs)
_addComment("Description")    // Add comment to output
_addNL()                       // Add blank line
Danger: Do NOT mix tracked and untracked stack operations

_rpn().stop() adjusts the tracked stackPtr. appendRaw with VM_RPN pushes are invisible to the tracker. Mixing the two causes “stack not neutral” errors at compile time. Use one approach per section: either all tracked helpers, or all appendRaw.

GBVM Actor Opcodes

Actor commands use pseudo-struct patterns — multi-word local variables where the first word is the actor ID and subsequent words hold data.

Position Struct Pattern

A 3-word struct: [actorId, X, Y] at offsets +0, +1, +2.

// Declare a 3-word local for position
const actorPos = _declareLocal("actor_pos", 3, true);
setActorId(actorPos, input.actorId);  // Set struct[0] = resolved actor index

// Read current position into struct[1] and struct[2]
_addCmd("VM_ACTOR_GET_POS", actorPos);

// Modify position
_rpn()
  .int16(1280)                       // X in subpixels (40px * 32)
  .refSet(`${actorPos} + 1`)         // Store at struct[1]
  .int16(3840)                       // Y in subpixels (120px * 32)
  .refSet(`${actorPos} + 2`)         // Store at struct[2]
  .stop();

// Apply position
_addCmd("VM_ACTOR_SET_POS", actorPos);

Animation Frame Pattern

A 2-word struct: [actorId, frame] at offsets +0, +1.

// Declare a 2-word local for frame control
const actorFrame = _declareLocal("actor_frame", 2, true);
setActorId(actorFrame, input.actorId);

// Set frame via appendRaw
appendRaw(`VM_SET_CONST ^/(${actorFrame} + 1)/, ${input.frame}`);
appendRaw(`VM_ACTOR_SET_ANIM_FRAME ${actorFrame}`);
appendRaw(`VM_ACTOR_SET_ANIM_TICK ${actorFrame}, 255`);  // Pause auto-animation
Always pause animation after setting a frame

VM_ACTOR_SET_ANIM_FRAME does NOT stop the engine from auto-advancing frames. The engine's anim_tick keeps counting and will override your frame on the next tick. Always follow with VM_ACTOR_SET_ANIM_TICK actorVar, 255 to freeze the frame.

Reading Animation Frames

const frameStruct = _declareLocal("frm", 2, true);
setActorId(frameStruct, input.actorId);

// Read current frame into struct[1]
appendRaw(`VM_ACTOR_GET_ANIM_FRAME ${frameStruct}`);

// Frame value is now at ^/(${frameStruct} + 1)/
// Store to a global variable:
const varAlias = getVariableAlias(input.storeVariable);
appendRaw(`VM_SET ${varAlias}, ^/(${frameStruct} + 1)/`);

Smooth Actor Movement

// Move actor using the built-in movement system
appendRaw(`
VM_RPN
    .R_INT16    1
    .R_INT16    0
    .R_INT16    -20
    .R_STOP
VM_ACTOR_MOVE_TO_INIT   .ARG2, ^/(.ACTOR_ATTR_DIAGONAL | .ACTOR_ATTR_RELATIVE_SNAP_PX)/
VM_ACTOR_MOVE_TO_XY     .ARG2, ^/(.ACTOR_ATTR_DIAGONAL | .ACTOR_ATTR_RELATIVE_SNAP_PX)/
VM_POP                  3
`);

Actor Activation (Streaming Fix)

VM_ACTOR_ACTIVATE actorVar
Unpinned actors deactivate when moved off-screen

GB Studio's actors_update() automatically deactivates unpinned actors when they leave the visible area. After deactivation, VM_ACTOR_SET_POS updates position data but the actor stays invisible. Call VM_ACTOR_ACTIVATE before moving it back on-screen. The player actor is never deactivated by streaming — if something works on the player but not on NPCs, this is the cause.

Other Actor Commands

CommandArgumentsDescription
VM_ACTOR_SET_DIRaddr, directionSet facing direction
VM_ACTOR_SET_HIDDENaddr, hiddenShow (0) or hide (1) actor
VM_ACTOR_MOVE_CANCELaddrCancel current movement
VM_ACTOR_ACTIVATEaddrForce-activate a deactivated actor
VM_ACTOR_DEACTIVATEaddrRemove actor from active list
VM_ACTOR_SET_ANIM_TICKaddr, tickSet animation speed (255 = paused)
VM_ACTOR_GET_ANIM_FRAMEaddrRead current frame into struct[1]
VM_ACTOR_SET_ANIM_FRAMEaddrSet frame from struct[1]
VM_ACTOR_GET_POSaddrRead position into struct[1,2]
VM_ACTOR_SET_POSaddrApply position from struct[1,2]

Struct Field Access in Assembly

Multi-word locals are accessed using RGBDS assembly-time expressions with the ^/(expr)/ syntax:

const posStruct = _declareLocal("pos", 3, true);
// posStruct = ".LOCAL_TMP0_POS"

// In appendRaw:
// Offset +0 (actorId): ${posStruct}
// Offset +1 (X):       ^/(${posStruct} + 1)/
// Offset +2 (Y):       ^/(${posStruct} + 2)/

appendRaw(`VM_SET_CONST ^/(${posStruct} + 1)/, 2560`);  // Set X = 80px * 32
Stack pointer safety

.LOCAL_* symbols are SP-relative. The compiler adjusts them via _offsetStackAddr when using tracked helpers like _addCmd. In appendRaw, references are correct ONLY when the tracked stackPtr == 0. Always ensure stack-neutral operations (like setActorId, _setConst) before entering appendRaw blocks.

Storing to Global Variables

Use getVariableAlias() to resolve a variable UUID to its GBVM heap symbol, then VM_SET to copy between locals and globals:

// Field: { key: "storeVariable", type: "variable", defaultValue: "LAST_VARIABLE" }

const variableAlias = getVariableAlias(input.storeVariable);
const localVal = _declareLocal("val", 1, true);

// Copy from local to global:
appendRaw(`VM_SET ${variableAlias}, ${localVal}`);

// Copy from struct field to global:
appendRaw(`VM_SET ${variableAlias}, ^/(${frameStruct} + 1)/`);

getVariableAlias returns a .SCRIPT_VAR_X_Y symbol (positive heap address). VM_SET DEST, SRC works between any combination of stack locals (negative indices) and heap globals (positive indices).

Labels in Raw Assembly

When writing loop or branch logic in appendRaw, use getNextLabel() for unique IDs:

const loopLabel = getNextLabel();
const endLabel = getNextLabel();

appendRaw(`
VM_SET_CONST ${counter}, 10
${loopLabel}$:
; ... loop body ...
VM_RPN
    .R_REF      ${counter}
    .R_INT16    1
    .R_OPERATOR .SUB
    .R_REF_SET  ${counter}
    .R_REF      ${counter}
    .R_STOP
VM_IF_CONST .GT, .ARG0, 0, ${loopLabel}$, 1
`);
Label format

Label definitions end with $: (e.g., ${labelId}$:). Label references use $ without colon (e.g., ${labelId}$). Do NOT mix tracked _label() with appendRaw loop code — tracked labels assert stack neutrality, which conflicts with untracked operations.

Complete Examples

Four working examples demonstrating the core patterns, taken from production plugins in this project.

Example 1: Native C Function Call

Push parameters in reverse order, call the function, clean up the stack. From the HandheldCamera plugin:

const id = "HC_EVENT_START";
const groups = ["Handheld Camera"];
const name = "Start Handheld Camera";

const fields = [
    {
        key: "amplitude_x",
        label: "X Range (pixels)",
        description: "Total horizontal movement range (0-8, 0 = disabled)",
        type: "number",
        min: 0, max: 8, defaultValue: 2, width: "50%",
    },
    {
        key: "amplitude_y",
        label: "Y Range (pixels)",
        type: "number",
        min: 0, max: 8, defaultValue: 2, width: "50%",
    },
    {
        key: "speed",
        label: "Speed",
        type: "select",
        options: [
            ["1", "Slow"], ["2", "Normal"], ["3", "Fast"],
            ["4", "Faster"], ["5", "Fastest"],
        ],
        defaultValue: "2", width: "50%",
    },
    {
        key: "smoothness",
        label: "Smoothness",
        type: "select",
        options: [
            ["1", "Sharp"], ["2", "Normal"],
            ["3", "Smooth"], ["4", "Very Smooth"],
        ],
        defaultValue: "2", width: "50%",
    },
];

const compile = (input, helpers) => {
    const { _stackPushConst, _callNative, _stackPop } = helpers;

    const amplitude_x = input.amplitude_x !== undefined ? input.amplitude_x : 1;
    const amplitude_y = input.amplitude_y !== undefined ? input.amplitude_y : 1;
    const speed = parseInt(input.speed) || 2;
    const smoothness = parseInt(input.smoothness) || 2;

    // Push in reverse order: smoothness (arg3), speed (arg2), y (arg1), x (arg0)
    _stackPushConst(smoothness);
    _stackPushConst(speed);
    _stackPushConst(amplitude_y);
    _stackPushConst(amplitude_x);
    _callNative("vm_handheld_camera_start");
    _stackPop(4);
};

module.exports = { id, name, groups, fields, compile };

Example 2: High-Level Helpers with Actor & Variable Fields

Using variableSetToScriptValue, actorDeactivate, soundPlay, and textDialogue. From the TakeItem plugin:

const id = "EVENT_TAKE_ITEM";
const name = "Take Item";
const groups = ["EVENT_GROUP_ACTOR"];

const autoLabel = (fetchArg) => {
  const text = fetchArg("text");
  if (text && text.length > 0) {
    return `Take Item: ${text.substring(0, 20)}${text.length > 20 ? "..." : ""}`;
  }
  return "Take Item";
};

const fields = [
  {
    key: "itemVariable",
    label: "Item Variable",
    description: "Variable to set to 1 when item is taken",
    type: "variable",
    defaultValue: "LAST_VARIABLE",
  },
  {
    key: "text",
    label: "Pickup Message",
    type: "textarea",
    placeholder: "You picked up the item!",
    defaultValue: "",
  },
  {
    type: "group",
    fields: [
      {
        key: "type",
        label: "Sound Effect",
        type: "soundEffect",
        defaultValue: "beep",
        optional: true,
        width: "50%",
      },
    ],
  },
  {
    key: "avatarId",
    label: "Avatar",
    type: "avatar",
    defaultValue: "",
    optional: true,
    toggleLabel: "Add Avatar",
  },
];

const compile = (input, helpers) => {
  const { _addComment, variableSetToScriptValue, actorDeactivate,
          soundPlay, textDialogue } = helpers;

  _addComment("Take Item");

  // Set the item variable to 1
  variableSetToScriptValue(input.itemVariable, { type: "number", value: 1 });

  // Deactivate the actor (self)
  actorDeactivate("$self$");

  // Play sound effect
  if (input.type) {
    soundPlay(input.type, "medium", input.effect || 2);
  }

  // Display pickup message
  if (input.text && input.text.trim().length > 0) {
    textDialogue(input.text, input.avatarId || undefined);
  }
};

module.exports = { id, name, groups, autoLabel, fields, compile };

Example 3: Direct Memory Writes

Using _setConstMemInt8 to write to C global variables. From the SmoothFade plugin:

const id = "SF_EVENT_SET_FADE_COLOR";
const groups = ["EVENT_GROUP_SCREEN"];
const name = "Set Fade Color";

const fields = [
    {
        key: "fadeType",
        label: "Fade To",
        type: "select",
        options: [
            ["0", "White"], ["1", "Black"], ["2", "Custom RGB"],
        ],
        defaultValue: "0",
    },
    {
        type: "group",
        conditions: [{ key: "fadeType", eq: "2" }],  // Only show for Custom RGB
        fields: [
            { key: "r", label: "Red (0-31)", type: "number",
              min: 0, max: 31, defaultValue: 0, width: "33%" },
            { key: "g", label: "Green (0-31)", type: "number",
              min: 0, max: 31, defaultValue: 0, width: "33%" },
            { key: "b", label: "Blue (0-31)", type: "number",
              min: 0, max: 31, defaultValue: 0, width: "33%" },
        ],
    },
];

const compile = (input, helpers) => {
    const { _setConstMemInt8, _addComment } = helpers;
    const fadeType = parseInt(input.fadeType) || 0;

    _addComment("Set Fade Color");
    _setConstMemInt8("fade_style", fadeType);

    if (fadeType === 2) {
        _setConstMemInt8("fade_target_r", input.r || 0);
        _setConstMemInt8("fade_target_g", input.g || 0);
        _setConstMemInt8("fade_target_b", input.b || 0);
    }
};

module.exports = { id, name, groups, fields, compile };

Example 4: Raw Assembly with Actor Structs and Loops

Using appendRaw for complex control flow with position structs, frame structs, looping, and actor activation. From the CarouselSprite plugin:

const id = "CS_EVENT_CAROUSEL";
const groups = ["Carousel Sprite"];
const name = "Carousel Sprite";

const fields = [
    { key: "actorId", label: "Actor", type: "actor", defaultValue: "$self$" },
    { key: "totalFrames", label: "Total Frames", type: "number",
      min: 2, max: 25, defaultValue: 2, width: "50%" },
    { key: "direction", label: "Direction", type: "select",
      options: [["up","Up"],["down","Down"],["left","Left"],["right","Right"]],
      defaultValue: "up" },
    { key: "storeVariable", label: "Store Frame In", type: "variable",
      defaultValue: "LAST_VARIABLE", width: "50%" },
];

const compile = (input, helpers) => {
    const { _declareLocal, setActorId, _addComment, _addNL,
            getNextLabel, appendRaw, getVariableAlias } = helpers;

    const variableAlias = getVariableAlias(input.storeVariable);
    const totalFrames = input.totalFrames || 2;
    const STEP_SUBPX = 16 * 32;  // 512 subpixels per step

    // Compute movement deltas
    let dxStep = 0, dyStep = 0;
    switch (input.direction || "up") {
        case "up":    dyStep = -STEP_SUBPX; break;
        case "down":  dyStep = STEP_SUBPX; break;
        case "left":  dxStep = -STEP_SUBPX; break;
        case "right": dxStep = STEP_SUBPX; break;
    }

    // Declare local structs (all stack-neutral via setActorId)
    const posStruct = _declareLocal("crs_ps", 3, true);    // [actorId, X, Y]
    const frameStruct = _declareLocal("crs_fs", 2, true);  // [actorId, frame]
    const savedX = _declareLocal("crs_sx", 1, true);
    const savedY = _declareLocal("crs_sy", 1, true);
    const counter = _declareLocal("crs_ct", 1, true);

    setActorId(posStruct, input.actorId);
    setActorId(frameStruct, input.actorId);

    const exitLabel = getNextLabel();
    const entryLabel = getNextLabel();
    const wrapLabel = getNextLabel();
    const doneFrameLabel = getNextLabel();

    _addComment("Carousel Sprite");

    // All complex logic via appendRaw (stackPtr == 0 here)
    appendRaw(
`VM_ACTOR_GET_POS ${posStruct}
VM_ACTOR_GET_ANIM_FRAME ${frameStruct}
VM_RPN
    .R_REF      ^/(${posStruct} + 1)/
    .R_REF_SET  ${savedX}
    .R_REF      ^/(${posStruct} + 2)/
    .R_REF_SET  ${savedY}
    .R_REF      ^/(${frameStruct} + 1)/
    .R_INT16    1
    .R_OPERATOR .ADD
    .R_REF_SET  ^/(${frameStruct} + 1)/
    .R_STOP
VM_IF_CONST .GTE, ^/(${frameStruct} + 1)/, ${totalFrames}, ${wrapLabel}$, 0
VM_JUMP ${doneFrameLabel}$
${wrapLabel}$:
VM_SET_CONST ^/(${frameStruct} + 1)/, 0
${doneFrameLabel}$:
VM_SET ${variableAlias}, ^/(${frameStruct} + 1)/
VM_SET_CONST ${counter}, 10
${exitLabel}$:
VM_RPN
    .R_REF      ^/(${posStruct} + 1)/
    .R_INT16    ${dxStep}
    .R_OPERATOR .ADD
    .R_REF_SET  ^/(${posStruct} + 1)/
    .R_STOP
VM_ACTOR_SET_POS ${posStruct}
VM_IDLE
VM_RPN
    .R_REF      ${counter}
    .R_INT16    1
    .R_OPERATOR .SUB
    .R_REF_SET  ${counter}
    .R_REF      ${counter}
    .R_STOP
VM_IF_CONST .GT, .ARG0, 0, ${exitLabel}$, 1
VM_ACTOR_ACTIVATE ${posStruct}
VM_ACTOR_SET_ANIM_FRAME ${frameStruct}
VM_ACTOR_SET_ANIM_TICK ${posStruct}, 255`
    );
    _addNL();
};

module.exports = { id, name, groups, fields, compile };

Key patterns demonstrated:

  • All _declareLocal and setActorId calls are stack-neutral, done before appendRaw
  • Struct field access via ^/(${posStruct} + 1)/ syntax
  • Labels defined with ${label}$:, referenced with ${label}$
  • VM_IF_CONST with pop count as last argument
  • VM_ACTOR_ACTIVATE after moving actor off-screen (streaming deactivation fix)
  • VM_ACTOR_SET_ANIM_TICK after setting frame to prevent auto-advance

Common Pitfalls

#PitfallSolution
1 Mixing tracked and untracked stack operations Use either _rpn().stop() OR appendRaw("VM_RPN ...") per section, never both
2 _stackPop does NOT emit VM_POP It only adjusts the tracker. In appendRaw, use VM_POP N directly
3 .LOCAL_* references wrong in appendRaw after tracked pushes Ensure stackPtr == 0 before entering appendRaw blocks
4 Labels in appendRaw conflict with tracked _label() Use getNextLabel() IDs with ${id}$: format inside appendRaw only
5 Animation frame overridden by engine auto-advance Always follow VM_ACTOR_SET_ANIM_FRAME with VM_ACTOR_SET_ANIM_TICK actorVar, 255
6 Actor disappears after being moved off-screen Call VM_ACTOR_ACTIVATE before moving unpinned actors back on-screen
7 VM_IF_CONST pop count not matching Last argument is how many VM stack values to pop. In raw assembly, this physically pops; in tracked mode, it adjusts stackPtr
8 _declareLocal treated as returning an object It returns a STRING. Use template literals ${localVar} to interpolate