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 String | Category |
|---|---|
"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
| Type | Default Value | Description | Extra Props |
|---|---|---|---|
number | 0 | Numeric input | min, 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 picker | types, defaultType |
select | "optionValue" | Dropdown menu | options: [["val","Label"],...] |
togglebuttons | "value" | Button group | options, allowNone |
checkbox | true / false | Boolean toggle | — |
textarea | "" | Text input | placeholder, singleLine, multiple |
soundEffect | "beep" | Sound effect picker | — |
avatar | "" | Portrait picker | toggleLabel |
events | — | Child event container | — |
group | — | Field container (visual grouping) | fields: [...] |
tabs | "tabKey" | Tabbed section | values: {key:"Label"} |
break | — | Visual 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}"
_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
| Arithmetic | Bitwise | Comparison | Logical |
|---|---|---|---|
.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
_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
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
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
| Command | Arguments | Description |
|---|---|---|
VM_ACTOR_SET_DIR | addr, direction | Set facing direction |
VM_ACTOR_SET_HIDDEN | addr, hidden | Show (0) or hide (1) actor |
VM_ACTOR_MOVE_CANCEL | addr | Cancel current movement |
VM_ACTOR_ACTIVATE | addr | Force-activate a deactivated actor |
VM_ACTOR_DEACTIVATE | addr | Remove actor from active list |
VM_ACTOR_SET_ANIM_TICK | addr, tick | Set animation speed (255 = paused) |
VM_ACTOR_GET_ANIM_FRAME | addr | Read current frame into struct[1] |
VM_ACTOR_SET_ANIM_FRAME | addr | Set frame from struct[1] |
VM_ACTOR_GET_POS | addr | Read position into struct[1,2] |
VM_ACTOR_SET_POS | addr | Apply 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
.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 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
_declareLocalandsetActorIdcalls are stack-neutral, done beforeappendRaw - Struct field access via
^/(${posStruct} + 1)/syntax - Labels defined with
${label}$:, referenced with${label}$ VM_IF_CONSTwith pop count as last argumentVM_ACTOR_ACTIVATEafter moving actor off-screen (streaming deactivation fix)VM_ACTOR_SET_ANIM_TICKafter setting frame to prevent auto-advance
Common Pitfalls
| # | Pitfall | Solution |
|---|---|---|
| 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 |