Real-Time Packet Monitor Dashboard

Build a Velocity plugin that streams live Minecraft network metrics to a Canvas dashboard.

Introduction

30 Second Summary

When hundreds of players connect to a Minecraft network, thousands of packets flow through the proxy every second. Without visibility into that traffic, diagnosing lag spikes or capacity issues is pure guesswork.

In this project, you will build a real-time packet monitoring plugin for a Velocity proxy that intercepts all network traffic, counts packets and bytes using lock-free atomic operations, and streams live metrics to a browser dashboard over WebSocket.

What You'll Build

A Kotlin Velocity plugin that monitors all proxy network traffic in real time and visualizes it on a live Canvas dashboard connected over WebSocket.

By the end of this project, you'll have:

  • A Kotlin Velocity plugin that intercepts every packet flowing through your proxy using PacketEvents with zero impact on packet forwarding.
  • A lock-free metrics engine with atomic counters and a 150-sample ring buffer storing 30 seconds of rolling history.
  • A live Canvas dashboard connected over an embedded WebSocket server, rendering real-time packet rate and bandwidth charts updated every 200ms.
  • Secret Mission: Add per-server metrics breakdown showing packet rates and bandwidth per backend server.

Are there any prerequisites?

This project assumes basic Kotlin knowledge and a local Velocity + Paper test environment with PacketEvents 2.13.0 already installed as a plugin on your Velocity proxy. You should also have JDK 26 and IntelliJ IDEA ready to go.

Set Up the Gradle Project

Before your plugin can intercept a single packet, it needs a properly configured build system. Velocity plugins are Kotlin projects built with Gradle, and yours requires several dependencies: the Velocity API, PacketEvents for packet interception, and Java-WebSocket for streaming data to a browser.

In this step, you will create the Gradle project, configure the Shadow plugin to bundle Java-WebSocket into a fat JAR, and verify the build produces the right output.

In this step, get ready to:
  • Configure build.gradle.kts with the Kotlin, Shadow, Velocity API, PacketEvents, and Java-WebSocket dependencies.
  • Create the source directory structure and a placeholder plugin class.
  • Build the project with Shadow to produce a fat JAR containing only the shaded dependency.
Create a new Gradle Kotlin project in IntelliJ IDEA

IntelliJ's New Project wizard scaffolds the Gradle wrapper, directory layout, and initial build file for you.

  • In IntelliJ IDEA, click File in the top menu bar.
  • Click New, then Project.
  • In the Language section, select Kotlin.
  • Set the Build system to Gradle.
  • Set the Gradle DSL to Kotlin (this is the default).
  • Name the project packet-monitor.
  • Select your JDK 26 installation from the JDK dropdown.
  • Expand Advanced Settings.
  • Set GroupId to com.example and leave ArtifactId as packet-monitor.
  • Uncheck Add sample code if it is checked.
  • Click Create.

IntelliJ creates the project and runs an initial Gradle sync. Wait for the sync to finish before continuing.

What did IntelliJ just create?

The wizard generated a Gradle wrapper (gradlew and gradlew.bat), a build.gradle.kts build script, a settings.gradle.kts file, and the src/ directory tree.

You will replace the generated build script contents with your own configuration next.

Configure the build script with plugins and dependencies

The build script declares which plugins compile and package your code, where Gradle downloads dependencies from, and which libraries your plugin needs at compile time versus runtime.

  • In the Project tool window on the left, open build.gradle.kts by double-clicking it.
  • Select all existing content and replace it with the following:
plugins {
    kotlin("jvm") version "2.4.0"
    id("com.gradleup.shadow") version "9.4.3"
}

group = "com.example"
version = "1.0.0"

repositories {
    mavenCentral()
    maven {
        name = "papermc"
        url = uri("https://repo.papermc.io/repository/maven-public/")
    }
    maven {
        name = "codemc-releases"
        url = uri("https://repo.codemc.io/repository/maven-releases/")
    }
}

dependencies {
    compileOnly("com.velocitypowered:velocity-api:3.5.0-SNAPSHOT")
    annotationProcessor("com.velocitypowered:velocity-api:3.5.0-SNAPSHOT")
    compileOnly("com.github.retrooper:packetevents-velocity:2.13.0")
    implementation("org.java-websocket:Java-WebSocket:1.6.0")
}

kotlin {
    jvmToolchain(21)
}

tasks.shadowJar {
    archiveClassifier.set("")
    relocate("org.java_websocket", "com.example.packetmonitor.libs.websocket")
}

What does this build script do?

  • kotlin("jvm") compiles Kotlin source files targeting the JVM.
  • com.gradleup.shadow creates a "fat JAR" that bundles runtime dependencies (Java-WebSocket) into a single file.
  • compileOnly means the Velocity API and PacketEvents are available at compile time but NOT bundled. They already exist on the proxy at runtime.
  • implementation means Java-WebSocket IS bundled into the fat JAR because it is not available on the proxy.
  • relocate moves Java-WebSocket's package path to avoid classpath conflicts if another plugin also bundles it.
  • jvmToolchain(21) sets the compilation target to Java 21, which Velocity 3.5.x requires.
  • Save build.gradle.kts with Cmd+S (macOS) or Ctrl+S (Windows).
  • IntelliJ shows a floating banner prompting you to reload Gradle changes. Click Load Gradle Changes (or press the elephant icon in the editor gutter).
  • Wait for the sync to complete. You should see a green checkmark in the Build tool window at the bottom.

Gradle sync failed?

  • Check your internet connection. Gradle needs to download dependencies from the three repositories.
  • Verify the repository URLs have no typos. The PaperMC URL is https://repo.papermc.io/repository/maven-public/ and the CodeMC URL is https://repo.codemc.io/repository/maven-releases/.
  • Make sure you selected JDK 26 in the project settings. The Kotlin 2.4.0 plugin requires Gradle 7.6.3 or newer.

Help me fix my Gradle sync error for the packet-monitor project.

Create the source directory and placeholder plugin class

Velocity plugins need a main class in a specific package directory. You also need to confirm the project name in settings.gradle.kts.

  • Open settings.gradle.kts from the Project tool window.
  • Replace its contents with the following:
rootProject.name = "packet-monitor"

What does this line do?

This sets the root project name to packet-monitor. Gradle uses this name for the output JAR filename and as the project identity in multi-project builds.

  • Save settings.gradle.kts.
  • In the Project tool window, right-click the src/main/kotlin folder.
  • Select New, then Package.
  • Type com.example.packetmonitor and press Enter.
  • Right-click the new packetmonitor package.
  • Select New, then Kotlin Class/File.
  • Name it PacketMonitorPlugin and select Class from the dropdown.

IntelliJ creates the file with a package declaration and empty class. Verify it matches this:

package com.example.packetmonitor

class PacketMonitorPlugin

Why is the class empty?

This is a placeholder to confirm the project compiles. You will add the Velocity annotations, dependency injection, and event handlers in the next step.

  • Save PacketMonitorPlugin.kt.
  • Confirm the Project tool window shows the path src/main/kotlin/com/example/packetmonitor/PacketMonitorPlugin.kt.

Don't see the src/main/kotlin folder?

If IntelliJ did not create src/main/kotlin during project setup, right-click the project root, select New then Directory, and type src/main/kotlin. IntelliJ auto-creates nested folders from the slash-separated path.

Help me set up the source directory structure for my Velocity plugin project.

Build the shadow JAR and verify the output

Running the Shadow task compiles your Kotlin code, bundles Java-WebSocket (relocated), and produces the final plugin JAR.

  • Open IntelliJ's built-in terminal by pressing Alt+F12 (Windows) or clicking Terminal in the bottom tool bar.
  • Build the fat JAR by running this command:

macOS

./gradlew shadowJar

Windows

gradlew.bat shadowJar

What does shadowJar do?

The shadowJar task compiles the project, then packages your compiled classes plus all implementation dependencies into a single JAR.

It relocates org.java_websocket classes into com.example.packetmonitor.libs.websocket to prevent conflicts with other plugins. Libraries marked compileOnly (Velocity API, PacketEvents) are excluded because they already exist on the proxy.

  • You should see BUILD SUCCESSFUL in the terminal output.
  • Verify the JAR was created by running:

macOS

ls build/libs/

Windows

dir build\libs\

You should see a file named packet-monitor-1.0.0.jar in the output.

  • Confirm the JAR contains relocated WebSocket classes but not Velocity or PacketEvents classes by running:

macOS

jar tf build/libs/packet-monitor-1.0.0.jar | grep websocket

Windows

jar tf build\libs\packet-monitor-1.0.0.jar | findstr websocket

You should see paths like com/example/packetmonitor/libs/websocket/ listed. This confirms the relocation worked.

  • Verify that Velocity API classes are NOT in the JAR by running:

macOS

jar tf build/libs/packet-monitor-1.0.0.jar | grep velocitypowered

Windows

jar tf build\libs\packet-monitor-1.0.0.jar | findstr velocitypowered

This command should return no results. That confirms compileOnly dependencies are excluded from the fat JAR.

Build failed with unresolved references?

  • Run Load Gradle Changes again from the editor banner. Gradle may not have synced the new repositories yet.
  • If you see a "Could not resolve" error for a dependency, double-check the repository URLs in build.gradle.kts match exactly.
  • Make sure your PacketMonitorPlugin.kt has the correct package declaration com.example.packetmonitor and contains no syntax errors.

