Lightning Addon Walkthrough - clockworktownsmp/clockwork-bosses GitHub Wiki

Lightning addon walkthrough

Let's walk through the standalone lightning addon, from registering the action to cleaning up after it. If interfaces, factories, or callbacks are new to you, start with Addon API.

Experimental revision 1: stable 0.2.0 and beta.6 include the API; the older 0.1 jar does not. Build this separate addon against the exact core version you will install. Current example source is 0.1.8 with a core-version pin; that source inspection is not a new packaging/runtime result. The example is not bundled or automatically installed with the core. Addon-specific playtests remain necessary.

What the attack does

The action pauses native attacks and navigation, chooses an eligible player, shows a short particle warning, then displays one visual-only lightning bolt and attempts damage. It creates no fire. It waits five ticks after the strike before completing.

Setting Default Allowed values
warning_ticks 40 ticks, about 2 seconds Whole number 1–200
damage 8 HP before native adjustments Number 0–100
target_mode current current or random

Eight HP is four normal hearts before armor and other damage handling. Damage is attempted through Minecraft; an eligible target can still block or reject it through native rules or another mod.

The project files

The example lives under examples/lightning-addon/ in the core source checkout. Open that folder as a separate Gradle project in IntelliJ and use Java 25.

File Purpose
settings.gradle Identifies the independent project and Fabric plugin repository.
build.gradle Defines Minecraft/Fabric dependencies, Java 25 and the local core jar dependency.
gradle.properties Gradle settings for this project.
src/main/resources/fabric.mod.json Mod ID, version, dependencies and discovery entrypoint.
src/main/java/clockwork/example/LightningAddon.java Registers and implements the mechanic.

Its mod ID is clockwork_lightning_example; its registered action ID is clockwork_lightning_example:lightning. The configuration uses these IDs; the editor shows the readable label Example lightning.

The discovery entrypoint

The metadata contains this entry inside entrypoints:

{
  "entrypoints": {
    "clockwork_bosses": ["clockwork.example.LightningAddon"]
  }
}

That class implements BossAddon. Clockwork Bosses calls its register(...) method once during startup, before loading templates. This makes the action available. It doesn't run an attack yet.

The existing implementation

The following is the current example source. The explanations after it follow the same order.

package clockwork.example;

import clockwork.bosses.api.BossAction;
import clockwork.bosses.api.BossActionContext;
import clockwork.bosses.api.BossActionRegistry;
import clockwork.bosses.api.BossActionType;
import clockwork.bosses.api.BossAddon;
import clockwork.bosses.api.BossEncounterEvents;
import clockwork.bosses.api.BossSetting;
import java.util.List;
import java.util.Set;
import java.util.UUID;
import net.minecraft.core.particles.ParticleTypes;
import net.minecraft.resources.Identifier;
import net.minecraft.server.level.ServerPlayer;
import net.minecraft.world.entity.EntitySpawnReason;
import net.minecraft.world.entity.EntityTypes;
import org.slf4j.LoggerFactory;

/** No mixins, core implementation imports or client entrypoint. The server
 * advertises this schema to the editor; every execution receives a fresh action. */
public final class LightningAddon implements BossAddon {
    @Override public void register(BossActionRegistry.Registrar actions) {
        actions.register(new BossActionType(
                Identifier.fromNamespaceAndPath("clockwork_lightning_example", "lightning"),
                "Example lightning", "Warn one eligible participant, then strike once without setting fires.",
                List.of(
                        BossSetting.number("warning_ticks", "Warning ticks", "Time to react before the strike; 20 ticks = one second.", 40, 1, 200, true),
                        BossSetting.number("damage", "Damage HP", "Native damage amount, before armor and other mods adjust it.", 8, 0, 100, false),
                        BossSetting.text("target_mode", "Target choice", "Use the current target or pick a random arena participant.", "current", List.of("current", "random"), 16)),
                Set.of(), Lightning::new)); // No Warden-specific capability is required.

        // Event snapshots can be logged without retaining players/worlds.
        BossEncounterEvents.STARTED.register(encounter -> LoggerFactory.getLogger("clockwork_lightning_example")
                .info("Encounter activated: {} ({})", encounter.name(), encounter.bossId()));
    }

