TL;DR

A custom node needs 3 things: a Schema (what the node looks like), a Factory (how to build it), and a Compiled Node (what it does each tick). Register all three in one line. That's it.

On This Page

Overview: The 3 Pieces

Every custom node is made of exactly 3 parts:

#WhatJava TypePurpose
1SchemaSpcNodeSchemaDeclares the node's type ID, ports, parameters, and execution phase. Think of it as the blueprint.
2Compiled NodeISpcCompiledNodeThe per-tick logic. Reads inputs, does work, writes outputs. One instance per node on the canvas.
3FactoryISpcNodeFactoryCreates compiled node instances. Called once during program compilation — not every tick.
Schema ports, params, phase SpcNodeSchema feeds Factory resolves addresses ISpcNodeFactory creates Compiled Node runs every tick ISpcCompiledNode

Step 1 — Define Your Schema

A SpcNodeSchema tells the editor everything about your node: what it's called, what data it accepts, what it outputs, and when it runs.

public static final SpcNodeSchema MY_SENSOR = new SpcNodeSchema(
    "mymod:temperature_sensor",          // unique type ID
    SpcExecutionPhase.INPUT_READ,         // when it runs (see below)
    List.of(                                // ports:
        new SpcPortSpec("Q",  OUTPUT, INTEGER, true)   // one integer output
    ),
    List.of(                                // parameters:
        new SpcParameterSpec("radius", SpcParameterValueType.INTEGER, false)
    ),
    false                                  // requires display? no
);
💡 Naming your type ID
Always namespace your type ID with your mod ID: "mymod:temperature_sensor". This prevents collisions if two addons add a node with the same name.

What each field means

FieldTypeWhat it does
typeIdStringUnique name for this node type. Use "yourmod:name" format.
phaseSpcExecutionPhaseWhen this node runs in the tick cycle. See Choosing a Phase.
portsList<SpcPortSpec>Input and output ports. Each has an ID, direction, signal type, and whether it's required.
parametersList<SpcParameterSpec>User-configurable values set in the properties dialog (not signals).
requiresDisplaybooleanSet true if your node calls display methods (message text, etc.).

Step 2 — Implement the Compiled Node

This is the heart of your node — the code that runs every single tick. It reads inputs from the signal bus, does its work, and writes outputs back.

public record TemperatureSensorNode(
    UUID nodeId,
    SpcSignalAddress outputAddr,
    int radius
) implements ISpcCompiledNode {

    @Override
    public String typeId()  { return "mymod:temperature_sensor"; }

    @Override
    public SpcExecutionPhase phase() { return SpcExecutionPhase.INPUT_READ; }

    @Override
    public void execute(ISpcExecutionContext ctx, ISpcRuntimeState state) {
        // Read temperature from the Minecraft world
        int temp = ctx.readAnalogSensor(0);

        // Write result to the output port
        state.setSignal(outputAddr, SpcSignalValue.integer(temp));
    }
}
⚠ No instance state!
Your compiled node must not store mutable fields. All state that survives between ticks must go through ISpcRuntimeState slot methods. See State & Persistence.

Key rules for execute()

  1. Read inputs from state.readDigitalSignal(), readIntegerSignal(), etc.
  2. Do your work — math, comparisons, world queries via ctx.
  3. Write outputs via state.setSignal(address, value).
  4. Never store results in instance fields — use state.setPending*Slot() instead.

Step 3 — Write the Factory

The factory creates compiled node instances when a program is compiled. It receives a NodeCompilationContext with resolved addresses and parameters.

public class TemperatureSensorFactory implements ISpcNodeFactory {

    @Override
    public ISpcCompiledNode create(NodeCompilationContext ctx) {
        // Get the output address for port "Q"
        SpcSignalAddress output = ctx.outputAddress("Q");

        // Read the "radius" parameter, defaulting to 16 if not set
        int radius = ctx.intParameter("radius", 16);

        // Create and return the compiled node
        return new TemperatureSensorNode(ctx.nodeId(), output, radius);
    }
}

What NodeCompilationContext gives you

MethodReturnsUse for
nodeId()UUIDUnique ID of this node instance on the canvas
inputAddress("portId")SpcSignalAddress or nullWhere to read an input signal (null = not connected)
outputAddress("portId")SpcSignalAddressWhere to write an output signal
isInverted("portId")booleanWhether the user added a negation bubble
intParameter("id", default)intRead a parameter as integer
doubleParameter("id", default)doubleRead a parameter as double
stringParameter("id", default)StringRead a parameter as text
booleanParameter("id", default)booleanRead a parameter as boolean
numberedInputAddresses("I", 4)List<SpcSignalAddress>Collect I1, I2, I3, I4 addresses in order