Help me fix my shadowJar build failure.

✔️ Awesome, I've got everything!

Great. Double check you have saved all files and your JAR is in build/libs/.

ⓧ I'd like to double check the full code

Here are all three files in full:

plugins {
    kotlin("jvm") version "2.4.0"
    id("com.gradleup.shadow") version "9.4.3"
}

group = "com.example"
version = "1.0.0"

repositories {
    mavenCentral()
    maven {
        name = "papermc"
        url = uri("https://repo.papermc.io/repository/maven-public/")
    }
    maven {
        name = "codemc-releases"
        url = uri("https://repo.codemc.io/repository/maven-releases/")
    }
}

dependencies {
    compileOnly("com.velocitypowered:velocity-api:3.5.0-SNAPSHOT")
    annotationProcessor("com.velocitypowered:velocity-api:3.5.0-SNAPSHOT")
    compileOnly("com.github.retrooper:packetevents-velocity:2.13.0")
    implementation("org.java-websocket:Java-WebSocket:1.6.0")
}

kotlin {
    jvmToolchain(21)
}

tasks.shadowJar {
    archiveClassifier.set("")
    relocate("org.java_websocket", "com.example.packetmonitor.libs.websocket")
}
rootProject.name = "packet-monitor"
package com.example.packetmonitor

class PacketMonitorPlugin

Your build pipeline is locked in. Next up, you will write the real plugin class with Velocity lifecycle annotations and register a PacketEvents listener.

Implement the Plugin Lifecycle and PacketEvents Registration

Your Gradle project builds and produces a fat JAR. Now you need to turn that empty class into a real Velocity plugin that the proxy recognizes and loads.

Velocity uses annotation-based discovery. You annotate your main class with @Plugin metadata and inject the proxy's services through the constructor. Once the proxy finishes startup, it fires a ProxyInitializeEvent where you register your PacketEvents listener.

In this step, get ready to:
  • Build the main plugin class with Velocity annotations and dependency injection.
  • Create a stub PacketEvents listener that compiles without real logic.
  • Deploy the shadow JAR and verify the plugin loads on the proxy.
Write the main plugin class with Velocity annotations

Velocity discovers your plugin through the @Plugin annotation at compile time. The annotation processor generates a metadata file inside the JAR so Velocity knows your plugin's ID, name, and version without scanning bytecode at runtime.

  • In IntelliJ IDEA, open src/main/kotlin/com/example/packetmonitor/PacketMonitorPlugin.kt.
  • Replace the entire file contents with the following code:
package com.example.packetmonitor

import com.github.retrooper.packetevents.PacketEvents
import com.github.retrooper.packetevents.event.PacketListenerPriority
import com.velocitypowered.api.event.Subscribe
import com.velocitypowered.api.event.proxy.ProxyInitializeEvent
import com.velocitypowered.api.event.proxy.ProxyShutdownEvent
import com.velocitypowered.api.plugin.Plugin
import com.velocitypowered.api.plugin.PluginContainer
import com.velocitypowered.api.plugin.annotation.DataDirectory
import com.velocitypowered.api.proxy.ProxyServer
import jakarta.inject.Inject
import org.slf4j.Logger
import java.nio.file.Path
import java.time.Duration

// Velocity discovers this class via the @Plugin annotation at compile time
@Plugin(
    id = "packet-monitor",
    name = "Packet Monitor",
    version = "1.0.0",
    description = "Real-time packet monitoring with WebSocket dashboard",
    authors = ["NextWork"]
)
class PacketMonitorPlugin @Inject constructor(
    private val server: ProxyServer,
    private val logger: Logger,
    private val pluginContainer: PluginContainer,
    @DataDirectory private val dataDirectory: Path
) {
    // Atomic counters for thread-safe packet counting (implemented next step)
    private val metricsCollector = MetricsCollector()
    // Fixed-size history buffer for 30 seconds of snapshots
    private val ringBuffer = RingBuffer(150)
    // WebSocket server for streaming to the dashboard
    private lateinit var wsServer: MetricsWebSocketServer
    // Unique ID for this proxy instance
    private val serverId = System.getenv("PROXY_ID") ?: "proxy-1"

    @Subscribe
    fun onProxyInitialize(event: ProxyInitializeEvent) {
        // Register our listener with PacketEvents at MONITOR priority (read-only, runs last)
        PacketEvents.getAPI().eventManager.registerListener(
            PacketMonitorListener(metricsCollector),
            PacketListenerPriority.MONITOR
        )
        logger.info("Packet listener registered with PacketEvents")
    }

    @Subscribe
    fun onProxyShutdown(event: ProxyShutdownEvent) {
        logger.info("Packet Monitor shut down")
    }
}

What does this code do?

The @Plugin annotation tells Velocity everything it needs to identify and load your plugin. The annotation processor bakes this into the JAR so discovery is instant.

The @Inject constructor asks Velocity to hand you its core services: the ProxyServer for scheduling tasks and querying players, a Logger for console output, and a data directory path for config files.

Inside onProxyInitialize, you register your packet listener at MONITOR priority. This priority runs last, guaranteeing you only observe packets without interfering with other plugins that might cancel or modify them.

Why MONITOR priority?

PacketEvents processes listeners in priority order. MONITOR is the final tier, designed for read-only observation. Your counter increments happen after all other plugins have had their chance to cancel or rewrite the packet. This means your metrics reflect what actually reached the player, not what was originally sent.

Create the PacketEvents listener stub

The plugin class references PacketMonitorListener which does not exist yet. You also reference MetricsCollector, RingBuffer, and MetricsWebSocketServer. You need to create stub files for all of these so the project compiles.

  • Create a new file PacketMonitorListener.kt in the same com/example/packetmonitor/ package directory by pasting the following code:
package com.example.packetmonitor

import com.github.retrooper.packetevents.event.PacketListenerAbstract
import com.github.retrooper.packetevents.event.PacketListenerPriority
import com.github.retrooper.packetevents.event.PacketReceiveEvent
import com.github.retrooper.packetevents.event.PacketSendEvent

// Stub listener - real packet counting logic added in the next step
class PacketMonitorListener(
    private val metrics: MetricsCollector
) : PacketListenerAbstract(PacketListenerPriority.MONITOR) {

    override fun onPacketReceive(event: PacketReceiveEvent) {}

    override fun onPacketSend(event: PacketSendEvent) {}
}

What does this code do?

This class extends PacketListenerAbstract from PacketEvents. The two override methods fire every time a packet arrives from or is sent to a player. Right now the bodies are empty. In the next step you will add atomic counter increments here.

Passing PacketListenerPriority.MONITOR to the superclass constructor ensures this listener is registered at the correct priority level.

  • Create MetricsCollector.kt in the same package directory with this placeholder code:
package com.example.packetmonitor

// Placeholder - full atomic counter implementation in Step 3
class MetricsCollector
  • Create RingBuffer.kt:
package com.example.packetmonitor

// Placeholder - full ring buffer implementation in Step 3
class RingBuffer(private val capacity: Int)
  • Create MetricsWebSocketServer.kt:
package com.example.packetmonitor

import org.java_websocket.server.WebSocketServer
import java.net.InetSocketAddress

// Placeholder - full WebSocket server implementation in Step 4
class MetricsWebSocketServer(
    port: Int,
    private val ringBuffer: RingBuffer
) : WebSocketServer(InetSocketAddress(port)) {
    override fun onOpen(conn: org.java_websocket.WebSocket?, handshake: org.java_websocket.handshake.ClientHandshake?) {}
    override fun onClose(conn: org.java_websocket.WebSocket?, code: Int, reason: String?, remote: Boolean) {}
    override fun onMessage(conn: org.java_websocket.WebSocket?, message: String?) {}
    override fun onError(conn: org.java_websocket.WebSocket?, ex: Exception?) {}
    override fun onStart() {}
}

Why create placeholder files?

Kotlin requires all referenced types to exist at compile time. These stubs satisfy the compiler now. You will replace each one with a full implementation in the upcoming steps.

This incremental approach lets you verify the plugin lifecycle works before adding complex logic.

  • Save all five files.

Seeing unresolved reference errors?

Make sure all stub files are in the same package directory: src/main/kotlin/com/example/packetmonitor/. Each file must have package com.example.packetmonitor as its first line.

If IntelliJ still shows red underlines, click File, then Invalidate Caches, and restart the IDE. Sometimes the index needs a refresh after creating multiple new files.

Help me figure out what's wrong with my package structure or imports

Build, deploy, and verify on the proxy

With all files in place, you can build the shadow JAR and test it on your running Velocity proxy.

  • Build the fat JAR by running this command in your project's terminal:
gradlew.bat shadowJar
  • Confirm the build succeeds with no errors. The output JAR appears in build/libs/.
  • Copy the JAR to your Velocity proxy's plugins/ folder (alongside the existing PacketEvents JAR) by running:
copy build\libs\packet-monitor-1.0.0.jar [[VELOCITY_PLUGINS="path\\to\\your\\velocity\\plugins folder"]]
  • Start (or restart) your Velocity proxy.
  • Watch the console output for your plugin's log messages.

You should see two lines in the Velocity console confirming successful loading: one from Velocity itself loading the plugin, and your own Packet listener registered with PacketEvents message.

What should I see?