    /** Holds only a participant UUID and timing state. Native damage is routed
     * through the core so its eligibility, hit notifications and rotation apply. */
    private static final class Lightning implements BossAction {
        private UUID target;
        private long struckAt = -1;
        @Override public void start(BossActionContext context) {
            if (context.settings().text("target_mode").equals("current"))
                context.target().filter(ServerPlayer.class::isInstance).map(ServerPlayer.class::cast)
                        .ifPresent(player -> target = player.getUUID());
            if (target == null) {
                var players = context.participants();
                if (!players.isEmpty()) target = players.get(context.controller().mob().getRandom().nextInt(players.size())).getUUID();
            }
        }
        @Override public Result tick(BossActionContext context) {
            long elapsed = context.elapsedTicks();
            if (struckAt >= 0) return elapsed - struckAt >= 5 ? Result.COMPLETE : Result.RUNNING;
            ServerPlayer player = target == null ? null : context.level().getServer().getPlayerList().getPlayer(target);
            if (player == null || !context.isParticipant(player)) return Result.COMPLETE;
            if (elapsed < context.settings().integer("warning_ticks")) {
                // A few native particles from the existing action callback; no
                // additional watcher, task queue, packet protocol or assets.
                if (elapsed % 5 == 0) context.level().sendParticles(ParticleTypes.ELECTRIC_SPARK,
                        player.getX(), player.getY() + 1, player.getZ(), 6, .35, .6, .35, .03);
                return Result.RUNNING;
            }
            var bolt = EntityTypes.LIGHTNING_BOLT.create(context.level(), EntitySpawnReason.COMMAND);
            if (bolt == null) return Result.COMPLETE;
            bolt.setVisualOnly(true); // No fire or duplicate native lightning damage.
            bolt.snapTo(player.getX(), player.getY(), player.getZ(), 0, 0);
            context.ownEntity(bolt); // Acquire cleanup before publishing the entity.
            context.level().addFreshEntity(bolt);
            struckAt = elapsed;
            context.damage(player, context.controller().mob().damageSources().mobAttack(context.controller().mob()),
                    (float) context.settings().number("damage"));
            return Result.RUNNING;
        }
        // Scope cleanup discards the owned bolt on completion/interruption even
        // if a later callback fails. No custom stop method is needed here.
    }
}

Registration describes the mechanic

new BossActionType(...) supplies the unique action ID, editor label, description, setting definitions, required capabilities and factory.

BossSetting.number("warning_ticks", ..., 40, 1, 200, true) means default 40, minimum 1, maximum 200, whole numbers only. Its label and help become editor text and generated comments. target_mode supplies a list of choices, so its control becomes a dropdown.

Set.of() declares no Warden-specific animation requirement. The action uses generic boss and participant operations, although today's core backend still controls Wardens only.

Lightning::new is the factory: create a fresh Lightning instance for every execution. It must not return a shared object containing another boss's target or timer.

The two fields belong to one execution

target stores a player UUID. struckAt starts at -1, meaning no strike has happened. After striking, it stores the elapsed action tick at the strike.

Each instance has its own fields, so two bosses can run independent lightning executions. The class's private static declaration does not make its instances or fields shared; it avoids implicitly retaining an enclosing addon object.

Start chooses a player

start(context) first attempts the current native target when target_mode is current. It accepts only a player from the context's validated target result.

If no player was selected, it picks a random eligible arena participant. That fallback also provides random mode. It stores the UUID and lets tick() handle an empty target pool.

The warning follows the selected player for this execution. The action doesn't pick someone new on every update.

Update checks eligibility and timing

tick(context) reads elapsed game time. If a strike already happened, it waits until five ticks have elapsed since that strike, then returns COMPLETE.

Otherwise it resolves the stored UUID through the server's current player list. If the player is missing or no longer eligible, the action ends without a strike. isParticipant(player) rechecks life, dimension, connection, arena position and target eligibility at use time.

While the warning is running, six native electric-spark particles appear every five ticks. RUNNING keeps the execution alive. This uses the existing boss AI callback; it starts no extra watcher, scheduled task or custom particle network protocol.

The bolt is a visual

After the warning, the code creates a native lightning entity and marks it visual-only. That avoids normal lightning fire and a second native lightning damage hit.

It places the bolt at the player's current position, not the position captured when warning began. The UUID is resolved again each update, so disconnecting or leaving the arena can cancel the strike.

Ownership comes before publication