Step 4 — Register Everything

One line in your @Mod constructor:

@Mod("mymod")
public class MyMod {

    public static final SpcNodeCategory MY_CATEGORY =
        new SpcNodeCategory("mymod:sensors", "My Sensors");

    public MyMod(IEventBus modEventBus) {
        // That's it — one line per node type:
        SpcNodeRegistry.register(MY_SENSOR, new TemperatureSensorFactory(), MY_CATEGORY);
    }
}
💡 When does registration happen?
Register in your @Mod constructor — before the registry freezes. After freeze, any register() call throws IllegalStateException.

Full Example: Temperature Sensor

A sensor that reads the biome temperature at the LOGO machine's position and outputs it as an integer (scaled ×100 so 1.2 becomes 120).

What this produces in-game
A node in the editor palette under "My Sensors" with one output port Q. When the program runs, it outputs the biome temperature as an integer every tick.
// ── Schema ─────────────────────────────────────────────
public static final SpcNodeSchema TEMP_SENSOR = new SpcNodeSchema(
    "mymod:temp_sensor",
    SpcExecutionPhase.INPUT_READ,
    List.of(
        new SpcPortSpec("Q", OUTPUT, INTEGER, true)
    ),
    List.of(),       // no parameters
    false
);

// ── Compiled Node ──────────────────────────────────────
public record TempSensorNode(UUID nodeId, SpcSignalAddress out)
        implements ISpcCompiledNode {

    @Override public String typeId() { return "mymod:temp_sensor"; }
    @Override public SpcExecutionPhase phase() { return INPUT_READ; }

    @Override
    public void execute(ISpcExecutionContext ctx, ISpcRuntimeState state) {
        // readAnalogSensor(0) reads the first analog input channel
        int rawTemp = ctx.readAnalogSensor(0);
        state.setSignal(out, SpcSignalValue.integer(rawTemp));
    }
}

// ── Factory ────────────────────────────────────────────
public class TempSensorFactory implements ISpcNodeFactory {
    @Override
    public ISpcCompiledNode create(NodeCompilationContext ctx) {
        return new TempSensorNode(ctx.nodeId(), ctx.outputAddress("Q"));
    }
}

// ── Registration (in @Mod constructor) ─────────────────
SpcNodeRegistry.register(TEMP_SENSOR, new TempSensorFactory(), MY_CATEGORY);

Full Example: Weighted AND Gate

A logic gate that outputs HIGH only when at least N of its 4 inputs are HIGH. The threshold N is a user-configurable parameter.

What this produces in-game
A node with 4 digital inputs (I1–I4), one digital output (Q), and a "threshold" parameter. If threshold is 3 and inputs I1, I2, I3 are on, the output is on. If only I1 and I2 are on, the output stays off.
// ── Schema ─────────────────────────────────────────────
public static final SpcNodeSchema WEIGHTED_AND = new SpcNodeSchema(
    "mymod:weighted_and",
    SpcExecutionPhase.LOGIC_EVALUATION,
    List.of(
        new SpcPortSpec("I1", INPUT,  DIGITAL, true),
        new SpcPortSpec("I2", INPUT,  DIGITAL, true),
        new SpcPortSpec("I3", INPUT,  DIGITAL, false),  // optional
        new SpcPortSpec("I4", INPUT,  DIGITAL, false),  // optional
        new SpcPortSpec("Q",  OUTPUT, DIGITAL, true)
    ),
    List.of(
        new SpcParameterSpec("threshold", SpcParameterValueType.INTEGER, false)
    ),
    false
);

// ── Compiled Node ──────────────────────────────────────
public record WeightedAndNode(
    UUID nodeId,
    List<SpcSignalAddress> inputs,   // I1..I4 (some may be null)
    SpcSignalAddress output,
    int threshold
) implements ISpcCompiledNode {

    @Override public String typeId() { return "mymod:weighted_and"; }
    @Override public SpcExecutionPhase phase() { return LOGIC_EVALUATION; }

    @Override
    public void execute(ISpcExecutionContext ctx, ISpcRuntimeState state) {
        int count = 0;
        for (SpcSignalAddress addr : inputs) {
            // Null means the port is not connected — skip it
            if (addr != null && state.readDigitalSignal(addr)) {
                count++;
            }
        }
        boolean result = count >= threshold;
        state.setSignal(output, SpcSignalValue.digital(result));
    }
}