Velocity prints plugin load messages in the format [packet-monitor] followed by your logger output. If you see the registration message without any stack traces or errors, your plugin lifecycle is working correctly and PacketEvents accepted your listener.

Plugin not loading or showing errors?

  • Verify the PacketEvents JAR is also in the plugins/ folder. Your plugin calls PacketEvents.getAPI() which requires PacketEvents to be loaded first.
  • Check that you are using the shadow JAR (the one without a classifier suffix), not the plain JAR. The shadow JAR contains the relocated Java-WebSocket classes your plugin needs.
  • If you see a ClassNotFoundException for WebSocketServer, the relocation in your build.gradle.kts may not be working. Re-run gradlew.bat shadowJar and check the task output for warnings.

Help me diagnose why my packet-monitor plugin is not loading on Velocity

Declare PacketEvents as a plugin dependency

For production plugins, you should declare PacketEvents as a dependency so Velocity guarantees it loads first. Add a dependencies parameter to your @Plugin annotation with @Dependency(id = "packetevents"). For this project, PacketEvents loads before your plugin alphabetically, so it works without the explicit declaration.

✔️ Awesome, I've got everything!

Great. Double check you have saved all your files and your Velocity console shows the plugin loaded successfully.

ⓧ I'd like to double check the full code

Here is the complete PacketMonitorPlugin.kt:

package com.example.packetmonitor

import com.github.retrooper.packetevents.PacketEvents
import com.github.retrooper.packetevents.event.PacketListenerPriority
import com.velocitypowered.api.event.Subscribe
import com.velocitypowered.api.event.proxy.ProxyInitializeEvent
import com.velocitypowered.api.event.proxy.ProxyShutdownEvent
import com.velocitypowered.api.plugin.Plugin
import com.velocitypowered.api.plugin.PluginContainer
import com.velocitypowered.api.plugin.annotation.DataDirectory
import com.velocitypowered.api.proxy.ProxyServer
import jakarta.inject.Inject
import org.slf4j.Logger
import java.nio.file.Path
import java.time.Duration

@Plugin(
    id = "packet-monitor",
    name = "Packet Monitor",
    version = "1.0.0",
    description = "Real-time packet monitoring with WebSocket dashboard",
    authors = ["NextWork"]
)
class PacketMonitorPlugin @Inject constructor(
    private val server: ProxyServer,
    private val logger: Logger,
    private val pluginContainer: PluginContainer,
    @DataDirectory private val dataDirectory: Path
) {
    private val metricsCollector = MetricsCollector()
    private val ringBuffer = RingBuffer(150)
    private lateinit var wsServer: MetricsWebSocketServer
    private val serverId = System.getenv("PROXY_ID") ?: "proxy-1"

    @Subscribe
    fun onProxyInitialize(event: ProxyInitializeEvent) {
        PacketEvents.getAPI().eventManager.registerListener(
            PacketMonitorListener(metricsCollector),
            PacketListenerPriority.MONITOR
        )
        logger.info("Packet listener registered with PacketEvents")
    }

    @Subscribe
    fun onProxyShutdown(event: ProxyShutdownEvent) {
        logger.info("Packet Monitor shut down")
    }
}

And the complete PacketMonitorListener.kt:

package com.example.packetmonitor

import com.github.retrooper.packetevents.event.PacketListenerAbstract
import com.github.retrooper.packetevents.event.PacketListenerPriority
import com.github.retrooper.packetevents.event.PacketReceiveEvent
import com.github.retrooper.packetevents.event.PacketSendEvent

class PacketMonitorListener(
    private val metrics: MetricsCollector
) : PacketListenerAbstract(PacketListenerPriority.MONITOR) {

    override fun onPacketReceive(event: PacketReceiveEvent) {}

    override fun onPacketSend(event: PacketSendEvent) {}
}

Your plugin is alive on the proxy and PacketEvents is routing every packet through your listener. Next up, you will fill those empty listener methods with lock-free atomic counters that safely count packets across Netty I/O threads.

Build the Lock-Free Metrics Engine

Your plugin is loaded and PacketEvents is listening for packets. But the listener bodies are empty. No data is being captured yet.

In this step, you will build the metrics engine that counts every packet and byte flowing through the proxy. The engine uses lock-free atomic counters so it never blocks Netty I/O threads, and stores 30 seconds of rolling history in a ring buffer.

In this step, get ready to:
  • Create a lock-free metrics collector with atomic counters.
  • Wire the packet listener to record inbound and outbound bytes.
  • Build a ring buffer and schedule a 200ms collection task.
Create the MetricSnapshot data class

Before building the collector, you need a container to hold each measurement. A data class with default values makes it easy to initialize an empty ring buffer later.

  • Create a new file called MetricSnapshot.kt inside src/main/kotlin/com/example/packetmonitor/.
  • Add the following data class:
package com.example.packetmonitor

data class MetricSnapshot(
    val timestamp: Long = 0,
    val packetsIn: Long = 0,
    val packetsOut: Long = 0,
    val bytesIn: Long = 0,
    val bytesOut: Long = 0,
    val players: Int = 0,
    val ppsIn: Long = 0,
    val ppsOut: Long = 0,
    val bpsIn: Long = 0,
    val bpsOut: Long = 0
) {
    // Serialize snapshot to JSON for WebSocket broadcasting
    fun toJson(serverId: String): String {
        return """{"serverId":"$serverId","timestamp":$timestamp,"packetsIn":$packetsIn,"packetsOut":$packetsOut,"bytesIn":$bytesIn,"bytesOut":$bytesOut,"ppsIn":$ppsIn,"ppsOut":$ppsOut,"bpsIn":$bpsIn,"bpsOut":$bpsOut,"players":$players}"""
    }
}

What does this code do?

This data class holds a single point-in-time measurement of proxy traffic. Every field defaults to 0 so you can create empty placeholders for the ring buffer.

The toJson method produces a compact JSON string using Kotlin string templates. No JSON library needed for a flat structure like this.

  • Save MetricSnapshot.kt.

Red underlines in your IDE?

If IntelliJ shows unresolved reference errors, that is expected. The MetricsCollector class that uses this snapshot does not exist yet. The errors will resolve as you add the remaining files.

Why is IntelliJ showing errors before I finish adding all files?

Create the MetricsCollector with atomic counters

Velocity's Netty I/O threads handle thousands of packets per second. A traditional synchronized block would force threads to wait for each other. Atomic counters let every thread update counts simultaneously without locking.

  • Create a new file called MetricsCollector.kt inside src/main/kotlin/com/example/packetmonitor/.
  • Add the following class:
package com.example.packetmonitor

import java.util.concurrent.atomic.AtomicLong

class MetricsCollector {
    // Lock-free counters safe for concurrent Netty I/O threads
    val packetsIn = AtomicLong(0)
    val packetsOut = AtomicLong(0)
    val bytesIn = AtomicLong(0)
    val bytesOut = AtomicLong(0)

    // Called from Netty threads on every inbound packet
    fun recordInbound(bytes: Int) {
        packetsIn.incrementAndGet()
        bytesIn.addAndGet(bytes.toLong())
    }

    // Called from Netty threads on every outbound packet
    fun recordOutbound(bytes: Int) {
        packetsOut.incrementAndGet()
        bytesOut.addAndGet(bytes.toLong())
    }

    // Atomically read and reset all counters, compute per-second rates
    fun drain(currentPlayers: Int): MetricSnapshot {
        val pIn = packetsIn.getAndSet(0)
        val pOut = packetsOut.getAndSet(0)
        val bIn = bytesIn.getAndSet(0)
        val bOut = bytesOut.getAndSet(0)

        return MetricSnapshot(
            timestamp = System.currentTimeMillis(),
            packetsIn = pIn,
            packetsOut = pOut,
            bytesIn = bIn,
            bytesOut = bOut,
            players = currentPlayers,
            ppsIn = pIn * 5,
            ppsOut = pOut * 5,
            bpsIn = bIn * 5,
            bpsOut = bOut * 5
        )
    }
}

What does this code do?

Four AtomicLong fields track raw packet and byte counts without locks. Multiple Netty threads can call recordInbound and recordOutbound simultaneously. The CPU handles the atomic increment in hardware.

The drain method uses getAndSet(0) to read and reset each counter in one atomic operation. It multiplies raw counts by 5 to convert from per-200ms values to per-second rates (since 200ms is 1/5 of a second).

  • Save MetricsCollector.kt.

Why lock-free instead of synchronized?

Netty I/O threads must never block. If a packet handler waits on a lock held by another thread, all packets queued behind it stall. Atomic operations complete in constant time with no waiting, so packet forwarding latency stays at zero.

✔️ Awesome, I've got everything!

Great. Double check you have saved MetricsCollector.kt and MetricSnapshot.kt.

ⓧ I'd like to double check the full code

Here is the full MetricsCollector.kt:

package com.example.packetmonitor

import java.util.concurrent.atomic.AtomicLong

class MetricsCollector {
    val packetsIn = AtomicLong(0)
    val packetsOut = AtomicLong(0)
    val bytesIn = AtomicLong(0)
    val bytesOut = AtomicLong(0)

    fun recordInbound(bytes: Int) {
        packetsIn.incrementAndGet()
        bytesIn.addAndGet(bytes.toLong())
    }

