Your First Addon Beginner
A hands-on, step-by-step tutorial. We'll build a Daylight Sensor node that outputs whether it's daytime and the current world tick time. By the end you'll have a working addon in about 80 lines of code.
3 Java files (Schema + CompiledNode + @Mod class) โ a Daylight Sensor that appears in the LOGO editor palette. Outputs a digital "is daytime?" signal and an integer "world time ticks" value. Copy-paste ready โ each step explains what it does and why.
On this page
What We're Building
Our Daylight Sensor node will:
- Run in the
INPUT_READphase (it reads world data) - Have no inputs โ it reads directly from the execution context
- Have two outputs:
Q(digital) โtruewhen it's daytime,falseat nightAQ(integer) โ the current world time in ticks (0โ24000)
- Have one parameter:
offset(integer) โ an optional tick offset to shift the day/night boundary - Appear in a custom palette category called "My Addon: Sensors"
Step 1 Define the Schema
The schema declares the structure of our node: its unique ID, when it executes, what ports it has, and what parameters the player can configure.
package com.example.myaddon.node;
import com.hypernova.spc.api.execution.SpcExecutionPhase;
import com.hypernova.spc.api.node.SpcNodeSchema;
import com.hypernova.spc.api.node.SpcParameterSpec;
import com.hypernova.spc.api.node.SpcParameterValueType;
import com.hypernova.spc.api.node.SpcPortDirection;
import com.hypernova.spc.api.node.SpcPortSpec;
import com.hypernova.spc.api.signal.SpcSignalType;
import java.util.List;
public final class DaylightSensorSchema {
public static final SpcNodeSchema SCHEMA = new SpcNodeSchema(
"myaddon:daylight_sensor", // 1. Unique type ID (namespaced!)
SpcExecutionPhase.INPUT_READ, // 2. Runs first โ it reads world data
List.of( // 3. Ports: no inputs, two outputs
new SpcPortSpec(
"Q", // port ID
SpcPortDirection.OUTPUT, // direction
SpcSignalType.DIGITAL, // data type
false // not required to connect
),
new SpcPortSpec(
"AQ", // port ID
SpcPortDirection.OUTPUT, // direction
SpcSignalType.INTEGER, // data type
false // not required
)
),
List.of( // 4. Parameters
new SpcParameterSpec(
"offset", // parameter ID
SpcParameterValueType.INTEGER, // data type
false // not required (optional)
)
),
false // 5. Does not require a display block
);
private DaylightSensorSchema() {} // prevent instantiation
}
"myaddon:daylight_sensor" instead of just "daylight_sensor"
prevents collisions if another addon creates a node with the same name.
Always use "yourmodid:node_name" format.
Step 2 Implement the Compiled Node
This class is the executable logic. It implements ISpcCompiledNode
and runs once per tick during the INPUT_READ phase.
package com.example.myaddon.node;
import com.hypernova.spc.api.execution.ISpcExecutionContext;
import com.hypernova.spc.api.execution.SpcExecutionPhase;
import com.hypernova.spc.api.node.ISpcCompiledNode;
import com.hypernova.spc.api.signal.SpcSignalAddress;
import com.hypernova.spc.api.signal.SpcSignalValue;
import com.hypernova.spc.api.state.ISpcRuntimeState;
import java.util.UUID;
public class DaylightSensorNode implements ISpcCompiledNode {
// โโ Immutable fields set during compilation โโโโโโโโโโโ
private final UUID nodeId;
private final SpcSignalAddress outputQ; // digital output
private final SpcSignalAddress outputAQ; // integer output
private final int offset; // user parameter
public DaylightSensorNode(UUID nodeId, SpcSignalAddress outputQ,
SpcSignalAddress outputAQ, int offset) {
this.nodeId = nodeId;
this.outputQ = outputQ;
this.outputAQ = outputAQ;
this.offset = offset;
}
// โโ ISpcCompiledNode implementation โโโโโโโโโโโโโโโโโโโโ
@Override
public UUID nodeId() {
return nodeId;
}
@Override
public String typeId() {
return "myaddon:daylight_sensor";
}
@Override
public SpcExecutionPhase phase() {
return SpcExecutionPhase.INPUT_READ;
}
@Override
public void execute(ISpcExecutionContext context, ISpcRuntimeState state) {
// 1. Read the world time from the execution context
long rawTime = context.worldTimeTicks();
// 2. Apply the user-configured offset
long adjustedTime = Math.floorMod(rawTime + offset, 24000L);
// 3. Determine if it's daytime (ticks 0โ11999 are day)
boolean isDaytime = adjustedTime < 12000L;
// 4. Write outputs to the signal bus
state.setSignal(outputQ, SpcSignalValue.digital(isDaytime));
state.setSignal(outputAQ, SpcSignalValue.integer((int) adjustedTime));
}
}
nodeId,
outputQ, outputAQ, and offset are all final
and set during compilation. If you need state that persists across ticks, use
ISpcRuntimeState persistent slots (see Runtime State).
What happens in execute()?
- Read โ we call
context.worldTimeTicks()to get the current world time - Compute โ we apply the offset and determine daytime/night
- Write โ we call
state.setSignal()to put our results on the signal bus
Downstream nodes connected to our Q and AQ ports will read these values in later phases.
Step 3 Create the Factory
The factory bridges the schema (design-time) and the compiled node (runtime).
It receives a NodeCompilationContext with all resolved data and constructs
a DaylightSensorNode instance.
package com.example.myaddon.node;
import com.hypernova.spc.api.node.ISpcCompiledNode;
import com.hypernova.spc.api.node.ISpcNodeFactory;
public class DaylightSensorFactory implements ISpcNodeFactory {
@Override
public ISpcCompiledNode create(NodeCompilationContext ctx) {
return new DaylightSensorNode(
ctx.nodeId(), // unique ID for this instance
ctx.outputAddress("Q"), // signal address for digital output
ctx.outputAddress("AQ"), // signal address for integer output
ctx.intParameter("offset", 0) // user's offset, default 0
);
}
}
// Instead of new DaylightSensorFactory(), you can write:
ISpcNodeFactory factory = ctx -> new DaylightSensorNode(
ctx.nodeId(),
ctx.outputAddress("Q"),
ctx.outputAddress("AQ"),
ctx.intParameter("offset", 0)
);
Step 4 Register Everything
Now we put it all together in the @Mod constructor.
We register the schema, factory, and a palette category.
package com.example.myaddon;
import com.example.myaddon.node.DaylightSensorFactory;
import com.example.myaddon.node.DaylightSensorSchema;
import com.hypernova.spc.api.category.SpcNodeCategory;
import com.hypernova.spc.api.registry.SpcNodeRegistry;
import net.neoforged.bus.api.IEventBus;
import net.neoforged.fml.common.Mod;
import org.slf4j.Logger;
import com.mojang.logging.LogUtils;
@Mod("my_spc_addon")
public class MyAddon {
private static final Logger LOGGER = LogUtils.getLogger();
// Define the palette category our nodes will appear in
private static final SpcNodeCategory SENSORS = new SpcNodeCategory(
"myaddon:sensors", // unique category ID
"My Addon: Sensors", // display name in the palette
5000 // sort order (built-in uses 0-1000)
);
public MyAddon(IEventBus modEventBus) {
LOGGER.info("My SPC Addon loading โ registering custom nodes");
// Register the daylight sensor node
SpcNodeRegistry.register(
DaylightSensorSchema.SCHEMA, // schema (what the node IS)
new DaylightSensorFactory(), // factory (how to compile it)
SENSORS // category (where it appears)
);
LOGGER.info("My SPC Addon loaded โ {} nodes registered", 1);
}
}
Testing Your Addon
- Build your project:
./gradlew build - Place the SPC mod jar in
run/mods/ - Launch:
./gradlew runClient - In Minecraft:
- Build a LOGO multiblock machine (Processing Unit + I/O blocks)
- Right-click the Programming Block to open the editor
- Look for "My Addon: Sensors" in the palette sidebar
- Drag the Daylight Sensor onto the canvas
- Wire its
Qoutput to a digital output block - Click "Run" โ the output should toggle with daytime!
Troubleshooting
| Problem | Likely cause | Fix |
|---|---|---|
| Node doesn't appear in palette | Registration happened too late | Register in @Mod constructor, not in event handlers |
IllegalStateException: frozen |
Registration after FMLCommonSetupEvent |
Move SpcNodeRegistry.register() to constructor |
IllegalArgumentException: Duplicate |
Another mod uses the same typeId |
Use a unique namespace: "myaddon:node_name" |
| Node appears but doesn't work | Wrong execution phase or null output address | Verify phase and check outputAddress() port IDs match schema |