// ── Factory ────────────────────────────────────────────
public class WeightedAndFactory implements ISpcNodeFactory {
    @Override
    public ISpcCompiledNode create(NodeCompilationContext ctx) {
        return new WeightedAndNode(
            ctx.nodeId(),
            ctx.numberedInputAddresses("I", 4),   // collects I1, I2, I3, I4
            ctx.outputAddress("Q"),
            ctx.intParameter("threshold", 2)       // default: 2 of 4
        );
    }
}

Full Example: Signal Converter

Converts a decimal (double) signal into an integer by rounding, scaling, or truncating. The mode is chosen via a text parameter.

What this produces in-game
A node with one decimal input, one integer output, and a "mode" parameter that accepts "round", "floor", or "ceil". Useful for converting analog sensor values to discrete values for comparisons or displays.
// ── Schema ─────────────────────────────────────────────
public static final SpcNodeSchema DECIMAL_TO_INT = new SpcNodeSchema(
    "mymod:decimal_to_int",
    SpcExecutionPhase.LOGIC_EVALUATION,
    List.of(
        new SpcPortSpec("I", INPUT,  DECIMAL, true),
        new SpcPortSpec("Q", OUTPUT, INTEGER, true)
    ),
    List.of(
        new SpcParameterSpec("mode", SpcParameterValueType.TEXT, false)
    ),
    false
);

// ── Compiled Node ──────────────────────────────────────
public record DecimalToIntNode(
    UUID nodeId,
    SpcSignalAddress input,
    SpcSignalAddress output,
    String mode
) implements ISpcCompiledNode {

    @Override public String typeId() { return "mymod:decimal_to_int"; }
    @Override public SpcExecutionPhase phase() { return LOGIC_EVALUATION; }

    @Override
    public void execute(ISpcExecutionContext ctx, ISpcRuntimeState state) {
        double raw = state.readDecimalSignal(input);

        int result = switch (mode) {
            case "floor" -> (int) Math.floor(raw);
            case "ceil"  -> (int) Math.ceil(raw);
            default      -> (int) Math.round(raw);  // "round" or unset
        };

        state.setSignal(output, SpcSignalValue.integer(result));
    }
}

// ── Factory ────────────────────────────────────────────
public class DecimalToIntFactory implements ISpcNodeFactory {
    @Override
    public ISpcCompiledNode create(NodeCompilationContext ctx) {
        return new DecimalToIntNode(
            ctx.nodeId(),
            ctx.inputAddress("I"),
            ctx.outputAddress("Q"),
            ctx.stringParameter("mode", "round")
        );
    }
}

Full Example: Redstone Actuator

An output node that writes a digital signal to a physical output channel. When the input goes HIGH, channel 1 activates.

What this produces in-game
A node with one digital input and a "channel" parameter. When input is HIGH, the specified output channel on the LOGO machine turns on — driving redstone, pistons, or any connected device.
// ── Schema ─────────────────────────────────────────────
public static final SpcNodeSchema REDSTONE_OUT = new SpcNodeSchema(
    "mymod:redstone_output",
    SpcExecutionPhase.OUTPUT_APPLY,       // outputs run last!
    List.of(
        new SpcPortSpec("I", INPUT, DIGITAL, true)
    ),
    List.of(
        new SpcParameterSpec("channel", SpcParameterValueType.INTEGER, true)
    ),
    false
);

// ── Compiled Node ──────────────────────────────────────
public record RedstoneOutputNode(
    UUID nodeId,
    SpcSignalAddress input,
    int channel
) implements ISpcCompiledNode {

    @Override public String typeId() { return "mymod:redstone_output"; }
    @Override public SpcExecutionPhase phase() { return OUTPUT_APPLY; }

    @Override
    public void execute(ISpcExecutionContext ctx, ISpcRuntimeState state) {
        boolean active = state.readDigitalSignal(input);
        state.setDigitalOutput(channel, active);
    }
}

// ── Factory ────────────────────────────────────────────
public class RedstoneOutputFactory implements ISpcNodeFactory {
    @Override
    public ISpcCompiledNode create(NodeCompilationContext ctx) {
        return new RedstoneOutputNode(
            ctx.nodeId(),
            ctx.inputAddress("I"),
            ctx.intParameter("channel", 1)
        );
    }
}

Ports In Depth

Each port is defined by a SpcPortSpec:

new SpcPortSpec(portId, direction, signalType, required)
new SpcPortSpec(portId, direction, signalType, required, signalDomain)

Port directions

DirectionMeaningIn factory
INPUTReceives a signal from another nodectx.inputAddress("portId") — may be null if not connected
OUTPUTSends a signal to downstream nodesctx.outputAddress("portId") — always non-null

Signal types for ports