    fun recordOutbound(bytes: Int) {
        packetsOut.incrementAndGet()
        bytesOut.addAndGet(bytes.toLong())
    }

    fun drain(currentPlayers: Int): MetricSnapshot {
        val pIn = packetsIn.getAndSet(0)
        val pOut = packetsOut.getAndSet(0)
        val bIn = bytesIn.getAndSet(0)
        val bOut = bytesOut.getAndSet(0)

        return MetricSnapshot(
            timestamp = System.currentTimeMillis(),
            packetsIn = pIn,
            packetsOut = pOut,
            bytesIn = bIn,
            bytesOut = bOut,
            players = currentPlayers,
            ppsIn = pIn * 5,
            ppsOut = pOut * 5,
            bpsIn = bIn * 5,
            bpsOut = bOut * 5
        )
    }
}

And the full MetricSnapshot.kt:

package com.example.packetmonitor

data class MetricSnapshot(
    val timestamp: Long = 0,
    val packetsIn: Long = 0,
    val packetsOut: Long = 0,
    val bytesIn: Long = 0,
    val bytesOut: Long = 0,
    val players: Int = 0,
    val ppsIn: Long = 0,
    val ppsOut: Long = 0,
    val bpsIn: Long = 0,
    val bpsOut: Long = 0
) {
    fun toJson(serverId: String): String {
        return """{"serverId":"$serverId","timestamp":$timestamp,"packetsIn":$packetsIn,"packetsOut":$packetsOut,"bytesIn":$bytesIn,"bytesOut":$bytesOut,"ppsIn":$ppsIn,"ppsOut":$ppsOut,"bpsIn":$bpsIn,"bpsOut":$bpsOut,"players":$players}"""
    }
}
Wire the packet listener to record metrics

The listener stub from the previous step has empty method bodies. Now you will fill them in to capture every packet's size and pass it to the collector.

  • In PacketMonitorListener.kt, find the empty onPacketReceive and onPacketSend method bodies.
  • Replace the entire file contents with the following:
package com.example.packetmonitor

import com.github.retrooper.packetevents.event.PacketListenerAbstract
import com.github.retrooper.packetevents.event.PacketListenerPriority
import com.github.retrooper.packetevents.event.PacketReceiveEvent
import com.github.retrooper.packetevents.event.PacketSendEvent
import io.netty.buffer.ByteBuf

class PacketMonitorListener(
    private val metrics: MetricsCollector
) : PacketListenerAbstract(PacketListenerPriority.MONITOR) {

    // Read inbound packet size without modifying the buffer
    override fun onPacketReceive(event: PacketReceiveEvent) {
        val byteBuf = event.byteBuf as? ByteBuf ?: return
        metrics.recordInbound(byteBuf.readableBytes())
    }

    // Read outbound packet size without modifying the buffer
    override fun onPacketSend(event: PacketSendEvent) {
        val byteBuf = event.byteBuf as? ByteBuf ?: return
        metrics.recordOutbound(byteBuf.readableBytes())
    }
}

What does this code do?

Each method casts event.byteBuf to Netty's ByteBuf type. The safe cast (as?) returns null if the cast fails, and ?: return exits early in that case.

Calling readableBytes() gives the packet's wire size without consuming or modifying the buffer. The listener never cancels, modifies, or allocates objects beyond the two atomic counter updates.

  • Save PacketMonitorListener.kt.

Unresolved reference to ByteBuf?

Make sure the import reads io.netty.buffer.ByteBuf. Netty is on the classpath because Velocity itself is built on Netty. You do not need to add a Netty dependency to your build.gradle.kts.

Help me resolve a ByteBuf import error in my Velocity plugin.

✔️ Awesome, I've got everything!

Great. Make sure PacketMonitorListener.kt is saved.

ⓧ I'd like to double check the full code

Here is the complete PacketMonitorListener.kt:

package com.example.packetmonitor

import com.github.retrooper.packetevents.event.PacketListenerAbstract
import com.github.retrooper.packetevents.event.PacketListenerPriority
import com.github.retrooper.packetevents.event.PacketReceiveEvent
import com.github.retrooper.packetevents.event.PacketSendEvent
import io.netty.buffer.ByteBuf

class PacketMonitorListener(
    private val metrics: MetricsCollector
) : PacketListenerAbstract(PacketListenerPriority.MONITOR) {

    override fun onPacketReceive(event: PacketReceiveEvent) {
        val byteBuf = event.byteBuf as? ByteBuf ?: return
        metrics.recordInbound(byteBuf.readableBytes())
    }

    override fun onPacketSend(event: PacketSendEvent) {
        val byteBuf = event.byteBuf as? ByteBuf ?: return
        metrics.recordOutbound(byteBuf.readableBytes())
    }
}
Create the ring buffer and schedule the collection task

The collector accumulates raw counts, but you need somewhere to store historical snapshots. A ring buffer holds exactly 150 slots of metric data. At one snapshot every 200ms, that gives you 30 seconds of scrolling history for the dashboard.

  • Create a new file called RingBuffer.kt inside src/main/kotlin/com/example/packetmonitor/.
  • Add the following class:
package com.example.packetmonitor

class RingBuffer(private val capacity: Int) {
    // Fixed-size array pre-filled with empty snapshots
    private val buffer = Array(capacity) { MetricSnapshot() }
    private var writeIndex = 0

    // Write the latest snapshot, overwriting the oldest entry
    @Synchronized
    fun write(snapshot: MetricSnapshot) {
        buffer[writeIndex % capacity] = snapshot
        writeIndex++
    }

    // Return all stored snapshots in chronological order
    @Synchronized
    fun getHistory(): List<MetricSnapshot> {
        val count = minOf(writeIndex, capacity)
        if (count == 0) return emptyList()
        val start = if (writeIndex > capacity) writeIndex - capacity else 0
        val result = ArrayList<MetricSnapshot>(count)
        for (i in start until start + count) {
            result.add(buffer[i % capacity])
        }
        return result
    }
}

What does this code do?

The ring buffer uses modular arithmetic (writeIndex % capacity) to wrap around when it reaches the end. Once full, new entries overwrite the oldest ones automatically.

The @Synchronized annotation protects against concurrent access. Only one thread writes (the scheduler), but the WebSocket server thread reads history when a client connects. This simple lock is fine because reads and writes are both fast array operations.

  • Save RingBuffer.kt.

Why 150 slots?

At one snapshot every 200ms, 150 slots hold exactly 30 seconds of data (150 x 200ms = 30,000ms). This gives the dashboard a meaningful window of history to chart without consuming much memory.

Now you need to schedule a repeating task that drains the collector every 200ms and feeds snapshots into the ring buffer.

  • In PacketMonitorPlugin.kt, find this line inside the class body:
private val serverId = System.getenv("PROXY_ID") ?: "proxy-1"
  • Add a new field directly below it:
private var snapshotCount = 0
  • Next, find the onProxyInitialize method. Locate this line at the end of the method body:
logger.info("Packet listener registered with PacketEvents")
  • Add the following scheduler block below that log line, replacing anything else that was in the method after it:
        server.scheduler.buildTask(this) {
            val snapshot = metricsCollector.drain(server.playerCount)
            ringBuffer.write(snapshot)
            snapshotCount++
            if (snapshotCount % 25 == 0) {
                logger.info("Snapshot: ppsIn=${snapshot.ppsIn} ppsOut=${snapshot.ppsOut} bpsIn=${snapshot.bpsIn} bpsOut=${snapshot.bpsOut} players=${snapshot.players}")
            }
        }.repeat(Duration.ofMillis(200)).schedule()

        logger.info("Packet Monitor active")

What does this code do?

The Velocity scheduler runs the lambda every 200ms. Inside it, drain atomically harvests all counters, resets them to zero, and returns a snapshot. That snapshot goes into the ring buffer.

The snapshotCount % 25 check prints a log line every 5 seconds (25 x 200ms). This temporary logging lets you verify the engine works before the WebSocket dashboard is built.

  • Save PacketMonitorPlugin.kt.
  • Rebuild the plugin by running this command from your project root:
gradlew.bat shadowJar
  • Copy the new JAR from build/libs/ into your Velocity plugins/ folder, replacing the old one.
  • Restart the Velocity proxy.
  • Connect a Minecraft client through the proxy. After about 5 seconds, you should see a log line in the Velocity console showing non-zero ppsIn, ppsOut, bpsIn, and bpsOut values.

Seeing all zeros in the log?

If every value stays at 0, the packet listener is not recording. Check that PacketMonitorListener.kt has the import io.netty.buffer.ByteBuf line and that the method bodies use event.byteBuf as? ByteBuf (not event.getByteBuf()).

Also confirm your Minecraft client is fully connected to a backend server. Packets only flow after the handshake completes.

Help me debug why my packet monitor shows zero counts.

✔️ Awesome, I've got everything!

Great. Make sure you see non-zero values in the periodic log line.

ⓧ I'd like to double check the full code

Here is the complete PacketMonitorPlugin.kt at this point:

package com.example.packetmonitor

import com.github.retrooper.packetevents.PacketEvents
import com.github.retrooper.packetevents.event.PacketListenerPriority
import com.velocitypowered.api.event.Subscribe
import com.velocitypowered.api.event.proxy.ProxyInitializeEvent
import com.velocitypowered.api.event.proxy.ProxyShutdownEvent
import com.velocitypowered.api.plugin.Plugin
import com.velocitypowered.api.plugin.PluginContainer
import com.velocitypowered.api.plugin.annotation.DataDirectory
import com.velocitypowered.api.proxy.ProxyServer
import jakarta.inject.Inject
import org.slf4j.Logger
import java.nio.file.Path
import java.time.Duration

