Hytale Modding
Hytale Modding
Server Plugins

Packet Interception

Step by step breakdown on customizing hotbar functionality via packet interception.

Written by Vibe Theory

This guide teaches packet interception and modification using hotbar actions as a practical example. If custom hotbar actions are your primary goal, consider using item-level or unarmed interaction overrides instead. They're the native approach for that mechanic and avoid desync issues entirely (see Native Interaction Overrides at the bottom). The hotbar use case makes a great learning example because it touches packet filtering, client-server desync, interaction chain cancellation, and thread safety.

Learn By Doing

In this guide, We're going to turn hotbar slot 8 (the "9" key) into an ability trigger. When pressed:

  1. The normal slot-switching behavior is blocked
  2. The player stays on their current slot
  3. Some ideas to get started on what to have it actually do

Part 1: Understanding the Packet System

You can use the Listening to Packets Guide to learn about how the packet system works and how to listen. In short, the Client sends the Server packets for any user interactions the server needs to know about. For the sake of this guide, we care about the SyncInteractionChains packet.

The SyncInteractionChains Packet

This packet (ID 290) is the workhorse for player interactions. It contains an array of SyncInteractionChain objects, each describing an interaction the player is attempting.

For slot switching, we care about these fields:

  • interactionType - What kind of interaction (SwapFrom, SwapTo, Attack, etc.)
  • activeHotbarSlot - The slot the player is currently on
  • data.targetSlot - The slot they want to switch to
  • initial - Whether this is the start of a new interaction chain

When switching from slot 5 to slot 9, you'll see:

SwapFrom: activeSlot=5, targetSlot=8, initial=true

Note: Slot index is 0-based, so slot "9" is index 8.


Part 2: Packet Interception

Watcher vs Filter

Hytale provides two ways to intercept packets via PacketAdapters:

TypeInterfaceCan Block?Use Case
WatcherPlayerPacketWatcherNoLogging, analytics, triggering side effects
FilterPlayerPacketFilterYesBlocking/modifying behavior

Since we need to block the slot switch, we use PlayerPacketFilter. This interface has one method - test() - which receives every inbound packet. Return true to block the packet, false to let it through.

Setting Up the Handler

Your handler class implements the filter interface. You'll need these imports:

import com.hypixel.hytale.protocol.Packet;
import com.hypixel.hytale.protocol.InteractionType;
import com.hypixel.hytale.protocol.packets.interaction.SyncInteractionChain;
import com.hypixel.hytale.protocol.packets.interaction.SyncInteractionChains;
import com.hypixel.hytale.server.core.io.adapter.PlayerPacketFilter;
import com.hypixel.hytale.server.core.universe.PlayerRef;

The handler skeleton:

public class AbilitySlotHandler implements PlayerPacketFilter {

    private static final int ABILITY_SLOT = 8;  // Slot index 8 = Key "9"

    @Override
    public boolean test(@Nonnull PlayerRef playerRef, @Nonnull Packet packet) {
        // We'll fill this in next
        return false;
    }
}

Registration

In your plugin, register the handler in setup() and store the returned filter so you can deregister later:

import com.hypixel.hytale.server.core.io.adapter.PacketAdapters;
import com.hypixel.hytale.server.core.io.adapter.PacketFilter;

// In your plugin class:
private PacketFilter inboundFilter;

@Override
protected void setup() {
    AbilitySlotHandler handler = new AbilitySlotHandler(this);
    inboundFilter = PacketAdapters.registerInbound(handler);
}

@Override
protected void shutdown() {
    if (inboundFilter != null) {
        PacketAdapters.deregisterInbound(inboundFilter);
    }
}

Great! Now a PlayerPacketFilter is intercepting every inbound packet and letting us choose to block or let them through. Now we need to set up what we're filtering for.


Part 3: Detecting the Ability Trigger

The test() Method

Inside test(), we need to:

  1. Check if this is the packet type we care about
  2. Look for our specific trigger condition
  3. Block and handle it, or let it through