TypeJava equivalentExample use
DIGITALbooleanOn/off switches, logic gates
INTEGERintCounts, redstone levels, item amounts
DECIMALdoubleTemperatures, rotation speeds, percentages
TEXTStringDisplay messages, item IDs, labels
ITEMString + intItem type + stack count
ITEM_IDStringJust the item registry name

Required vs optional ports

Signal domains

Domains restrict which ports can connect. Two ports can only be wired together if their domains are compatible. A null domain (the default) connects to anything.

// Only connects to other "rotation" ports:
new SpcPortSpec("speed", INPUT, DECIMAL, true, "rotation")

// Connects to any decimal port (universal):
new SpcPortSpec("value", INPUT, DECIMAL, true)

Parameters In Depth

Parameters are constant values set by the player in the node properties dialog. Unlike signals, they don't change every tick — they stay the same for the entire program run.

new SpcParameterSpec(parameterId, valueType, required)

Parameter value types

TypeFactory methodExample
BOOLEANctx.booleanParameter("id", false)Enable/disable a feature
INTEGERctx.intParameter("id", 10)Threshold, channel number, delay ticks
DECIMALctx.doubleParameter("id", 1.0)Gain factor, scaling ratio
TEXTctx.stringParameter("id", "default")Mode selection, display format
💡 Always provide a sensible default
If a parameter isn't required, always pass a meaningful default value in the factory. Players who don't set a parameter should still get working behavior.

Choosing an Execution Phase

The LOGO runtime runs nodes in a strict order every tick. Pick the phase that matches what your node does:

PhaseRunsUse when your node…Examples
INPUT_READ1st Reads from the physical world or external data Sensors, input blocks, time readers
LOGIC_EVALUATION2nd Combines, transforms, or compares signals AND/OR gates, math, comparators, converters
STATE_UPDATE3rd Needs to remember state across ticks Timers, counters, shift registers, latches
OUTPUT_APPLY4th Writes to the physical world Output blocks, displays, redstone, effects
INPUT_READ LOGIC_EVALUATION STATE_UPDATE OUTPUT_APPLY
⚠ Wrong phase = wrong results
If a sensor runs in OUTPUT_APPLY, its data arrives one tick late for logic nodes. If an output runs in INPUT_READ, it writes before logic has computed. Always match the phase to the node's role.

Custom Node Colors

By default, addon nodes use the same brass color scheme as built-in LOGO! nodes. You can override these colors so your nodes stand out with a distinctive look.

Quick: From a single accent color

The easiest approach — provide one color and let the API derive fill, stroke, and text colors automatically:

import com.hypernova.spc.api.style.SpcNodeColors;

// Derive all colors from a blue accent
SpcNodeColors blueTheme = SpcNodeColors.fromAccent(0xFF4488CC);

// Register with custom colors:
SpcNodeRegistry.register(MY_SENSOR, new TempSensorFactory(), MY_CATEGORY, blueTheme);

Custom: Fill and stroke

Specify fill and stroke explicitly; text colors are auto-derived for contrast:

SpcNodeColors myColors = SpcNodeColors.of(
    0xFF4488CC,  // fill (body background)
    0xFF1A3355   // stroke (border)
);

Full control: All 5 colors

For maximum control, specify every color individually:

SpcNodeColors custom = new SpcNodeColors(
    0xFF4488CC,  // fill — body background color
    0xFF1A3355,  // stroke — border outline color
    0xFFFFFFFF,  // text — symbol text inside the body
    0xFFCCDDEE,  // labelText — label text above the node
    0xFF66AAEE   // activeFill — fill when active during simulation
);

Color fields reference

FieldDefault (brass)Description
fill0xFFF1D789Background fill of the node body
stroke0xFF3A2C12Outline/border color
text0xFF2A2010Symbol text rendered inside the node
labelText0xFFF3E8C4Label text rendered above the node
activeFill0xFFF7E7A7Fill color when the node is active in simulation
💡 Tip: Selection and active strokes
The selected (blue) and active (green) stroke colors are global and cannot be overridden per-node. This keeps the selection state easily recognizable across all node types.

Quick Checklist

Before shipping your addon node, verify:

  1. ✅ Type ID is namespaced: "yourmod:node_name"
  2. ✅ Execution phase matches the node's role
  3. ✅ All required ports are marked required: true
  4. ✅ Optional inputs are null-checked in execute()
  5. ✅ Parameters have sensible defaults in the factory
  6. ✅ No mutable instance fields on the compiled node
  7. ✅ Registration happens in the @Mod constructor
  8. ✅ State that survives ticks uses setPending*Slot(), not fields