@Plugin(
    id = "packet-monitor",
    name = "Packet Monitor",
    version = "1.0.0",
    description = "Real-time packet monitoring with WebSocket dashboard",
    authors = ["NextWork"]
)
class PacketMonitorPlugin @Inject constructor(
    private val server: ProxyServer,
    private val logger: Logger,
    private val pluginContainer: PluginContainer,
    @DataDirectory private val dataDirectory: Path
) {
    private val metricsCollector = MetricsCollector()
    private val ringBuffer = RingBuffer(150)
    private lateinit var wsServer: MetricsWebSocketServer
    private val serverId = System.getenv("PROXY_ID") ?: "proxy-1"
    private var snapshotCount = 0

    @Subscribe
    fun onProxyInitialize(event: ProxyInitializeEvent) {
        PacketEvents.getAPI().eventManager.registerListener(
            PacketMonitorListener(metricsCollector),
            PacketListenerPriority.MONITOR
        )
        logger.info("Packet listener registered with PacketEvents")

        server.scheduler.buildTask(this) {
            val snapshot = metricsCollector.drain(server.playerCount)
            ringBuffer.write(snapshot)
            snapshotCount++
            if (snapshotCount % 25 == 0) {
                logger.info("Snapshot: ppsIn=${snapshot.ppsIn} ppsOut=${snapshot.ppsOut} bpsIn=${snapshot.bpsIn} bpsOut=${snapshot.bpsOut} players=${snapshot.players}")
            }
        }.repeat(Duration.ofMillis(200)).schedule()

        logger.info("Packet Monitor active")
    }

    @Subscribe
    fun onProxyShutdown(event: ProxyShutdownEvent) {
        logger.info("Packet Monitor shut down")
    }
}

And the complete RingBuffer.kt:

package com.example.packetmonitor

class RingBuffer(private val capacity: Int) {
    private val buffer = Array(capacity) { MetricSnapshot() }
    private var writeIndex = 0

    @Synchronized
    fun write(snapshot: MetricSnapshot) {
        buffer[writeIndex % capacity] = snapshot
        writeIndex++
    }

    @Synchronized
    fun getHistory(): List<MetricSnapshot> {
        val count = minOf(writeIndex, capacity)
        if (count == 0) return emptyList()
        val start = if (writeIndex > capacity) writeIndex - capacity else 0
        val result = ArrayList<MetricSnapshot>(count)
        for (i in start until start + count) {
            result.add(buffer[i % capacity])
        }
        return result
    }
}

Your metrics engine is collecting live traffic data and storing 30 seconds of history. Next up, you will build a WebSocket server that streams these snapshots to a browser dashboard in real time.

Add the WebSocket Server and JSON Broadcasting

Your metrics engine is collecting real-time packet data and storing snapshots in the ring buffer. The next challenge is getting that data out of the proxy process and into a browser where you can visualize it.

WebSocket provides a persistent, two-way connection between your plugin and any number of dashboard clients. In this step, you'll embed a WebSocket server inside the plugin that broadcasts JSON snapshots every 200ms to every connected client.

In this step, get ready to:
  • Create a WebSocket server class that manages client connections and handles history requests.
  • Wire the server into the plugin lifecycle and broadcast metric snapshots every 200ms.
  • Test the live WebSocket connection from a browser console.
Create the WebSocket server class

The Java-WebSocket library you shaded into your JAR in Step 1 provides a WebSocketServer base class. Your implementation will track connected clients, broadcast metrics to all of them, and respond to a history command that sends the full ring buffer on connect.

  • Create a new file called MetricsWebSocketServer.kt in src/main/kotlin/com/example/packetmonitor/.
  • Paste the following code into the file:
package com.example.packetmonitor

import org.java_websocket.WebSocket
import org.java_websocket.handshake.ClientHandshake
import org.java_websocket.server.WebSocketServer
import java.net.InetSocketAddress
import java.util.concurrent.CopyOnWriteArraySet

// WebSocket server that streams metric snapshots to dashboard clients
class MetricsWebSocketServer(
    port: Int,
    private val ringBuffer: RingBuffer
) : WebSocketServer(InetSocketAddress(port)) {

    // Thread-safe set for tracking connected dashboard clients
    private val clients = CopyOnWriteArraySet<WebSocket>()

    override fun onOpen(conn: WebSocket, handshake: ClientHandshake) {
        clients.add(conn)
    }

    override fun onClose(conn: WebSocket, code: Int, reason: String, remote: Boolean) {
        clients.remove(conn)
    }

    // When a client sends "history", respond with the full ring buffer as JSON
    override fun onMessage(conn: WebSocket, message: String) {
        if (message == "history") {
            val history = ringBuffer.getHistory()
            val json = buildString {
                append("[")
                history.forEachIndexed { index, snap ->
                    if (index > 0) append(",")
                    append(snap.toJson(""))
                }
                append("]")
            }
            conn.send(json)
        }
    }

    override fun onError(conn: WebSocket?, ex: Exception) {
        conn?.let { clients.remove(it) }
    }

    // Disable the built-in connection-lost detection
    override fun onStart() {
        connectionLostTimeout = 0
    }

    // Push a JSON string to every connected client
    fun broadcastJson(text: String) {
        for (client in clients) {
            if (client.isOpen) {
                client.send(text)
            }
        }
    }
}

What does this code do?

  • CopyOnWriteArraySet<WebSocket> tracks connected clients. Writes (connect/disconnect) are rare compared to reads (every 200ms broadcast), making this the ideal concurrent set.
  • onMessage listens for the literal string "history". When a dashboard first connects, it sends this to backfill 30 seconds of chart data instantly.
  • connectionLostTimeout = 0 disables the library's built-in ping/pong timeout. Your 200ms broadcasts already prove liveness.
  • broadcastJson iterates all clients and sends only to open connections, silently skipping any that disconnected between iterations.
  • Save MetricsWebSocketServer.kt.

You won't see a change yet. The next substep wires this server into the plugin lifecycle.

Wire the server into the plugin lifecycle

The WebSocket server needs to start when the proxy initializes and stop when it shuts down. You also need to update the 200ms scheduled task to broadcast each snapshot as JSON instead of logging it.

  • Open PacketMonitorPlugin.kt.
  • Replace the entire file contents with the following updated version:
package com.example.packetmonitor

import com.github.retrooper.packetevents.PacketEvents
import com.github.retrooper.packetevents.event.PacketListenerPriority
import com.velocitypowered.api.event.Subscribe
import com.velocitypowered.api.event.proxy.ProxyInitializeEvent
import com.velocitypowered.api.event.proxy.ProxyShutdownEvent
import com.velocitypowered.api.plugin.Plugin
import com.velocitypowered.api.plugin.PluginContainer
import com.velocitypowered.api.plugin.annotation.DataDirectory
import com.velocitypowered.api.proxy.ProxyServer
import jakarta.inject.Inject
import org.slf4j.Logger
import java.nio.file.Path
import java.time.Duration

@Plugin(
    id = "packet-monitor",
    name = "Packet Monitor",
    version = "1.0.0",
    description = "Real-time packet monitoring with WebSocket dashboard",
    authors = ["NextWork"]
)
class PacketMonitorPlugin @Inject constructor(
    private val server: ProxyServer,
    private val logger: Logger,
    private val pluginContainer: PluginContainer,
    @DataDirectory private val dataDirectory: Path
) {
    private val metricsCollector = MetricsCollector()
    private val ringBuffer = RingBuffer(150)
    private lateinit var wsServer: MetricsWebSocketServer
    private val serverId = System.getenv("PROXY_ID") ?: "proxy-1"

    @Subscribe
    fun onProxyInitialize(event: ProxyInitializeEvent) {
        // Register the packet listener with PacketEvents
        PacketEvents.getAPI().eventManager.registerListener(
            PacketMonitorListener(metricsCollector),
            PacketListenerPriority.MONITOR
        )
        logger.info("Packet listener registered with PacketEvents")

        // Start the WebSocket server on a daemon thread
        wsServer = MetricsWebSocketServer(9100, ringBuffer)
        val wsThread = Thread(wsServer, "PacketMonitor-WS")
        wsThread.isDaemon = true
        wsThread.start()
        logger.info("WebSocket server started on port 9100")

        // Schedule the 200ms task that drains counters and broadcasts JSON
        server.scheduler.buildTask(this) {
            val snapshot = metricsCollector.drain(server.playerCount)
            ringBuffer.write(snapshot)
            val json = snapshot.toJson(serverId)
            wsServer.broadcastJson(json)
        }.repeat(Duration.ofMillis(200)).schedule()

        logger.info("Packet Monitor active - dashboard at ws://localhost:9100")
    }

    @Subscribe
    fun onProxyShutdown(event: ProxyShutdownEvent) {
        // Gracefully stop the WebSocket server with a 1-second timeout
        wsServer.stop(1000)
        logger.info("Packet Monitor shut down")
    }
}

