Creating Custom Nodes Beginner
Everything you need to know to add your own function block nodes to the LOGO editor. This page covers every piece — schema, factory, compiled node, and registration — with complete, copy-paste-ready examples.
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.
- Overview: The 3 Pieces
- Step 1 — Define Your Schema
- Step 2 — Implement the Compiled Node
- Step 3 — Write the Factory
- Step 4 — Register Everything
- Full Example: Temperature Sensor
- Full Example: Weighted AND Gate
- Full Example: Signal Converter
- Full Example: Redstone Actuator
- Ports In Depth
- Parameters In Depth
- Choosing an Execution Phase
- Custom Node Colors
- Quick Checklist
Overview: The 3 Pieces
Every custom node is made of exactly 3 parts:
| # | What | Java Type | Purpose |
|---|---|---|---|
| 1 | Schema | SpcNodeSchema | Declares the node's type ID, ports, parameters, and execution phase. Think of it as the blueprint. |
| 2 | Compiled Node | ISpcCompiledNode | The per-tick logic. Reads inputs, does work, writes outputs. One instance per node on the canvas. |
| 3 | Factory | ISpcNodeFactory | Creates compiled node instances. Called once during program compilation — not every tick. |
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
);
"mymod:temperature_sensor".
This prevents collisions if two addons add a node with the same name.
What each field means
| Field | Type | What it does |
|---|---|---|
typeId | String | Unique name for this node type. Use "yourmod:name" format. |
phase | SpcExecutionPhase | When this node runs in the tick cycle. See Choosing a Phase. |
ports | List<SpcPortSpec> | Input and output ports. Each has an ID, direction, signal type, and whether it's required. |
parameters | List<SpcParameterSpec> | User-configurable values set in the properties dialog (not signals). |
requiresDisplay | boolean | Set 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));
}
}
ISpcRuntimeState slot methods. See
State & Persistence.
Key rules for execute()
- Read inputs from
state.readDigitalSignal(),readIntegerSignal(), etc. - Do your work — math, comparisons, world queries via
ctx. - Write outputs via
state.setSignal(address, value). - 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
| Method | Returns | Use for |
|---|---|---|
nodeId() | UUID | Unique ID of this node instance on the canvas |
inputAddress("portId") | SpcSignalAddress or null | Where to read an input signal (null = not connected) |
outputAddress("portId") | SpcSignalAddress | Where to write an output signal |
isInverted("portId") | boolean | Whether the user added a negation bubble |
intParameter("id", default) | int | Read a parameter as integer |
doubleParameter("id", default) | double | Read a parameter as double |
stringParameter("id", default) | String | Read a parameter as text |
booleanParameter("id", default) | boolean | Read 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);
}
}
@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).
// ── 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.
// ── 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.
// ── 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.
// ── 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
| Direction | Meaning | In factory |
|---|---|---|
INPUT | Receives a signal from another node | ctx.inputAddress("portId") — may be null if not connected |
OUTPUT | Sends a signal to downstream nodes | ctx.outputAddress("portId") — always non-null |
Signal types for ports
| Type | Java equivalent | Example use |
|---|---|---|
DIGITAL | boolean | On/off switches, logic gates |
INTEGER | int | Counts, redstone levels, item amounts |
DECIMAL | double | Temperatures, rotation speeds, percentages |
TEXT | String | Display messages, item IDs, labels |
ITEM | String + int | Item type + stack count |
ITEM_ID | String | Just the item registry name |
Required vs optional ports
- Required (
true): The program won't compile unless this port is connected. - Optional (
false): The port can be left unconnected.inputAddress()returnsnull. Always null-check optional inputs!
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
| Type | Factory method | Example |
|---|---|---|
BOOLEAN | ctx.booleanParameter("id", false) | Enable/disable a feature |
INTEGER | ctx.intParameter("id", 10) | Threshold, channel number, delay ticks |
DECIMAL | ctx.doubleParameter("id", 1.0) | Gain factor, scaling ratio |
TEXT | ctx.stringParameter("id", "default") | Mode selection, display format |
Choosing an Execution Phase
The LOGO runtime runs nodes in a strict order every tick. Pick the phase that matches what your node does:
| Phase | Runs | Use when your node… | Examples |
|---|---|---|---|
INPUT_READ | 1st | Reads from the physical world or external data | Sensors, input blocks, time readers |
LOGIC_EVALUATION | 2nd | Combines, transforms, or compares signals | AND/OR gates, math, comparators, converters |
STATE_UPDATE | 3rd | Needs to remember state across ticks | Timers, counters, shift registers, latches |
OUTPUT_APPLY | 4th | Writes to the physical world | Output blocks, displays, redstone, effects |
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
| Field | Default (brass) | Description |
|---|---|---|
fill | 0xFFF1D789 | Background fill of the node body |
stroke | 0xFF3A2C12 | Outline/border color |
text | 0xFF2A2010 | Symbol text rendered inside the node |
labelText | 0xFFF3E8C4 | Label text rendered above the node |
activeFill | 0xFFF7E7A7 | Fill color when the node is active in simulation |
Quick Checklist
Before shipping your addon node, verify:
- ✅ Type ID is namespaced:
"yourmod:node_name" - ✅ Execution phase matches the node's role
- ✅ All required ports are marked
required: true - ✅ Optional inputs are null-checked in
execute() - ✅ Parameters have sensible defaults in the factory
- ✅ No mutable instance fields on the compiled node
- ✅ Registration happens in the
@Modconstructor - ✅ State that survives ticks uses
setPending*Slot(), not fields