context.ownEntity(bolt) registers the temporary bolt before addFreshEntity(bolt) exposes it to the world. If a later step fails or the boss is interrupted, ownership is already established.

The scope excludes the bolt from native persistence and discards the exact loaded entity during cleanup. It does not search unloaded chunks. Use this ownership for temporary entities. It isn't a system for persistent summoned minions.

Damage uses the core helper

context.damage(...) rechecks participant eligibility and calls native server damage using a mob-attack damage source. Armor, invulnerability and another mod's damage veto remain relevant.

The helper emits an accepted-hit notification when applicable and reuses existing voice/target-rotation handling. The action decides when it's complete, without needing access to ClockworkBossManager or private encounter state.

The return value from tick() after the attempt is still RUNNING; the five-tick visual hold handles completion. If damage triggers an interruption, the runtime's ownership check prevents this old execution from advancing a new subphase.

Stop and cleanup

This example doesn't need to override stop(). The interface supplies an empty default, and the scope already owns its only temporary world resource: the bolt.

During warning, interruption means the strike never happens. After bolt creation, closing the action runs entity cleanup. A phase change, reload, death, removal, unload or shutdown closes the same scope. Runtime failures also attempt cleanup and leave that execution stopped until a transition or reload.

An addon changing another temporary resource would register restoration with onCleanup(...) before making the change. Crash recovery for persistent world mutations requires more than a callback; see API reference.

The activation listener is separate

The BossEncounterEvents.STARTED.register(...) line observes encounter activation and logs its name and UUID. It does not run the lightning action or select a subphase.

The supplied snapshot contains values and IDs rather than retaining a world or player. Events are short synchronous notifications, not permission/veto hooks. An unload-ending notification must not be treated as boss death or awarded as a death reward.

Build and install the example

From the core checkout, package the core first, then the independent example:

Windows:

.\gradlew.bat jar sourcesJar
.\gradlew.bat -p examples/lightning-addon jar sourcesJar

Linux/macOS:

bash ./gradlew jar sourcesJar
bash ./gradlew -p examples/lightning-addon jar sourcesJar

These commands compile/package without running tests. The example reads the core's mod_version and archives_base_name from its gradle.properties, then selects that exact jar under ../../build/libs/. A missing matching jar stops the build rather than selecting an older one. The addon is written under examples/lightning-addon/build/libs/ and never bundles the core.

Install the latest development core and example addon on the server. Install the matching core client where you want the editor and enhanced boss presentation; this example needs no addon client installation. With the latest optional-client server source, players without the core client can also see its native particle warning and lightning. Mixed-client gameplay remains untested. Restart, edit a template from a modded client, add a subphase and select Example lightning, then Save and Reload saved.

Current source identifies addon 0.1.8 and pins its runtime dependency to the core version used to compile it, currently 0.3.0-beta.6. Build the matching core first, then the addon, and inspect the actual jar metadata. Earlier addon 0.1.3 packaging against core 0.2.0 on 2026-10-12 is historical evidence, not verification of this later source. Run the lightning playtest for the pair you install.

Configure it in a phase

This is a partial object to merge into an existing template, not a complete replacement:

{
  "phases": {
    "annoyed": {
      "subphases": {
        "lightning": {
          "type": "clockwork_lightning_example:lightning",
          "action_settings": {
            "warning_ticks": 40,
            "damage": 8,
            "target_mode": "current"
          }
        }
      }
    }
  }
}

Existing sequential/random order and optional roar interludes still apply. Returning COMPLETE uses that transition path.

Make your own addon

  1. Copy this independent project and choose your own mod ID and Java package.
  2. Update metadata, entrypoint class name, artifact name and action namespace consistently.
  3. Describe the mechanic's settings and required capabilities.
  4. Supply a fresh action factory, then implement its start/update/cleanup behavior.
  5. Use only public clockwork.bosses.api imports plus supported Minecraft/Fabric APIs; avoid core implementation classes.
  6. Compile/package against the matching development core.
  7. Use an isolated instance to check both successful execution and interruptions before distribution.

Runtime checks should include two bosses at once, no eligible player, target exit/death/disconnect, phase change during warning, reload, removal, chunk unload/restart and another mod rejecting damage. Confirm editor fields and invalid-save behavior with the addon installed only on the server. These checks remain pending for the current example.

For exact signatures, stop reasons, capabilities and field limits, continue to API reference.