What changed from the previous version?

  • The debug snapshotCount and logging are gone. You no longer need them because the WebSocket stream is the verification mechanism.
  • The WebSocket server is created with MetricsWebSocketServer(9100, ringBuffer) and started on a daemon thread. A daemon thread automatically dies when the JVM exits, so it won't keep the proxy alive.
  • The scheduled task now calls snapshot.toJson(serverId) to build a JSON string, then wsServer.broadcastJson(json) to push it to all connected dashboards.
  • wsServer.stop(1000) in the shutdown handler gives connected clients 1 second to receive a close frame before the server terminates.

Why a daemon thread?

Java-WebSocket runs its own NIO selector loop on the thread you give it. If that thread were a regular (non-daemon) thread, a crash in your shutdown handler could leave the JVM hanging indefinitely. Marking it as daemon ensures the WebSocket server dies automatically if the proxy exits unexpectedly.

  • Save PacketMonitorPlugin.kt.
Build, deploy, and test the WebSocket stream

With both files saved, rebuild the shadow JAR and deploy it to your Velocity proxy to see the WebSocket server in action.

  • Build the fat JAR by running this command in your project's terminal:
.\gradlew.bat shadowJar
  • Copy the JAR from build/libs/ into your Velocity proxy's plugins/ folder, replacing the previous version.
  • Restart the Velocity proxy.

You should see these log messages in the Velocity console:

Packet listener registered with PacketEvents
WebSocket server started on port 9100
Packet Monitor active - dashboard at ws://localhost:9100
  • Open any browser and press F12 to open DevTools.
  • Switch to the Console tab.
  • Create a WebSocket connection by pasting this line and pressing Enter:
let ws = new WebSocket("ws://localhost:9100"); ws.onmessage = (e) => console.log(JSON.parse(e.data));

You should see JSON objects appearing in the console every 200ms with fields like ppsIn, ppsOut, bpsIn, and players.

  • Test the history command by pasting this line:
ws.send("history");

You should see a large JSON array logged in the console containing all the snapshots your ring buffer has collected so far.

Not seeing any messages?

  • Check that your Velocity console shows the WebSocket server started on port 9100 log. If it's missing, the shadow JAR might not have been copied correctly.
  • Make sure nothing else is using port 9100. If you see an address-in-use error in the Velocity console, stop any other process on that port before restarting.
  • Verify you're connecting to ws://localhost:9100 (not wss://). The server doesn't use TLS.

Help me debug my WebSocket server not receiving connections on port 9100

✔️ Awesome, I've got everything!

Great. Double check you've saved both files before moving on.

ⓧ I'd like to double check the full code

Here is MetricsWebSocketServer.kt:

package com.example.packetmonitor

import org.java_websocket.WebSocket
import org.java_websocket.handshake.ClientHandshake
import org.java_websocket.server.WebSocketServer
import java.net.InetSocketAddress
import java.util.concurrent.CopyOnWriteArraySet

class MetricsWebSocketServer(
    port: Int,
    private val ringBuffer: RingBuffer
) : WebSocketServer(InetSocketAddress(port)) {

    private val clients = CopyOnWriteArraySet<WebSocket>()

    override fun onOpen(conn: WebSocket, handshake: ClientHandshake) {
        clients.add(conn)
    }

    override fun onClose(conn: WebSocket, code: Int, reason: String, remote: Boolean) {
        clients.remove(conn)
    }

    override fun onMessage(conn: WebSocket, message: String) {
        if (message == "history") {
            val history = ringBuffer.getHistory()
            val json = buildString {
                append("[")
                history.forEachIndexed { index, snap ->
                    if (index > 0) append(",")
                    append(snap.toJson(""))
                }
                append("]")
            }
            conn.send(json)
        }
    }

    override fun onError(conn: WebSocket?, ex: Exception) {
        conn?.let { clients.remove(it) }
    }

    override fun onStart() {
        connectionLostTimeout = 0
    }

    fun broadcastJson(text: String) {
        for (client in clients) {
            if (client.isOpen) {
                client.send(text)
            }
        }
    }
}

Here is PacketMonitorPlugin.kt:

package com.example.packetmonitor

import com.github.retrooper.packetevents.PacketEvents
import com.github.retrooper.packetevents.event.PacketListenerPriority
import com.velocitypowered.api.event.Subscribe
import com.velocitypowered.api.event.proxy.ProxyInitializeEvent
import com.velocitypowered.api.event.proxy.ProxyShutdownEvent
import com.velocitypowered.api.plugin.Plugin
import com.velocitypowered.api.plugin.PluginContainer
import com.velocitypowered.api.plugin.annotation.DataDirectory
import com.velocitypowered.api.proxy.ProxyServer
import jakarta.inject.Inject
import org.slf4j.Logger
import java.nio.file.Path
import java.time.Duration

@Plugin(
    id = "packet-monitor",
    name = "Packet Monitor",
    version = "1.0.0",
    description = "Real-time packet monitoring with WebSocket dashboard",
    authors = ["NextWork"]
)
class PacketMonitorPlugin @Inject constructor(
    private val server: ProxyServer,
    private val logger: Logger,
    private val pluginContainer: PluginContainer,
    @DataDirectory private val dataDirectory: Path
) {
    private val metricsCollector = MetricsCollector()
    private val ringBuffer = RingBuffer(150)
    private lateinit var wsServer: MetricsWebSocketServer
    private val serverId = System.getenv("PROXY_ID") ?: "proxy-1"

    @Subscribe
    fun onProxyInitialize(event: ProxyInitializeEvent) {
        PacketEvents.getAPI().eventManager.registerListener(
            PacketMonitorListener(metricsCollector),
            PacketListenerPriority.MONITOR
        )
        logger.info("Packet listener registered with PacketEvents")

        wsServer = MetricsWebSocketServer(9100, ringBuffer)
        val wsThread = Thread(wsServer, "PacketMonitor-WS")
        wsThread.isDaemon = true
        wsThread.start()
        logger.info("WebSocket server started on port 9100")

        server.scheduler.buildTask(this) {
            val snapshot = metricsCollector.drain(server.playerCount)
            ringBuffer.write(snapshot)
            val json = snapshot.toJson(serverId)
            wsServer.broadcastJson(json)
        }.repeat(Duration.ofMillis(200)).schedule()

        logger.info("Packet Monitor active - dashboard at ws://localhost:9100")
    }

    @Subscribe
    fun onProxyShutdown(event: ProxyShutdownEvent) {
        wsServer.stop(1000)
        logger.info("Packet Monitor shut down")
    }
}

Your plugin is now streaming live network data to any connected client. Next up, you'll build a standalone HTML dashboard that turns this JSON stream into real-time line charts.

Build the HTML Canvas Dashboard

Your plugin is now collecting metrics and streaming them over WebSocket every 200ms. The data is flowing, but you have no way to see it beyond raw JSON in a browser console.

In this step, you will build a standalone HTML dashboard that connects to the WebSocket server and renders live packet rate and bandwidth charts on Canvas. The dashboard auto-reconnects, backfills 30 seconds of history on load, and scrolls the charts in real time.

In this step, get ready to:
  • Create the dashboard HTML structure with a status indicator, stats bar, and chart panels.
  • Implement a WebSocket client that receives live JSON and populates fixed-length data arrays.
  • Render dual line charts on Canvas with real-time scrolling and crisp HiDPI rendering.
Create the dashboard layout with HTML and CSS

The dashboard is a single self-contained HTML file with no external dependencies. All CSS and JavaScript live inline so you can open it directly in a browser without a build step or web server.

  • Create a new file called dashboard.html in the root of your packet-monitor project folder (the same level as build.gradle.kts).
  • Add the following HTML structure and styles:
<!DOCTYPE html>
<html lang="en">
<head>
    <meta charset="UTF-8">
    <title>Packet Monitor Dashboard</title>
    <style>
        * { margin: 0; padding: 0; box-sizing: border-box; }
        body { background: #1a1a2e; color: #eee; font-family: monospace; padding: 16px; }
        .header { display: flex; justify-content: space-between; align-items: center; margin-bottom: 16px; }
        .header h1 { font-size: 18px; color: #0ff; }
        .status { padding: 4px 12px; border-radius: 4px; font-size: 12px; }
        .status.connected { background: #0a3; }
        .status.disconnected { background: #a00; }
        .stats-bar { display: flex; gap: 24px; margin-bottom: 16px; background: #16213e; padding: 12px; border-radius: 8px; }
        .stat { text-align: center; }
        .stat-value { font-size: 24px; color: #0ff; }
        .stat-label { font-size: 11px; color: #888; margin-top: 4px; }
        .charts { display: grid; grid-template-columns: 1fr 1fr; gap: 16px; }
        .chart-panel { background: #16213e; border-radius: 8px; padding: 12px; }
        .chart-panel h2 { font-size: 13px; color: #888; margin-bottom: 8px; }
        canvas { width: 100%; height: 200px; display: block; }
    </style>
</head>
<body>
    <!-- Header with connection status indicator -->
    <div class="header">
        <h1>Packet Monitor</h1>
        <span class="status disconnected" id="status">DISCONNECTED</span>
    </div>
    <!-- Stats bar showing live metric values -->
    <div class="stats-bar">
        <div class="stat"><div class="stat-value" id="players">0</div><div class="stat-label">Players</div></div>
        <div class="stat"><div class="stat-value" id="ppsIn">0</div><div class="stat-label">Packets In/s</div></div>
        <div class="stat"><div class="stat-value" id="ppsOut">0</div><div class="stat-label">Packets Out/s</div></div>
        <div class="stat"><div class="stat-value" id="bpsIn">0</div><div class="stat-label">Bytes In/s</div></div>
        <div class="stat"><div class="stat-value" id="bpsOut">0</div><div class="stat-label">Bytes Out/s</div></div>
    </div>
    <!-- Two chart panels side by side -->
    <div class="charts">
        <div class="chart-panel">
            <h2>Packets per Second</h2>
            <canvas id="ppsChart"></canvas>
        </div>
        <div class="chart-panel">
            <h2>Bandwidth (bytes/s)</h2>
            <canvas id="bpsChart"></canvas>
        </div>
    </div>

What does this structure do?

The layout has three vertical sections. The header holds the title and a connection status badge that flips between green (connected) and red (disconnected). The stats bar displays the five key metrics as large readable numbers. Below that, a CSS Grid places two Canvas panels side by side for the charts.

Each element has a unique id so the JavaScript can update text content directly without any DOM querying overhead.

Add the WebSocket client and data management

The dashboard needs to connect to the plugin's WebSocket server, request historical data on first connect, and keep its data arrays capped at 150 entries (matching the ring buffer's 30-second window).

  • Immediately below the closing </div> of the charts section, add a <script> tag with the WebSocket client and data helpers:
    <script>
        // Maximum data points to store (150 = 30 seconds at 200ms intervals)
        var MAX_POINTS = 150;
        var data = { ppsIn: [], ppsOut: [], bpsIn: [], bpsOut: [] };

        // Connect to the plugin's WebSocket server with auto-reconnect
        function connect() {
            var ws = new WebSocket("ws://localhost:9100");
            ws.onopen = function() {
                document.getElementById("status").textContent = "CONNECTED";
                document.getElementById("status").className = "status connected";
                ws.send("history");
            };
            ws.onclose = function() {
                document.getElementById("status").textContent = "DISCONNECTED";
                document.getElementById("status").className = "status disconnected";
                setTimeout(connect, 2000);
            };
            ws.onmessage = function(evt) {
                var parsed = JSON.parse(evt.data);
                if (Array.isArray(parsed)) {
                    for (var i = 0; i < parsed.length; i++) {
                        pushData(parsed[i]);
                    }
                } else {
                    pushData(parsed);
                }
            };
            ws.onerror = function() { ws.close(); };
        }

        // Push a single metric snapshot into data arrays and update the stats bar
        function pushData(m) {
            push(data.ppsIn, m.ppsIn);
            push(data.ppsOut, m.ppsOut);
            push(data.bpsIn, m.bpsIn);
            push(data.bpsOut, m.bpsOut);
            document.getElementById("players").textContent = m.players;
            document.getElementById("ppsIn").textContent = fmt(m.ppsIn);
            document.getElementById("ppsOut").textContent = fmt(m.ppsOut);
            document.getElementById("bpsIn").textContent = fmtBytes(m.bpsIn);
            document.getElementById("bpsOut").textContent = fmtBytes(m.bpsOut);
        }

        // Keep arrays capped at MAX_POINTS by shifting old entries off the front
        function push(arr, val) {
            arr.push(val);
            if (arr.length > MAX_POINTS) arr.shift();
        }

        // Format large numbers with K/M suffixes
        function fmt(n) {
            if (n >= 1000000) return (n / 1000000).toFixed(1) + "M";
            if (n >= 1000) return (n / 1000).toFixed(1) + "K";
            return n.toString();
        }

        // Format byte counts with human-readable units
        function fmtBytes(n) {
            if (n >= 1073741824) return (n / 1073741824).toFixed(1) + " GB";
            if (n >= 1048576) return (n / 1048576).toFixed(1) + " MB";
            if (n >= 1024) return (n / 1024).toFixed(1) + " KB";
            return n + " B";
        }

What does this code do?

  • connect() opens a WebSocket to ws://localhost:9100 and sends "history" on open so the ring buffer's 30 seconds of past data fills the charts immediately.
  • On close, it waits 2 seconds and retries automatically. This means you can restart the proxy without manually refreshing the browser.
  • pushData() handles both array responses (the history backfill) and individual snapshots (the 200ms live stream) using the same logic.
  • push() caps each data array at 150 entries by shifting old values off the front, keeping memory stable.
Add Canvas chart rendering and start the dashboard

The charts use requestAnimationFrame to redraw every frame. Each chart plots two line series (IN and OUT) that scroll left as new data arrives, giving you a live 30-second window into proxy traffic.

  • Directly below the fmtBytes function (still inside the same <script> tag), add the chart rendering functions and initialization calls:
        // Draw a dual-line chart on the specified canvas
        function drawChart(canvasId, series1, series2, color1, color2) {
            var canvas = document.getElementById(canvasId);
            var ctx = canvas.getContext("2d");
            // Use device pixel ratio for crisp rendering on HiDPI screens
            var dpr = window.devicePixelRatio || 1;
            var rect = canvas.getBoundingClientRect();
            canvas.width = rect.width * dpr;
            canvas.height = rect.height * dpr;
            ctx.scale(dpr, dpr);
            var w = rect.width;
            var h = rect.height;

            ctx.clearRect(0, 0, w, h);

            // Find the maximum value across both series for Y-axis scaling
            var allVals = series1.concat(series2);
            var max = 1;
            for (var i = 0; i < allVals.length; i++) {
                if (allVals[i] > max) max = allVals[i];
            }

            drawLine(ctx, series1, w, h, max, color1);
            drawLine(ctx, series2, w, h, max, color2);

            // Y-axis labels
            ctx.fillStyle = "#555";
            ctx.font = "10px monospace";
            ctx.fillText(fmt(max), 2, 12);
            ctx.fillText("0", 2, h - 2);

            // Legend labels
            ctx.fillStyle = color1;
            ctx.fillText("IN", w - 50, 12);
            ctx.fillStyle = color2;
            ctx.fillText("OUT", w - 25, 12);
        }

        // Draw a single line series across the chart width
        function drawLine(ctx, arr, w, h, max, color) {
            if (arr.length < 2) return;
            var step = w / (MAX_POINTS - 1);
            ctx.beginPath();
            ctx.strokeStyle = color;
            ctx.lineWidth = 1.5;
            // Offset start so lines scroll from right to left
            var startX = (MAX_POINTS - arr.length) * step;
            for (var i = 0; i < arr.length; i++) {
                var x = startX + i * step;
                var y = h - (arr[i] / max) * (h - 20);
                if (i === 0) ctx.moveTo(x, y);
                else ctx.lineTo(x, y);
            }
            ctx.stroke();
        }

        // Animation loop redraws both charts every frame
        function render() {
            drawChart("ppsChart", data.ppsIn, data.ppsOut, "#0ff", "#f0f");
            drawChart("bpsChart", data.bpsIn, data.bpsOut, "#0f0", "#fa0");
            requestAnimationFrame(render);
        }

        // Start the WebSocket connection and rendering loop
        connect();
        requestAnimationFrame(render);
    </script>
</body>
</html>

What does this code do?

  • drawChart() sets up the Canvas at the correct device pixel ratio (so lines look sharp on Retina/HiDPI displays), computes the Y-axis max from all visible values, then draws both line series and their legends.
  • drawLine() plots points from left to right with an offset (startX) so that when there are fewer than 150 points, the line starts partway across the chart and scrolls left as data fills in.
  • render() uses requestAnimationFrame to redraw at the browser's native refresh rate. The packet chart uses cyan/magenta and the bandwidth chart uses green/orange.
  • Save dashboard.html.
  • Make sure your Velocity proxy is still running with the Packet Monitor plugin loaded.
  • Open dashboard.html directly in your browser (double-click the file, or drag it into a browser tab).

The status indicator in the top right should turn green and show CONNECTED. The stats bar should start displaying non-zero values. Both charts should begin drawing lines that scroll leftward.

  • Connect a Minecraft client through the proxy. You should see the packet and byte rates jump immediately as the client joins and loads chunks.
  • Move around in-game and watch the charts respond to increased packet volume.
  • Disconnect the client and confirm the charts drop back toward zero.

Dashboard stays DISCONNECTED?

  • Make sure the Velocity proxy is running and the Packet Monitor plugin is loaded. Check the proxy console for the log message confirming the WebSocket server started on port 9100.
  • Check that nothing else is using port 9100. If another process occupies the port, the WebSocket server cannot bind.
  • If you opened the file via a file:// URL, WebSocket connections to localhost should still work in all major browsers. If your browser blocks it, try serving the file with a simple HTTP server instead.

Help me debug my WebSocket dashboard connection

Why Canvas instead of a charting library?

A charting library like Chart.js adds convenience but also adds a dependency and DOM weight. For a monitoring dashboard receiving 5 updates per second, drawing directly on Canvas keeps redraws fast and the file completely self-contained. One HTML file, zero dependencies, zero build steps.

✔️ Awesome, I've got everything!

Great. Double check you have saved dashboard.html and confirmed the charts update in real time.

ⓧ I'd like to double check the full code

Here is the complete dashboard.html file. Compare it with yours to find any differences.

<!DOCTYPE html>
<html lang="en">
<head>
    <meta charset="UTF-8">
    <title>Packet Monitor Dashboard</title>
    <style>
        * { margin: 0; padding: 0; box-sizing: border-box; }
        body { background: #1a1a2e; color: #eee; font-family: monospace; padding: 16px; }
        .header { display: flex; justify-content: space-between; align-items: center; margin-bottom: 16px; }
        .header h1 { font-size: 18px; color: #0ff; }
        .status { padding: 4px 12px; border-radius: 4px; font-size: 12px; }
        .status.connected { background: #0a3; }
        .status.disconnected { background: #a00; }
        .stats-bar { display: flex; gap: 24px; margin-bottom: 16px; background: #16213e; padding: 12px; border-radius: 8px; }
        .stat { text-align: center; }
        .stat-value { font-size: 24px; color: #0ff; }
        .stat-label { font-size: 11px; color: #888; margin-top: 4px; }
        .charts { display: grid; grid-template-columns: 1fr 1fr; gap: 16px; }
        .chart-panel { background: #16213e; border-radius: 8px; padding: 12px; }
        .chart-panel h2 { font-size: 13px; color: #888; margin-bottom: 8px; }
        canvas { width: 100%; height: 200px; display: block; }
    </style>
</head>
<body>
    <div class="header">
        <h1>Packet Monitor</h1>
        <span class="status disconnected" id="status">DISCONNECTED</span>
    </div>
    <div class="stats-bar">
        <div class="stat"><div class="stat-value" id="players">0</div><div class="stat-label">Players</div></div>
        <div class="stat"><div class="stat-value" id="ppsIn">0</div><div class="stat-label">Packets In/s</div></div>
        <div class="stat"><div class="stat-value" id="ppsOut">0</div><div class="stat-label">Packets Out/s</div></div>
        <div class="stat"><div class="stat-value" id="bpsIn">0</div><div class="stat-label">Bytes In/s</div></div>
        <div class="stat"><div class="stat-value" id="bpsOut">0</div><div class="stat-label">Bytes Out/s</div></div>
    </div>
    <div class="charts">
        <div class="chart-panel">
            <h2>Packets per Second</h2>
            <canvas id="ppsChart"></canvas>
        </div>
        <div class="chart-panel">
            <h2>Bandwidth (bytes/s)</h2>
            <canvas id="bpsChart"></canvas>
        </div>
    </div>
    <script>
        var MAX_POINTS = 150;
        var data = { ppsIn: [], ppsOut: [], bpsIn: [], bpsOut: [] };

        function connect() {
            var ws = new WebSocket("ws://localhost:9100");
            ws.onopen = function() {
                document.getElementById("status").textContent = "CONNECTED";
                document.getElementById("status").className = "status connected";
                ws.send("history");
            };
            ws.onclose = function() {
                document.getElementById("status").textContent = "DISCONNECTED";
                document.getElementById("status").className = "status disconnected";
                setTimeout(connect, 2000);
            };
            ws.onmessage = function(evt) {
                var parsed = JSON.parse(evt.data);
                if (Array.isArray(parsed)) {
                    for (var i = 0; i < parsed.length; i++) {
                        pushData(parsed[i]);
                    }
                } else {
                    pushData(parsed);
                }
            };
            ws.onerror = function() { ws.close(); };
        }

        function pushData(m) {
            push(data.ppsIn, m.ppsIn);
            push(data.ppsOut, m.ppsOut);
            push(data.bpsIn, m.bpsIn);
            push(data.bpsOut, m.bpsOut);
            document.getElementById("players").textContent = m.players;
            document.getElementById("ppsIn").textContent = fmt(m.ppsIn);
            document.getElementById("ppsOut").textContent = fmt(m.ppsOut);
            document.getElementById("bpsIn").textContent = fmtBytes(m.bpsIn);
            document.getElementById("bpsOut").textContent = fmtBytes(m.bpsOut);
        }

        function push(arr, val) {
            arr.push(val);
            if (arr.length > MAX_POINTS) arr.shift();
        }

        function fmt(n) {
            if (n >= 1000000) return (n / 1000000).toFixed(1) + "M";
            if (n >= 1000) return (n / 1000).toFixed(1) + "K";
            return n.toString();
        }

        function fmtBytes(n) {
            if (n >= 1073741824) return (n / 1073741824).toFixed(1) + " GB";
            if (n >= 1048576) return (n / 1048576).toFixed(1) + " MB";
            if (n >= 1024) return (n / 1024).toFixed(1) + " KB";
            return n + " B";
        }

        function drawChart(canvasId, series1, series2, color1, color2) {
            var canvas = document.getElementById(canvasId);
            var ctx = canvas.getContext("2d");
            var dpr = window.devicePixelRatio || 1;
            var rect = canvas.getBoundingClientRect();
            canvas.width = rect.width * dpr;
            canvas.height = rect.height * dpr;
            ctx.scale(dpr, dpr);
            var w = rect.width;
            var h = rect.height;

            ctx.clearRect(0, 0, w, h);

            var allVals = series1.concat(series2);
            var max = 1;
            for (var i = 0; i < allVals.length; i++) {
                if (allVals[i] > max) max = allVals[i];
            }

            drawLine(ctx, series1, w, h, max, color1);
            drawLine(ctx, series2, w, h, max, color2);

            ctx.fillStyle = "#555";
            ctx.font = "10px monospace";
            ctx.fillText(fmt(max), 2, 12);
            ctx.fillText("0", 2, h - 2);

            ctx.fillStyle = color1;
            ctx.fillText("IN", w - 50, 12);
            ctx.fillStyle = color2;
            ctx.fillText("OUT", w - 25, 12);
        }

        function drawLine(ctx, arr, w, h, max, color) {
            if (arr.length < 2) return;
            var step = w / (MAX_POINTS - 1);
            ctx.beginPath();
            ctx.strokeStyle = color;
            ctx.lineWidth = 1.5;
            var startX = (MAX_POINTS - arr.length) * step;
            for (var i = 0; i < arr.length; i++) {
                var x = startX + i * step;
                var y = h - (arr[i] / max) * (h - 20);
                if (i === 0) ctx.moveTo(x, y);
                else ctx.lineTo(x, y);
            }
            ctx.stroke();
        }

        function render() {
            drawChart("ppsChart", data.ppsIn, data.ppsOut, "#0ff", "#f0f");
            drawChart("bpsChart", data.bpsIn, data.bpsOut, "#0f0", "#fa0");
            requestAnimationFrame(render);
        }

        connect();
        requestAnimationFrame(render);
    </script>
</body>
</html>

Secret mission

Per-Server Metrics Breakdown

Your dashboard shows global traffic totals, but on a real network with multiple backend servers (lobby, survival, minigames), you need to know WHERE the traffic is going. Add per-server metrics tracking so you can see exactly which backend is generating the most load.

Clean Up Your Resources

Clean Up Your Resources

Decide whether to keep your resources running, pause them to come back later, or delete them entirely. This project runs entirely locally with no cloud services, so there are no ongoing costs.

Resources you used:

  • The packet-monitor Gradle project directory on your Desktop.
  • The shadow JAR deployed to your Velocity plugins/ folder.
  • The dashboard.html file (inside your project directory).

Keep everything running

No action needed. Choose this if you are still actively using the packet monitor on your Velocity proxy.

  • The plugin JAR stays in your Velocity plugins/ folder and loads automatically every time the proxy starts.
  • The WebSocket server on port 9100 only runs while the Velocity proxy is running. It stops automatically when you shut down the proxy.
  • Open dashboard.html in your browser whenever you want to view live metrics.

Pause - I'll come back to this later

Remove the plugin from your proxy to stop packet monitoring without deleting your source code.

  • Delete the packet-monitor-1.0.0.jar file from your Velocity plugins/ folder.
  • Restart your Velocity proxy. The plugin will no longer load and the WebSocket server will not start.
  • Your project source code stays on your Desktop. When you want to re-enable monitoring, rebuild with gradlew.bat shadowJar and copy the JAR back to plugins/.

Delete - I don't want to use this again

Remove all project files and the deployed plugin entirely.

  • Delete the packet-monitor-1.0.0.jar file from your Velocity plugins/ folder.
  • Delete the project directory from your Desktop by running:
cd ~/Desktop
rm -rf packet-monitor

On Windows, use this command instead:

cd ~\Desktop
rmdir /s /q packet-monitor
  • Restart your Velocity proxy to confirm the plugin no longer loads.
  • Verify the plugin is gone by checking your Velocity startup log. You should no longer see the "Packet Monitor active" message.

Nice Work!

Nice Work!

You just built a production-grade real-time network monitoring system for a Minecraft proxy from scratch. Your Velocity plugin intercepts every packet flowing through the proxy, measures it with zero-overhead atomics, and streams the data to a live browser dashboard.

You learned how to:

  • Built a Kotlin Velocity plugin using PacketEvents v2.13.0 with lock-free atomic counters safe for concurrent Netty I/O threads.
  • Created a 150-sample ring buffer storing 30 seconds of metric history and embedded a Java-WebSocket server streaming JSON snapshots every 200ms.
  • Built a standalone HTML Canvas dashboard with live dual-line charts for packet rates and bandwidth that update in real time via WebSocket.
  • Secret Mission: Added a per-server metrics breakdown with a dynamic dashboard table showing packet rates and bandwidth for each backend server.

Ready to quiz yourself?