@Override
public boolean test(@Nonnull PlayerRef playerRef, @Nonnull Packet packet) {
    // Step 1
    if (!(packet instanceof SyncInteractionChains syncPacket)) {
        return false;
    }

   // Step 2: Separate ability chains from everything else
   SyncInteractionChain abilityChain = null;
   List<SyncInteractionChain> keep = new ArrayList<>();

   for (SyncInteractionChain chain : syncPacket.updates) {
      if (chain.interactionType == InteractionType.SwapFrom
             && chain.data != null
             && chain.data.targetSlot == ABILITY_SLOT
             && chain.initial
             && abilityChain == null) {
         abilityChain = chain;
      } else {
         keep.add(chain);
      }
   }

   // No ability chain found — let packet through untouched
   if (abilityChain == null) {
        return false;
   }

   // Step 3: Cancel the ability chain and trigger the ability
   handleAbilityTrigger(playerRef, abilityChain.activeHotbarSlot,
           abilityChain.chainId, abilityChain.forkedId);

   // Step 4: If the packet had other chains (attacks, block places, etc),
   // strip the ability chain out and let the rest through
   if (!keep.isEmpty()) {
       syncPacket.updates = keep.toArray(new SyncInteractionChain[0]);
       return false; // Modified packet passes through
   }

   // Packet only contained the ability chain — block it entirely
   return true;
}

Why not just block the whole packet? SyncInteractionChains can contain multiple chains bundled together. For example, a block placement and a slot swap in the same packet. If we return true to block everything, those other interactions get silently dropped. Instead, we strip out only the ability chain and let the rest pass through to the server normally.

So now we're detecting the ability trigger and preserving any other interactions in the same packet. But the client still doesn't know we rejected the ability chain. That's where CancelInteractionChain comes in.

Part 4: The Client Desync Problem

The Challenge

Here's where it gets tricky, because the Client performs this action locally before the server confirms it.

  • Server: Player stays on slot 5 (we blocked the packet)
  • Client: Player is on slot 8 (already switched locally before sending)

The client and server are now desynced, and it's deeper than just the visible hotbar slot. The client also started an interaction chain (identified by chainId) and is waiting for the server to acknowledge it. Since we blocked the packet that'll never happen, so the client's interaction prediction system will stay out of sync.

Fixing The Client State: CancelInteractionChain & SetActiveSlot Packet

We need to match the pattern the engine itself uses when rejecting invalid chains (see InteractionManager.java):

  1. CancelInteractionChain(chainId, forkedId): tells the client "the server rejected this chain, stop predicting it"
  2. SetActiveSlot(HOTBAR_SECTION_ID, originalSlot): resyncs the visible hotbar slot
import com.hypixel.hytale.protocol.ForkedChainId;
import com.hypixel.hytale.protocol.packets.interaction.CancelInteractionChain;
import com.hypixel.hytale.protocol.packets.inventory.SetActiveSlot;
import com.hypixel.hytale.server.core.inventory.Inventory;

// Cancel interaction chain so the client stops predicting it
playerRef.getPacketHandler().writeNoCache(
        new CancelInteractionChain(chainId, forkedId));

// Update server-side state
playerComponent.getInventory().setActiveHotbarSlot((byte) originalSlot);

// Send packet to force client to the correct slot
SetActiveSlot setActiveSlotPacket = new SetActiveSlot(
    Inventory.HOTBAR_SECTION_ID,  // -1 indicates the hotbar
    originalSlot                   // The slot index to select
);
playerRef.getPacketHandler().writeNoCache(setActiveSlotPacket);

The CancelInteractionChain tells the client to stop predicting the rejected chain, and SetActiveSlot fixes the visible hotbar slot, fixing the desync!


Part 5: Thread Safety: world.execute()

Packet handlers run on network threads, but entity operations must run on the world thread. Use world.execute() to schedule your code in the right place. See Thread Safety: Using world.execute() for details.

Schedule your entity operations on the world thread:

private void handleAbilityTrigger(PlayerRef playerRef, int originalSlot, int chainId, ForkedChainId forkedId) {
    Ref<EntityStore> entityRef = playerRef.getReference();
    if (entityRef == null || !entityRef.isValid()) {
        return;
    }

    Store<EntityStore> store = entityRef.getStore();
    World world = store.getExternalData().getWorld();

    world.execute(() -> {
        Player playerComponent = store.getComponent(entityRef, Player.getComponentType());
        if (playerComponent == null) {
            return;
        }

        // Cancel the interaction chain & resync hotbar
        playerRef.getPacketHandler().writeNoCache(
                new CancelInteractionChain(chainId, forkedId));
        playerComponent.getInventory().setActiveHotbarSlot((byte) originalSlot);
        SetActiveSlot setActiveSlotPacket = new SetActiveSlot(
            Inventory.HOTBAR_SECTION_ID,  // -1 indicates the hotbar
            originalSlot                   // The slot index to select
        );
        playerRef.getPacketHandler().writeNoCache(setActiveSlotPacket);

        // Your ability logic here
    });
}

Recommendation: read the Thread Safety guide linked above to understand why world.execute() is so important (you'll use it often).


Part 6: Triggering Abilities

Congratulations! You now have a custom keybind you can do basically anything you want with! Here's some starting points:

Running Commands: CommandManager.get().handleCommand(playerRef, "noon") executes a command as the player. Quick and easy for prototyping.

More Advanced: While you could technically just put abilities on commands, some more advanced options like spawning projectiles with ProjectileModule.get().spawnProjectile() are a cleaner way to go. This is a bit more involved, such as TargetUtil.getLook for player's eye position. As more guides are released this one will be updated to direct you there!


Part 7: Debugging Tips

Log Everything

When something doesn't work, add logging at each step:

// Log what packets you're seeing
plugin.getLogger().at(Level.INFO).log(
    "[DEBUG] Packet: %s, type=%s, activeSlot=%d, targetSlot=%d",
    playerRef.getUsername(),
    chain.interactionType,
    chain.activeHotbarSlot,
    chain.data != null ? chain.data.targetSlot : -1
);

You need this import for logging:

import java.util.logging.Level;

Troubleshooting

Nothing happens when I press the key:

  • Is your packet filter registered? Check for startup logs.
  • Is the packet type correct? Log all packets to see what's actually coming through.
  • Are your conditions matching? Log the chain fields to verify.

Ability fires but player is stuck on wrong slot:

  • Are you sending SetActiveSlot correctly?
  • Verify the code running on the world thread with world.execute().

Block placements or attacks get dropped when spamming them with ability keys:

  • If you're using return true to block the entire packet, other interactions bundled in the same packet get silently dropped. Verify against Part 3 again to ensure you're only filtering for the correct packets.

Errors about threads or null components:

  • Always check if entityRef.isValid() before using it.
  • Always null-check components - players can disconnect mid-operation.
  • Make sure entity operations are inside world.execute().

Native Interaction Overrides

The packet filter approach above works by intercepting packets outside the interaction system and manually cleaning up the desync. It's a great way to learn packet interception, but if custom hotbar actions are your end goal, there's 2 cleaner native approaches that avoids desync entirely.

Hytale's interaction system uses UnarmedInteractions, a JSON asset that maps each interactiontype to a RootInteraction. By default, SwapFrom maps to ChangeActiveSlotInteraction, which is the built-in slot-switch logic. You can override this at two levels:

1: Item-level interactions: Define a custom RootInteraction for SwapFrom or SwapTo on specific items. When that item is held and the player swaps, your interaction runs instead of the default. Both client and server execute the same interaction definition, so they stay in sync natively.

2: Override unarmed interactions globally: Replace SwapFrom's mapping in the "Empty" UnarmedInteractions asset in your asset pack, pointing it to your own interaction instead of *Default_Swap. This catches all hotbar swaps at the interaction level. No packet filtering, no cancel packets, no desync.

*Notes: Each of the above has considerations. 1: Item-level requires the item be held at the start of the chain (your sword needs the command the mapping, not an ability item). 2: Unarmed interactions is a global filter, you need to add supporting logic so it only covers specific hotbar slots under specific conditions, not all of them.


Going Further

This pattern opens up many possibilities:

  • Multiple ability slots - Slots 7, 8, 9 as three different abilities
  • Cooldowns - Track last use time per player with a Map
  • Class-based abilities - Store player class data, trigger different effects
  • Combo systems - Track sequences of interactions
  • Custom projectiles - Create your own ProjectileConfig assets

Video Example

From Hytale Server build 2026.01.13-dcad8778f