State & Persistence Intermediate
How to build nodes that remember things between ticks — timers, counters, toggle switches, running averages, and more. Everything about persistent slots, double buffering, and the rules for stateful nodes.
Use state.get*Slot(nodeId, "name") to read saved values and
state.setPending*Slot(nodeId, "name", value) to write them.
Never store state in instance fields. Writes are buffered and applied after the current phase.
Why Persistent Slots?
Simple nodes (AND gate, comparator) are stateless — they compute an output from the current inputs and nothing else. But many useful nodes need to remember data between ticks:
- Counters — how many times has this input gone HIGH?
- Timers — how many ticks has this input been HIGH?
- Toggle switches — is this currently on or off?
- Moving averages — what's the average of the last N readings?
- Edge detectors — was the input different last tick?
You cannot store this data in Java instance fields because compiled nodes
must be stateless. Instead, use persistent slots on ISpcRuntimeState.
The runtime saves and restores slot values automatically — even across world saves.
public class BadCounter implements ISpcCompiledNode {
private int count = 0; // ❌ WRONG — lost on save/load!
public void execute(...) {
count++; // ❌ This won't survive a world reload
}
}
Available Slot Types
The runtime provides 5 slot types. Each slot is identified by a (nodeId, slotName)
pair, so every node instance has its own private storage.
| Type | Read method | Write method | Default | Use for |
|---|---|---|---|---|
| boolean | getBooleanSlot(nodeId, "name") |
setPendingBooleanSlot(nodeId, "name", value) |
false |
Toggles, flags, edge state |
| int | getIntegerSlot(nodeId, "name") |
setPendingIntegerSlot(nodeId, "name", value) |
0 |
Counters, tick counts, indices |
| long | getLongSlot(nodeId, "name") |
setPendingLongSlot(nodeId, "name", value) |
0L |
Timestamps, large counters |
| double | getDoubleSlot(nodeId, "name") |
setPendingDoubleSlot(nodeId, "name", value) |
0.0 |
Accumulations, averages, fractional state |
| String | getTextSlot(nodeId, "name") |
setPendingTextSlot(nodeId, "name", value) |
"" |
Text history, cached lookups |
"count",
"prev_input", "accumulator". They're scoped to your node's UUID,
so they won't collide with other nodes.
Double Buffering Explained
The SPC runtime uses double buffering for all slot writes. This means:
- When you call
setPending*Slot(), the new value is stored in a pending buffer. - The current value (from
get*Slot()) is unchanged during this tick. - After all nodes in the current phase finish executing, pending values are committed.
- On the next phase (or next tick),
get*Slot()returns the new value.
Why does this matter?
- All nodes in the same phase see the same slot values, regardless of execution order.
- You can't read-your-own-write within the same phase —
setPendingthengetreturns the old value. - This prevents subtle order-dependent bugs where Node A's write affects Node B's read within one tick.
setPending on tick 5 → get returns
new value on tick 6. For almost all use-cases, this is the behavior you want.
Example: Tick Counter
Counts how many ticks the input has been HIGH. Resets when input goes LOW. Outputs the current count as an integer.
public record TickCounterNode(
UUID nodeId,
SpcSignalAddress input,
SpcSignalAddress output
) implements ISpcCompiledNode {
@Override public String typeId() { return "mymod:tick_counter"; }
@Override public SpcExecutionPhase phase() { return STATE_UPDATE; }
@Override
public void execute(ISpcExecutionContext ctx, ISpcRuntimeState state) {
boolean active = state.readDigitalSignal(input);
if (active) {
// Read current count, increment, save for next tick
int count = state.getIntegerSlot(nodeId, "count");
state.setPendingIntegerSlot(nodeId, "count", count + 1);
state.setSignal(output, SpcSignalValue.integer(count + 1));
} else {
// Input is LOW — reset counter
state.setPendingIntegerSlot(nodeId, "count", 0);
state.setSignal(output, SpcSignalValue.integer(0));
}
}
}
Example: On-Delay Timer
Output goes HIGH only after the input has been HIGH for at least N ticks. If the input drops LOW before the threshold, the timer resets.
public record OnDelayTimerNode(
UUID nodeId,
SpcSignalAddress input,
SpcSignalAddress output,
int delayTicks
) implements ISpcCompiledNode {
@Override public String typeId() { return "mymod:on_delay"; }
@Override public SpcExecutionPhase phase() { return STATE_UPDATE; }
@Override
public void execute(ISpcExecutionContext ctx, ISpcRuntimeState state) {
boolean active = state.readDigitalSignal(input);
int elapsed = state.getIntegerSlot(nodeId, "elapsed");
if (active) {
elapsed = Math.min(elapsed + 1, delayTicks);
} else {
elapsed = 0; // reset when input drops
}
state.setPendingIntegerSlot(nodeId, "elapsed", elapsed);
boolean triggered = elapsed >= delayTicks;
state.setSignal(output, SpcSignalValue.digital(triggered));
}
}
Example: Toggle Switch (Flip-Flop)
Each time the input transitions from LOW → HIGH, the output toggles. Uses a boolean slot to remember the current state and another to detect the edge.
public record ToggleNode(
UUID nodeId,
SpcSignalAddress input,
SpcSignalAddress output
) implements ISpcCompiledNode {
@Override public String typeId() { return "mymod:toggle"; }
@Override public SpcExecutionPhase phase() { return STATE_UPDATE; }
@Override
public void execute(ISpcExecutionContext ctx, ISpcRuntimeState state) {
boolean currentInput = state.readDigitalSignal(input);
boolean prevInput = state.getBooleanSlot(nodeId, "prev");
boolean outputState = state.getBooleanSlot(nodeId, "state");
// Detect rising edge: was LOW, now HIGH
if (currentInput && !prevInput) {
outputState = !outputState; // toggle!
}
// Save state for next tick
state.setPendingBooleanSlot(nodeId, "prev", currentInput);
state.setPendingBooleanSlot(nodeId, "state", outputState);
state.setSignal(output, SpcSignalValue.digital(outputState));
}
}
Example: Running Average
Computes a smoothed moving average of a decimal input using the double slot type. Useful for smoothing noisy sensor data.
public record RunningAverageNode(
UUID nodeId,
SpcSignalAddress input,
SpcSignalAddress output,
double alpha // smoothing factor 0.0–1.0
) implements ISpcCompiledNode {
@Override public String typeId() { return "mymod:running_avg"; }
@Override public SpcExecutionPhase phase() { return STATE_UPDATE; }
@Override
public void execute(ISpcExecutionContext ctx, ISpcRuntimeState state) {
double current = state.readDecimalSignal(input);
double avg = state.getDoubleSlot(nodeId, "avg");
// Exponential moving average: new = alpha*current + (1-alpha)*old
avg = alpha * current + (1.0 - alpha) * avg;
state.setPendingDoubleSlot(nodeId, "avg", avg);
state.setSignal(output, SpcSignalValue.decimal(avg));
}
}
Example: Peak Detector
Tracks the highest value seen on an integer input. A reset input clears the peak.
public record PeakDetectorNode(
UUID nodeId,
SpcSignalAddress valueIn,
SpcSignalAddress resetIn, // nullable (optional port)
SpcSignalAddress peakOut
) implements ISpcCompiledNode {
@Override public String typeId() { return "mymod:peak_detector"; }
@Override public SpcExecutionPhase phase() { return STATE_UPDATE; }
@Override
public void execute(ISpcExecutionContext ctx, ISpcRuntimeState state) {
// Check if reset is connected and active
boolean reset = resetIn != null && state.readDigitalSignal(resetIn);
int peak = reset ? 0 : state.getIntegerSlot(nodeId, "peak");
int current = state.readIntegerSignal(valueIn);
if (current > peak) {
peak = current;
}
state.setPendingIntegerSlot(nodeId, "peak", peak);
state.setSignal(peakOut, SpcSignalValue.integer(peak));
}
}
Global Markers
While persistent slots are per-node (scoped to a UUID), markers are global variables shared across the entire program. Any node can read or write them by name.
| Marker type | Read | Write |
|---|---|---|
| Boolean | state.getMarkerValue("key") |
state.setPendingMarkerValue("key", true) |
| Integer (analog) | state.getAnalogMarkerValue("key") |
state.setPendingAnalogMarkerValue("key", 42) |
| String | state.getStringMarkerValue("key") |
state.setPendingStringMarkerValue("key", "text") |
"mymod:safety_flag". Prefer per-node slots for node-private state.
Built-in Latch State
The runtime provides built-in latch and delay counter state. These are used by SPC's own timer/latch nodes, but addons can use them too:
| Method | Returns | Use for |
|---|---|---|
getLatchState(nodeId) | boolean | SR latch output state |
setPendingLatchState(nodeId, value) | void | Set latch output |
getOnDelayCounter(nodeId) | int | On-delay elapsed ticks |
setPendingOnDelayCounter(nodeId, value) | void | Set on-delay progress |
get*Slot/setPending*Slot methods —
you can have as many named slots per node as you need.
Rules & Gotchas
The golden rules
- Read with
get*Slot(), write withsetPending*Slot(). - Never store mutable state in instance fields on your compiled node.
- setPending is deferred — your write isn't visible until the next phase.
- Slots default to zero/false/empty — no initialization needed.
- Slots survive world saves — saved to NBT automatically.
- Slots are per-node-instance — two copies of the same node type have independent slots.
Common gotchas
| Problem | Cause | Fix |
|---|---|---|
| State resets on world reload | Storing state in Java fields instead of slots | Use setPending*Slot() |
| Write isn't visible this tick | Double buffering — pending isn't committed yet | Use a local variable for the computed value, write it to both the signal and the slot |
| Two nodes fight over same slot | Using the same slot name with different nodeIds is fine. Using markers with the same key is not. | Namespace marker keys: "mymod:flag" |
| Counter jumps by 2 | Node is registered in two phases, or there's a duplicate | Each node should run in exactly one phase |