TL;DR

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.

On This Page

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:

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.

⚠ Never do this
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.

TypeRead methodWrite methodDefaultUse 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
💡 Slot names are free-form
You choose the slot name strings. Use descriptive names like "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:

  1. When you call setPending*Slot(), the new value is stored in a pending buffer.
  2. The current value (from get*Slot()) is unchanged during this tick.
  3. After all nodes in the current phase finish executing, pending values are committed.
  4. On the next phase (or next tick), get*Slot() returns the new value.
Current Buffer get*Slot() reads from here commit Pending Buffer setPending*Slot() writes here next phase New Current pending is now current

Why does this matter?

In practice
Most nodes run in STATE_UPDATE phase and read the slot value that was committed after the previous tick. So 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.

What this produces in-game
A node with one digital input and one integer output. Wire a switch to the input and a display to the output — the display shows how many ticks the switch has been on.
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.

What this produces in-game
A timer node with a digital input, digital output, and a "delay" parameter (in ticks). Prevents false activations from brief signal spikes — the input must stay steady for the full delay before the output activates.
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.

What this produces in-game
Press a button once → output turns on. Press again → output turns off. Like a light switch connected to the LOGO machine.
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.

What this produces in-game
Feed a noisy sensor (like rotation speed) into this node. The output is a smooth average that doesn't jump around every tick. The "alpha" parameter controls how fast the average responds to changes (0.1 = very smooth, 0.9 = very responsive).
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.

What this produces in-game
Monitor a value (like FE production) and always display the maximum it has reached. Wire a button to the reset input to start tracking again from zero.
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 typeReadWrite
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")
⚠ Use markers sparingly
Markers are global — any node can overwrite them. Namespace marker keys with your mod ID: "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:

MethodReturnsUse for
getLatchState(nodeId)booleanSR latch output state
setPendingLatchState(nodeId, value)voidSet latch output
getOnDelayCounter(nodeId)intOn-delay elapsed ticks
setPendingOnDelayCounter(nodeId, value)voidSet on-delay progress
💡 Prefer named slots for custom state
The latch/delay APIs are convenient but limited to one value per node. For anything more complex, use the named get*Slot/setPending*Slot methods — you can have as many named slots per node as you need.

Rules & Gotchas

The golden rules

  1. Read with get*Slot(), write with setPending*Slot().
  2. Never store mutable state in instance fields on your compiled node.
  3. setPending is deferred — your write isn't visible until the next phase.
  4. Slots default to zero/false/empty — no initialization needed.
  5. Slots survive world saves — saved to NBT automatically.
  6. Slots are per-node-instance — two copies of the same node type have independent slots.

Common gotchas

ProblemCauseFix
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