Build a Network Ban System in Velocity
Block banned players across proxy instances with Redis caching and MongoDB.
Introduction
30 Second Summary
Multiplayer game servers need a way to keep bad actors out. When someone is cheating or griefing, every server in the network needs to know about the ban instantly. A single-server blocklist does not cut it when players can hop between proxy instances.
In this project, you will build a network-wide ban system as a Velocity proxy plugin. It uses Redis for sub-millisecond ban lookups, MongoDB for durable storage, and Redisson's pub/sub to propagate bans across every proxy instance in real time.
What You'll Build
When you are done, you will ban a player from one proxy and watch them get instantly rejected on every proxy in the network, with a formatted disconnect message showing the reason and time remaining.
By the end of this project, you'll have:
- A login interceptor that checks Redis for an active ban and rejects the player with a formatted message before they ever reach a backend server.
- A real-time broadcast system where banning a player on one proxy instantly updates every other proxy's cache via Redisson's RTopic pub/sub.
- A full command interface (/ban, /unban, /baninfo) with permission checks and automatic TTL-based expiry for temporary bans.
- Secret Mission: Add an online-player kick flow that disconnects currently-connected players the moment they are banned, even mid-session.
Are there any prerequisites?
This project assumes you have JDK 25, Gradle, Docker (with Redis and MongoDB containers running), and IntelliJ IDEA installed. You should also be comfortable with Kotlin and Gradle basics.
Before We Start
Before we dive in, let's lock in what you're building and why it requires two different data stores working together.
Scaffold the Velocity Plugin Project
A Velocity proxy plugin needs several moving parts before it can enforce bans: the Velocity API for intercepting connections, Redisson for distributed caching and pub/sub, and the MongoDB coroutine driver for persistent storage.
In this step, you will scaffold a Gradle Kotlin project in IntelliJ IDEA with all the dependencies wired up, create the plugin entry point, and confirm a clean build produces the deployable jar.
In this step, get ready to:
- Create a Gradle Kotlin project and configure all dependencies.
- Write the plugin main class with Velocity lifecycle listeners.
- Build the shadow jar and confirm it compiles without errors.
Create a new Gradle Kotlin project in IntelliJ IDEA
You need a Gradle project with Kotlin DSL as the foundation for the plugin.
- In IntelliJ IDEA, click File in the top menu bar.
- Click New, then Project.
- From the list on the left, select Kotlin.
- Name the project ban-system.
- Select Gradle as the build system.
- Select Kotlin as the Gradle DSL.
- Select your JDK 25 from the JDK dropdown.
- Uncheck Add sample code.
- Expand Advanced Settings and set GroupId to com.example.
- Click Create.
IntelliJ generates the project structure with a build.gradle.kts file and src/ directories.
Configure the build file with dependencies
The generated build file only has a basic Kotlin setup. You need to add the Velocity API, Redisson, MongoDB driver, the Shadow plugin for fat-jar packaging, and the PaperMC Maven repository where Velocity artifacts live.
- Open build.gradle.kts in the editor.
- Select all existing content and replace it with the following plugins, project coordinates, and dependencies:
plugins {
kotlin("jvm") version "2.1.0"
id("com.gradleup.shadow") version "9.0.0-beta12"
kotlin("plugin.serialization") version "2.1.0"
}
group = "com.example"
version = "1.0-SNAPSHOT"
repositories {
mavenCentral()
maven {
name = "papermc"
url = uri("https://repo.papermc.io/repository/maven-public/")
}
}
dependencies {
compileOnly("com.velocitypowered:velocity-api:3.5.0-SNAPSHOT")
annotationProcessor("com.velocitypowered:velocity-api:3.5.0-SNAPSHOT")
implementation(platform("org.mongodb:mongodb-driver-bom:5.8.0"))
implementation("org.mongodb:mongodb-driver-kotlin-coroutine")
implementation("org.mongodb:bson-kotlinx")
implementation("org.redisson:redisson:4.5.0")
implementation("org.jetbrains.kotlinx:kotlinx-coroutines-core:1.10.2")
implementation("org.jetbrains.kotlinx:kotlinx-serialization-json:1.8.1")
}
- Below the dependencies block, add the Kotlin toolchain and Shadow task configuration:
kotlin {
jvmToolchain(21)
}
tasks {
shadowJar {
mergeServiceFiles()
archiveClassifier.set("")
}
build {
dependsOn(shadowJar)
}
}
What does this build file do?
- The Shadow plugin bundles all implementation dependencies into a single fat jar, so you can drop one file into Velocity's plugins folder.
- The Velocity API is compileOnly because Velocity itself provides it at runtime.
- The MongoDB BOM (5.8.0) manages versions for the coroutine driver and bson-kotlinx so you only declare them once.
- The jvmToolchain(21) targets Java 21, which is the minimum Velocity requires.
- The PaperMC repository at https://repo.papermc.io/repository/maven-public/ hosts the Velocity API snapshots.
- Save build.gradle.kts.
- Press Ctrl+Shift+O (macOS: Cmd+Shift+O) to sync Gradle changes, or click the sync icon that appears in the editor gutter.
You should see Gradle download all the dependencies in the Build tool window at the bottom. Once it finishes with no errors, your dependencies are resolved.
Gradle sync failed?
- Check that you have an internet connection. Gradle needs to download artifacts from Maven Central and PaperMC.
- Confirm you did not accidentally leave the default plugins { id("java") } line from the generated file. You should have replaced all content.
- Make sure your JDK is version 21 or higher in the Gradle settings.
Still stuck? Help me fix my Gradle sync error for the ban-system project
✔️ Awesome, I've got everything!
Great. Double check you saved build.gradle.kts and the sync completed without errors.
ⓧ I'd like to double check the full code
Scroll up and confirm your build.gradle.kts has these sections in order:
- The plugins block with kotlin("jvm"), com.gradleup.shadow, and kotlin("plugin.serialization").
- The group and version lines.
- The repositories block with mavenCentral() and the PaperMC maven entry.
- The dependencies block with Velocity (compileOnly), MongoDB (via BOM), Redisson, coroutines, and serialization.
- The kotlin { jvmToolchain(21) } block.
- The tasks block with shadowJar and build configuration.
Create the plugin main class
Every Velocity plugin needs an entry point class annotated with @Plugin. Velocity uses this annotation to discover your plugin and its metadata at load time.
- In the Project tool window on the left, right-click src/main/kotlin.
- Select New, then Package.
- Enter com.example.bansystem and press Enter.
- Right-click the new bansystem package, select New, then Kotlin Class/File.
- Enter BanPlugin and select Class.
- Replace the generated content with the following plugin class:
package com.example.bansystem
import com.google.inject.Inject
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.proxy.ProxyServer
import org.slf4j.Logger
@Plugin(
id = "ban-system",
name = "Network Ban System",
version = "1.0-SNAPSHOT",
authors = ["Developer"]
)
class BanPlugin @Inject constructor(
private val server: ProxyServer,
private val logger: Logger
) {
@Subscribe
fun onProxyInitialize(event: ProxyInitializeEvent) {
logger.info("Network Ban System loading...")
}
@Subscribe
fun onProxyShutdown(event: ProxyShutdownEvent) {
logger.info("Network Ban System shutting down...")
}
}
What does this class do?
- The @Plugin annotation tells Velocity this class is a plugin. The id field is used for permissions and configuration.
- The @Inject constructor lets Velocity automatically provide the ProxyServer instance and a Logger.
- The @Subscribe annotation registers methods as event listeners. ProxyInitializeEvent fires when the proxy starts, and ProxyShutdownEvent fires on shutdown.
- Save BanPlugin.kt.
Seeing red underlines on imports?
- Make sure your Gradle sync completed successfully. The Velocity API imports only resolve after the dependencies are downloaded.
- If IntelliJ still shows errors, try invalidating caches: File then Invalidate Caches then restart.
Still stuck? Help me fix unresolved imports in my Velocity plugin
Build the shadow jar
The shadow jar bundles your plugin code and all runtime dependencies into a single deployable file. Before you run the build, what do you think will happen if it succeeds?
- Open the Terminal panel at the bottom of IntelliJ IDEA.
- Build the project by running this command:
macOS
./gradlew build
Windows
gradlew build
You should see output ending with BUILD SUCCESSFUL. The shadow jar is now in the build/libs/ directory.
- Confirm the jar exists by running:
ls build/libs/
You should see ban-system-1.0-SNAPSHOT.jar listed.
Build failed?
- Read the error message carefully. A common cause is a typo in the package declaration or import statements in BanPlugin.kt.
- If you see a Could not resolve error, your Gradle sync did not complete. Run the sync again with Ctrl+Shift+O (macOS: Cmd+Shift+O) and check for network issues.
- If you see Unsupported class file major version, confirm jvmToolchain(21) is present in build.gradle.kts.
Still stuck? Help me fix my Gradle build failure for the ban-system plugin
Your project compiles cleanly and produces a deployable jar. Next up, you will connect to MongoDB and define the ban data model that stores who is banned, why, and for how long.
Connect to MongoDB and Define the Ban Data Model
Your plugin skeleton is in place and builds successfully. Now you need somewhere to actually store ban records so they survive proxy restarts.
In this step you will define a BanRecord data class that models everything a ban needs (who, why, when, how long) and a BanRepository class that talks to MongoDB using the Kotlin coroutine driver.
In this step, get ready to:
- Create the BanRecord data class with expiry helpers.
- Build a BanRepository with coroutine-driven CRUD operations.
- Wire the repository into the plugin lifecycle and confirm MongoDB connects.
Create the BanRecord data class
Every ban in the system needs a consistent shape: who was banned, who issued it, why, and when it expires. A Kotlin data class gives you immutability and automatic serialization support for MongoDB.
- In IntelliJ, right-click the com.example.bansystem package in the Project sidebar.
- Select New then Package and name it model.
- Inside the model package, create a new Kotlin file named BanRecord.kt and paste this code:
package com.example.bansystem.model
import java.io.Serializable
import java.time.Instant
import java.util.UUID
data class BanRecord(
val playerUuid: String,
val playerName: String,
val reason: String,
val issuerUuid: String,
val issuerName: String,
val createdAt: Long = Instant.now().toEpochMilli(),
val expiresAt: Long? = null,
val active: Boolean = true
) : Serializable {
// Returns true if the ban has passed its expiry time
fun isExpired(): Boolean {
return expiresAt != null && Instant.now().toEpochMilli() > expiresAt
}
// Returns milliseconds remaining, or null for permanent bans
fun remainingDuration(): Long? {
if (expiresAt == null) return null
val remaining = expiresAt - Instant.now().toEpochMilli()
return if (remaining > 0) remaining else 0
}
}
What does this code do?
The BanRecord data class is the domain model for a single ban. It stores the banned player's UUID, the ban reason, who issued it, timestamps, and whether the ban is still active.
It implements Serializable because Redisson (which you will add later) uses Java serialization to store objects in Redis. The isExpired() and remainingDuration() helpers let the plugin decide at lookup time whether a temporary ban is still in effect.
- Now add the formatExpiry() method inside the class body, just above the final closing brace }:
// Formats remaining time as a human-readable string like "2d 5h"
fun formatExpiry(): String {
if (expiresAt == null) return "Permanent"
val remaining = remainingDuration() ?: return "Expired"
if (remaining == 0L) return "Expired"
val seconds = remaining / 1000
val minutes = seconds / 60
val hours = minutes / 60
val days = hours / 24
return when {
days > 0 -> "${days}d ${hours % 24}h"
hours > 0 -> "${hours}h ${minutes % 60}m"
minutes > 0 -> "${minutes}m ${seconds % 60}s"
else -> "${seconds}s"
}
}
What does formatExpiry do?
This method converts the remaining milliseconds into a player-facing string like 2d 5h or Permanent. The ban system will display this in disconnect messages so banned players know how long their ban lasts.
- Save BanRecord.kt.
- Confirm the project still compiles by running this in your terminal:
./gradlew build
You should see BUILD SUCCESSFUL in the output.
Seeing a compilation error?
- Check that the closing brace of formatExpiry() sits inside the class body, not after the final } of the class.
- Ensure the package declaration matches the folder path com.example.bansystem.model.
- Help me find the syntax error in my BanRecord.kt
Build the ban repository
The repository is the single gateway between your plugin and MongoDB. It handles inserting bans, querying active bans, and deactivating expired ones.
- Create a new package called repository under com.example.bansystem.
- Inside it, create BanRepository.kt and paste this code:
package com.example.bansystem.repository
import com.example.bansystem.model.BanRecord
import com.mongodb.client.model.Filters
import com.mongodb.kotlin.client.coroutine.MongoClient
import com.mongodb.kotlin.client.coroutine.MongoCollection
import kotlinx.coroutines.flow.firstOrNull
class BanRepository(connectionString: String) {
// Initialize the MongoDB client and point to the bans collection
private val client = MongoClient.create(connectionString)
private val database = client.getDatabase("ban_system")
private val collection: MongoCollection<BanRecord> = database.getCollection("bans")
// Persist a new ban record to MongoDB
suspend fun insertBan(ban: BanRecord) {
collection.insertOne(ban)
}
}
What does this code do?
The BanRepository creates a MongoClient from the connection string, selects the ban_system database, and gets a typed collection that maps directly to your BanRecord data class.
The suspend keyword marks insertBan as a coroutine function. This means MongoDB I/O runs without blocking the thread, which keeps Velocity responsive.
- Add the findActiveBan and deactivateBan methods below insertBan inside the class:
// Look up the active ban for a player, auto-deactivating expired ones
suspend fun findActiveBan(playerUuid: String): BanRecord? {
return collection.find(
Filters.and(
Filters.eq("playerUuid", playerUuid),
Filters.eq("active", true)
)
).firstOrNull()?.let { ban ->
if (ban.isExpired()) {
deactivateBan(playerUuid)
null
} else {
ban
}
}
}
// Mark the ban as inactive in MongoDB
suspend fun deactivateBan(playerUuid: String) {
collection.updateOne(
Filters.and(
Filters.eq("playerUuid", playerUuid),
Filters.eq("active", true)
),
com.mongodb.client.model.Updates.set("active", false)
)
}
What do these methods do?
- findActiveBan queries MongoDB for a record matching the player's UUID where active = true. If the ban exists but is expired, it deactivates it and returns null so the player is not blocked.
- deactivateBan sets active to false on the matching document rather than deleting it. This preserves ban history.
- Add the final two methods below deactivateBan to complete the repository:
// Find an active ban by player name (used by /unban and /baninfo commands)
suspend fun findBanByPlayerName(playerName: String): BanRecord? {
return collection.find(
Filters.and(
Filters.eq("playerName", playerName),
Filters.eq("active", true)
)
).firstOrNull()
}
// Close the MongoDB client on shutdown
fun close() {
client.close()
}
What does findBanByPlayerName do?
This looks up bans by name instead of UUID. Commands like /unban and /baninfo will use this since admins type player names, not UUIDs. The close() method shuts down the MongoClient cleanly when the proxy stops.
- Save BanRepository.kt and build to confirm everything compiles:
./gradlew build
You should see BUILD SUCCESSFUL in the output.
Still seeing unresolved references?
- Confirm the import kotlinx.coroutines.flow.firstOrNull is at the top of BanRepository.kt. IntelliJ sometimes imports the wrong firstOrNull extension.
- Make sure com.mongodb.client.model.Filters and com.mongodb.kotlin.client.coroutine.MongoClient are imported, not the sync driver equivalents.
- Help me check my imports against the MongoDB Kotlin coroutine driver
✔️ Awesome, I've got everything!
Great. Make sure both BanRecord.kt and BanRepository.kt are saved.
ⓧ I'd like to double check the full code
Scroll up and confirm these code blocks are present in order in your files:
- BanRecord.kt: the data class with eight fields, isExpired(), remainingDuration(), and formatExpiry() all inside the class body.
- BanRepository.kt: the class with client/database/collection fields, then insertBan, findActiveBan, findBanByPlayerName, deactivateBan, and close.
Wire the repository into BanPlugin
The repository class exists but nothing creates an instance of it yet. You need to instantiate it during proxy initialization and close it on shutdown.
- Switch back to BanPlugin.kt and find the existing import block.
- Add this import at the end of the import section:
import com.example.bansystem.repository.BanRepository
- Inside the class body, above the onProxyInitialize method, add this field:
private lateinit var banRepository: BanRepository
- Replace the body of onProxyInitialize with:
@Subscribe
fun onProxyInitialize(event: ProxyInitializeEvent) {
banRepository = BanRepository("mongodb://localhost:27017")
logger.info("Connected to MongoDB - ban collection ready")
}
- Replace the body of onProxyShutdown with:
@Subscribe
fun onProxyShutdown(event: ProxyShutdownEvent) {
banRepository.close()
}
Why lateinit?
Velocity plugins cannot safely access APIs during construction. The lateinit keyword lets you declare the field without initializing it in the constructor, then assign it inside onProxyInitialize when Velocity signals it is safe to proceed.
- Save BanPlugin.kt.
- Build the shadow jar by running:
./gradlew shadowJar
Before you test the plugin on Velocity, what do you think you will see in the console when the proxy starts?
- Copy the built jar from build/libs/ into your Velocity server's plugins/ directory.
- Make sure your Docker container for MongoDB is running.
- Start the Velocity proxy.
In the console output, you should see the log line:
Connected to MongoDB - ban collection ready
That confirms MongoDB is reachable and the repository was instantiated without error.
Don't see the log line?
- Verify your MongoDB Docker container is running with docker ps. You should see a container exposing port 27017.
- Check Velocity's console for a stacktrace. A MongoTimeoutException means the client cannot reach localhost:27017.
- Ensure the shadow jar (not the thin jar) is in the plugins/ folder. The thin jar will not bundle MongoDB driver classes.
- Help me diagnose why my MongoDB connection log is missing
Your data model and persistence layer are locked in. Next up, you will use this repository to block banned players the moment they try to log in.
Block Banned Players at Login
You have a MongoDB repository that stores and retrieves ban records. The next piece is enforcement. When a player connects to your network, the proxy needs to check whether they are banned and reject the connection before they ever reach a backend server.
In this step, you will hook into Velocity's LoginEvent to intercept every connection attempt, query MongoDB for an active ban, and deny the login with a formatted disconnect message showing the reason and remaining duration.
In this step, get ready to:
- Create a LoginListener that checks MongoDB for active bans on every login attempt.
- Register the listener in BanPlugin so Velocity fires it on connections.
- Test the full ban flow by inserting a ban document and verifying the player is rejected.
Create the LoginListener class
Velocity fires a LoginEvent after a player authenticates but before they are sent to a backend server. This is the ideal interception point for ban enforcement.
- In IntelliJ, right-click the com.example.bansystem package in the Project panel, then create a new package named listener.
- Inside the listener package, create a new Kotlin file named LoginListener.kt.
- Paste the following code into LoginListener.kt to create the listener class with the ban lookup logic:
package com.example.bansystem.listener
import com.example.bansystem.model.BanRecord
import com.example.bansystem.repository.BanRepository
import com.velocitypowered.api.event.ResultedEvent
import com.velocitypowered.api.event.Subscribe
import com.velocitypowered.api.event.connection.LoginEvent
import kotlinx.coroutines.runBlocking
import net.kyori.adventure.text.Component
import net.kyori.adventure.text.format.NamedTextColor
import org.slf4j.Logger
class LoginListener(
private val banRepository: BanRepository,
private val logger: Logger
) {
@Subscribe
fun onLogin(event: LoginEvent) {
val playerUuid = event.player.uniqueId.toString()
// Query MongoDB for an active ban
val ban = runBlocking { banRepository.findActiveBan(playerUuid) }
if (ban != null) {
logger.info("Ban found in MongoDB for ${event.player.username}")
}
}
}
What does this code do?
The @Subscribe annotation tells Velocity to call onLogin every time a LoginEvent fires. Velocity treats this as an asynchronous blocking handler, so using runBlocking to call the suspend function is safe here.
The method grabs the connecting player's UUID and passes it to findActiveBan. If a ban record exists and is not expired, the repository returns it. For now, we just log that we found a ban.
- Now add the denial logic below the closing brace of the if (ban != null) log block. Copy this code inside the onLogin function, after line 25:
if (ban != null && !ban.isExpired()) {
val message = Component.text()
.append(Component.text("You are banned from this network!", NamedTextColor.RED))
.append(Component.newline())
.append(Component.text("Reason: ", NamedTextColor.GRAY))
.append(Component.text(ban.reason, NamedTextColor.WHITE))
.append(Component.newline())
.append(Component.text("Expires: ", NamedTextColor.GRAY))
.append(Component.text(ban.formatExpiry(), NamedTextColor.WHITE))
.build()
event.result = ResultedEvent.ComponentResult.denied(message)
}
What does the denial message do?
The Component.text() builder creates a rich disconnect message using the Adventure text library. It appends a red header, the ban reason in white, and the remaining duration from formatExpiry().
Setting event.result to ResultedEvent.ComponentResult.denied(message) tells Velocity to reject the connection and show that message to the player. They never reach a backend server.
- Save LoginListener.kt.
Register the listener in BanPlugin
Velocity automatically registers your main plugin class as a listener, but separate listener classes need explicit registration via the EventManager.
- Open BanPlugin.kt and add this import near the top of the file, below the existing BanRepository import:
import com.example.bansystem.listener.LoginListener
- In the onProxyInitialize function, find this line:
logger.info("Connected to MongoDB - ban collection ready")
- Add the following line directly below it to register the listener:
server.eventManager.register(this, LoginListener(banRepository, logger))
What does this registration do?
The server.eventManager.register(plugin, listener) call tells Velocity's event system to scan the listener object for @Subscribe methods and wire them to the corresponding events. The first argument is the plugin instance (this), the second is your LoginListener.
- Save BanPlugin.kt.
- Build the plugin to confirm everything compiles by running:
./gradlew shadowJar
You should see BUILD SUCCESSFUL with the shadow jar produced in build/libs/.
Build failing with unresolved references?
Make sure your LoginListener.kt is in the package com.example.bansystem.listener (check both the package declaration at the top and the file's directory path). The import in BanPlugin.kt must match exactly.
If you see errors about ResultedEvent or Component, confirm your build.gradle.kts has the Velocity API dependency and the PaperMC repository. These classes come from the Velocity API jar.
Help me fix this build error in my Velocity plugin.
Test ban enforcement with a live connection
The listener is registered and the plugin compiles. Now you need to prove that a banned player is actually rejected at login.
- Make sure your Minestom test server is running as the backend and Velocity is configured to point to it in velocity.toml.
- Copy the compiled jar from build/libs/ into Velocity's plugins/ directory and start the Velocity proxy.
- Confirm you see Connected to MongoDB - ban collection ready in the Velocity console output.
- Open a new terminal and connect to mongosh to insert a test ban document by running:
use ban_system
db.bans.insertOne({
playerUuid: "test-player-uuid-here",
playerName: "TestPlayer",
reason: "griefing",
issuerUuid: "CONSOLE",
issuerName: "Console",
createdAt: Date.now(),
expiresAt: Date.now() + 3600000,
active: true
})
What UUID should I use?
Replace test-player-uuid-here with the UUID of the Minecraft account you will use to connect. If you are using an offline-mode test setup, Velocity generates UUIDs from the player name. Check your Velocity logs from a previous successful connection to find the exact UUID string.
Before you connect, think about what you expect to happen. Will the player reach the backend server, or will the connection be rejected at the proxy level?
- Attempt to connect to the Velocity proxy using the banned player name.
The connection is immediately rejected. You see a disconnect screen showing "You are banned from this network!" with the reason "griefing" and the remaining duration. In the Velocity console, the log line Ban found in MongoDB for TestPlayer confirms the lookup path.
Player connected without being rejected?
The most common cause is a UUID mismatch. The playerUuid in your MongoDB document must exactly match what event.player.uniqueId.toString() returns. Check the Velocity console logs for the player's UUID when they connect.
Also verify that your MongoDB container is running and accessible at mongodb://localhost:27017. If the repository cannot connect, the lookup returns null and the player passes through.
Help me debug why my banned player is not being rejected.
✔️ Awesome, I've got everything!
Great. Double check you saved both LoginListener.kt and BanPlugin.kt.
ⓧ I'd like to double check the full code
Confirm your files contain the following. In LoginListener.kt, you should have:
- The package declaration com.example.bansystem.listener and all imports (BanRecord, BanRepository, ResultedEvent, Subscribe, LoginEvent, runBlocking, Component, NamedTextColor, Logger).
- The LoginListener class with constructor parameters banRepository: BanRepository and logger: Logger.
- The @Subscribe fun onLogin(event: LoginEvent) method that calls runBlocking { banRepository.findActiveBan(playerUuid) }, logs if found, then denies with a Component message if the ban is active and not expired.
In BanPlugin.kt, you should have:
- The additional import com.example.bansystem.listener.LoginListener.
- The line server.eventManager.register(this, LoginListener(banRepository, logger)) inside onProxyInitialize, after the MongoDB log message.
Your proxy now enforces bans at the login gate. Every connection hits MongoDB for a ban check. Next up, you will add a Redis cache layer so that ban lookups happen in sub-millisecond time instead of a database round-trip on every single login.
Add Redisson Caching with RMapCache
In the last step, you proved that banning works. Every player login triggers a MongoDB query to check for active bans.
This approach scales poorly because each login blocks the event thread while waiting for a network round-trip to the database. Redisson's RMapCache is a distributed Java map backed by Redis that supports per-entry TTL. A temporary ban cached with a matching TTL evicts itself the moment it expires. In this step, you'll create a cache layer that sits in front of MongoDB for sub-millisecond ban lookups.
In this step, get ready to:
- Create a BanCacheService class with Redisson's RMapCache.
- Add cache read, write, and remove methods with automatic TTL.
- Wire the cache into the login flow with a cache-first, MongoDB-fallback strategy.
Create the BanCacheService class
The cache service needs its own class that manages the Redisson connection and the map cache instance.
- Create a new file at src/main/kotlin/com/example/bansystem/cache/BanCacheService.kt.
- Add the class skeleton with the Redisson client initialization by pasting this code:
package com.example.bansystem.cache
import com.example.bansystem.model.BanRecord
import com.example.bansystem.repository.BanRepository
import kotlinx.coroutines.runBlocking
import org.redisson.Redisson
import org.redisson.api.*
import org.redisson.api.map.MapLoader
import org.redisson.api.map.MapWriter
import org.redisson.config.Config
import java.util.concurrent.TimeUnit
import org.slf4j.Logger
class BanCacheService(
redisAddress: String,
private val banRepository: BanRepository,
private val logger: Logger
) {
private val redissonClient: RedissonClient
private val banCache: RMapCache<String, BanRecord>
init {
val config = Config()
config.useSingleServer().address = redisAddress
redissonClient = Redisson.create(config)
- Continue building the init block by adding the MapLoader, MapWriter, and MapCacheOptions configuration directly below the Redisson client setup:
// Loads ban from MongoDB on cache miss (read-through)
val loader = object : MapLoader<String, BanRecord> {
override fun load(key: String): BanRecord? {
logger.info("Cache miss for $key - loading from MongoDB")
return runBlocking { banRepository.findActiveBan(key) }
}
override fun loadAllKeys(): Iterable<String>? = null
}
// Persists ban to MongoDB on cache write/remove (write-through)
val writer = object : MapWriter<String, BanRecord> {
override fun write(map: Map<String, BanRecord>) {
runBlocking { map.values.forEach { banRepository.insertBan(it) } }
}
override fun delete(keys: Collection<String>) {
runBlocking { keys.forEach { banRepository.removeBan(it) } }
}
}
// Wire loader and writer into MapCacheOptions
val options = MapCacheOptions.defaults<String, BanRecord>()
.loader(loader)
.writer(writer)
.writeMode(MapOptions.WriteMode.WRITE_THROUGH)
banCache = redissonClient.getMapCache("network:bans", options)
}
}
What does this code do?
The init block creates a Redisson client pointing at your local Redis, then configures MapCacheOptions with two callbacks and a write mode.
- MapLoader implements read-through. When banCache.get(key) misses, Redisson calls load(key) which queries MongoDB for the active ban and caches the result automatically.
- MapWriter implements write-through. Every put() or remove() on the cache also persists to MongoDB via the writer before returning.
- WriteMode.WRITE_THROUGH ensures writes to the backing store happen synchronously. The alternative, WRITE_BEHIND, batches writes asynchronously.
Add cache read and write methods
- In BanCacheService.kt, add the following methods inside the class body, below the init block (before the closing }):
fun cacheBan(ban: BanRecord) {
val ttl = ban.remainingDuration()
if (ttl != null && ttl > 0) {
// Temporary ban: TTL auto-evicts from cache; MapWriter persists to MongoDB
banCache.put(ban.playerUuid, ban, ttl, TimeUnit.MILLISECONDS)
} else if (ban.expiresAt == null) {
// Permanent ban: no expiry; MapWriter persists to MongoDB
banCache.put(ban.playerUuid, ban)
}
}
fun getCachedBan(playerUuid: String): BanRecord? {
// MapLoader auto-loads from MongoDB on cache miss
return banCache[playerUuid]
}
fun removeCachedBan(playerUuid: String) {
// MapWriter auto-deletes from MongoDB
banCache.remove(playerUuid)
}
fun shutdown() {
redissonClient.shutdown()
}
What does this code do?
- cacheBan() stores a ban in Redis with a TTL matching the remaining duration. The MapWriter automatically persists the same ban to MongoDB in the same call.
- getCachedBan() retrieves a ban by player UUID. On a cache miss, the MapLoader transparently queries MongoDB, caches the result, and returns it.
- removeCachedBan() removes a ban from Redis and the MapWriter deletes it from MongoDB simultaneously.
- shutdown() cleanly closes the Redisson connection when the plugin unloads.
- Save BanCacheService.kt.
- Confirm it compiles by running:
./gradlew build
You should see BUILD SUCCESSFUL. The new class compiles against the Redisson dependency you added in Step 1.
Seeing unresolved reference errors?
- Make sure the file is in the cache package directory: src/main/kotlin/com/example/bansystem/cache/.
- Check that build.gradle.kts contains implementation("org.redisson:redisson:4.5.0"). Sync Gradle if you changed the file.
- Verify the package statement at the top reads package com.example.bansystem.cache.
Still stuck? Help me fix Redisson import errors in my BanCacheService
✔️ Awesome, I've got everything!
Great. Double check you've saved your file before moving on.
ⓧ I'd like to double check the full code
Your BanCacheService.kt should contain these sections in order:
- Package declaration and imports (BanRecord, Redisson, RMapCache, RedissonClient, Config, TimeUnit).
- Class declaration with redisAddress: String parameter.
- Two private properties: redissonClient and banCache.
- The init block creating the Redisson config, client, and map cache named network:bans.
- Four methods: cacheBan, getCachedBan, removeCachedBan, shutdown.
Wire the cache into the login flow
With read-through in place, the login listener no longer needs manual MongoDB fallback logic. A single getCachedBan() call handles everything. You also need BanPlugin to pass the repository into the cache service.
- In LoginListener.kt, find the constructor:
class LoginListener(
private val banRepository: BanRepository,
private val logger: Logger
) {
- Replace it with this constructor that accepts only BanCacheService (the cache handles MongoDB internally now):
class LoginListener(
private val banCacheService: BanCacheService,
private val logger: Logger
) {
- Next, replace the entire onLogin method body with simplified read-through logic:
@Subscribe
fun onLogin(event: LoginEvent) {
val playerUuid = event.player.uniqueId.toString()
// MapLoader handles read-through: cache hit returns instantly,
// cache miss auto-loads from MongoDB and caches the result
val ban = banCacheService.getCachedBan(playerUuid)
if (ban != null && !ban.isExpired()) {
val message = Component.text()
.append(Component.text("You are banned from this network!", NamedTextColor.RED))
.append(Component.newline())
.append(Component.text("Reason: ", NamedTextColor.GRAY))
.append(Component.text(ban.reason, NamedTextColor.WHITE))
.append(Component.newline())
.append(Component.text("Expires: ", NamedTextColor.GRAY))
.append(Component.text(ban.formatExpiry(), NamedTextColor.WHITE))
.build()
event.result = ResultedEvent.ComponentResult.denied(message)
}
}
What changed in the login flow?
The manual cache-aside pattern (check cache, miss, query MongoDB, backfill) is gone. A single getCachedBan() call does everything: Redisson checks Redis first, and on a miss the MapLoader queries MongoDB and populates the cache automatically.
The MapLoader logs Cache miss for [uuid] - loading from MongoDB whenever it fires. On subsequent logins you won't see that log, confirming the ban was served from Redis.
- Save LoginListener.kt.
- Now open BanPlugin.kt. Find the field declarations:
private lateinit var banRepository: BanRepository
- Add a second field directly below it:
private lateinit var banRepository: BanRepository
private lateinit var banCacheService: BanCacheService
- Find the onProxyInitialize method. Replace its body with this version that initializes the cache service and passes it to the listener:
@Subscribe
fun onProxyInitialize(event: ProxyInitializeEvent) {
banRepository = BanRepository("mongodb://localhost:27017")
logger.info("Connected to MongoDB - ban collection ready")
// Pass banRepository so MapLoader/MapWriter can reach MongoDB
banCacheService = BanCacheService("redis://127.0.0.1:6379", banRepository, logger)
logger.info("Connected to Redis - ban cache ready")
server.eventManager.register(this, LoginListener(banCacheService, logger))
}
- Find the onProxyShutdown method. Replace it to shut down the cache before closing MongoDB:
@Subscribe
fun onProxyShutdown(event: ProxyShutdownEvent) {
banCacheService.shutdown()
banRepository.close()
}
- Finally, add the missing import at the top of BanPlugin.kt alongside the existing imports:
import com.example.bansystem.cache.BanCacheService
- Save BanPlugin.kt.
- Build the project to confirm everything compiles:
./gradlew shadowJar
You should see BUILD SUCCESSFUL.
Build failing with type mismatch on LoginListener?
- The constructor parameter order must match: BanCacheService first, then BanRepository, then Logger. Ensure BanPlugin.kt passes them in this same order.
- Check that you added the import com.example.bansystem.cache.BanCacheService line in BanPlugin.kt.
Need more help? Help me fix type mismatch errors when wiring BanCacheService into BanPlugin
Now test the full flow. Before you run this, think about what you expect to see. The banned player from Step 3 is still in MongoDB. On their first login attempt, where will the ban be found?
- Copy the new jar to your Velocity plugins/ folder and restart the proxy.
- Attempt to connect with the banned player.
In the Velocity console, you should see Ban found in MongoDB for [player] on the first attempt. The cache was empty, so it fell back to the database and backfilled Redis.
- Attempt to connect with the same banned player again.
This time the log should say Ban found in cache for [player]. The ban was served from Redis without touching MongoDB.
- Verify the cache entry exists in Redis by running:
redis-cli KEYS *
You should see a key containing network:bans in the output. The ban entry lives in Redis with a TTL matching the remaining ban duration.
Not seeing the cache key in Redis?
- Confirm your Redis Docker container is running with docker ps. The Redisson client silently fails to connect if Redis is down.
- Check the Velocity console for connection errors when the plugin starts. You should see Connected to Redis - ban cache ready on successful startup.
- If the first login shows Ban found in MongoDB but the second still does too, the backfill might be failing. Ensure BanRecord implements Serializable (it should from Step 2).
Still stuck? Help me debug why my Redisson RMapCache is not storing ban entries
Your ban lookups are now sub-millisecond for any player already in the cache. Temporary bans auto-expire from Redis when their TTL runs out, with no cleanup jobs needed. Next up, you'll add real-time pub/sub so banning a player on one proxy propagates to every other proxy instantly.
Add RTopic Pub/Sub and Ban Commands
Your ban system blocks players at login, but only on the proxy that checked the cache. If you run multiple Velocity instances behind a load balancer, banning a player on proxy A leaves proxy B completely unaware until its cache entry expires or the player reconnects.
Redisson's RTopic solves this with typed pub/sub messaging. In this step, you will broadcast ban events to every proxy instance the moment a ban is issued. You will also build the admin commands that drive the entire system.
In this step, get ready to:
- Create a broadcast service that publishes and subscribes to ban events via RTopic.
- Implement /ban, /unban, and /baninfo commands with permission checks.
- Wire everything into the plugin and verify cross-proxy propagation.
Create the ban broadcast service
When a ban is issued, every proxy in the network needs to know immediately. RTopic lets you publish typed messages to a named channel, and every subscriber receives them in real time.
- Create a new package pubsub inside com.example.bansystem.
- Create a new file BanBroadcastService.kt in that package and paste the following code:
package com.example.bansystem.pubsub
import com.example.bansystem.cache.BanCacheService
import com.example.bansystem.model.BanRecord
import com.velocitypowered.api.proxy.ProxyServer
import org.redisson.Redisson
import org.redisson.api.RTopic
import org.redisson.api.RedissonClient
import org.redisson.config.Config
import org.slf4j.Logger
import java.io.Serializable
import java.util.UUID
data class BanEvent(val ban: BanRecord) : Serializable
data class UnbanEvent(val playerUuid: String, val playerName: String) : Serializable
class BanBroadcastService(
private val banCacheService: BanCacheService,
redisAddress: String,
private val server: ProxyServer,
private val logger: Logger
) {
private val redissonClient: RedissonClient
private val topic: RTopic
init {
val config = Config()
config.useSingleServer().address = redisAddress
redissonClient = Redisson.create(config)
topic = redissonClient.getTopic("network:ban-events")
What does this code do?
BanEvent and UnbanEvent are lightweight message types that get serialized across Redis. They implement Serializable so Redisson's default codec can transmit them.
The service creates its own RedissonClient (separate from the cache client) and obtains an RTopic named "network:ban-events". This is the pub/sub channel every proxy subscribes to.
- Directly below the last line you just pasted, add the listener subscriptions and publish methods:
topic.addListener(BanEvent::class.java) { _, banEvent ->
logger.info("Received ban broadcast for ${banEvent.ban.playerName}")
banCacheService.cacheBan(banEvent.ban)
}
topic.addListener(UnbanEvent::class.java) { _, unbanEvent ->
logger.info("Received unban broadcast for ${unbanEvent.playerName}")
banCacheService.removeCachedBan(unbanEvent.playerUuid)
}
}
fun publishBan(ban: BanRecord) {
topic.publish(BanEvent(ban))
}
fun publishUnban(playerUuid: String, playerName: String) {
topic.publish(UnbanEvent(playerUuid, playerName))
}
fun shutdown() {
redissonClient.shutdown()
}
}
What does this code do?
- addListener(BanEvent::class.java) subscribes to incoming ban messages. When another proxy publishes a ban, this listener updates the local cache immediately.
- addListener(UnbanEvent::class.java) removes the ban from the local cache the moment an unban is broadcast.
- publishBan and publishUnban send messages outward. Every proxy subscribed to "network:ban-events" receives them.
- Save BanBroadcastService.kt.
- Build the project to confirm the new service compiles:
./gradlew build
You should see BUILD SUCCESSFUL in the output.
Seeing unresolved reference errors?
- Make sure the package declaration is com.example.bansystem.pubsub and the file is in the matching directory path.
- Confirm the BanCacheService import resolves. The cache package must exist from the previous step.
Why is my BanBroadcastService not compiling?
✔️ Awesome, I've got everything!
Great. Double check that the file is saved and the build passed without warnings.
ⓧ I'd like to double check the full code
Scroll up and confirm both code blocks are present in order in your BanBroadcastService.kt file. The first block ends at topic = redissonClient.getTopic("network:ban-events") and the second block continues directly after it with the listener registrations through the closing } of the class.
Implement the ban and unban commands
Velocity uses the SimpleCommand interface for commands. Each command class implements execute (the logic) and hasPermission (who can run it).
- Create a new package command inside com.example.bansystem.
- Create BanCommand.kt in that package and paste this first section:
package com.example.bansystem.command
import com.example.bansystem.cache.BanCacheService
import com.example.bansystem.model.BanRecord
import com.example.bansystem.pubsub.BanBroadcastService
import com.example.bansystem.repository.BanRepository
import com.velocitypowered.api.command.SimpleCommand
import com.velocitypowered.api.proxy.ProxyServer
import kotlinx.coroutines.runBlocking
import net.kyori.adventure.text.Component
import net.kyori.adventure.text.format.NamedTextColor
import java.time.Instant
class BanCommand(
private val banRepository: BanRepository,
private val banCacheService: BanCacheService,
private val banBroadcastService: BanBroadcastService,
private val server: ProxyServer
) : SimpleCommand {
override fun execute(invocation: SimpleCommand.Invocation) {
val source = invocation.source()
val args = invocation.arguments()
if (args.size < 3) {
source.sendMessage(Component.text("Usage: /ban <player> <duration> <reason>", NamedTextColor.RED))
return
}
- Directly below the closing } of the args check, add the argument parsing and ban record creation:
val playerName = args[0]
val durationStr = args[1]
val reason = args.drop(2).joinToString(" ")
val duration = parseDuration(durationStr)
if (duration == null && durationStr != "perm") {
source.sendMessage(Component.text("Invalid duration. Use: 1h, 1d, 7d, 30d, or perm", NamedTextColor.RED))
return
}
val expiresAt = if (durationStr == "perm") null else Instant.now().toEpochMilli() + duration!!
val issuerUuid = "CONSOLE"
val issuerName = "Console"
val targetPlayer = server.getPlayer(playerName).orElse(null)
val targetUuid = targetPlayer?.uniqueId?.toString() ?: playerName
val ban = BanRecord(
playerUuid = targetUuid,
playerName = playerName,
reason = reason,
issuerUuid = issuerUuid,
issuerName = issuerName,
expiresAt = expiresAt
)
- Finally, add the persistence, broadcast, response, permission check, and duration parser below the BanRecord construction:
runBlocking {
banRepository.insertBan(ban)
}
banCacheService.cacheBan(ban)
banBroadcastService.publishBan(ban)
source.sendMessage(
Component.text("Banned $playerName for: $reason (${ban.formatExpiry()})", NamedTextColor.GREEN)
)
}
override fun hasPermission(invocation: SimpleCommand.Invocation): Boolean {
return invocation.source().hasPermission("bansystem.ban")
}
private fun parseDuration(input: String): Long? {
val regex = Regex("(\\d+)([smhdw])")
val match = regex.matchEntire(input) ?: return null
val amount = match.groupValues[1].toLong()
return when (match.groupValues[2]) {
"s" -> amount * 1000
"m" -> amount * 60 * 1000
"h" -> amount * 3600 * 1000
"d" -> amount * 86400 * 1000
"w" -> amount * 604800 * 1000
else -> null
}
}
}
What does this command do?
- Parses three arguments: player name, duration (1h, 7d, perm), and a reason (multiple words joined).
- Persists the ban to MongoDB, caches it in Redis with TTL, and broadcasts via RTopic so all proxies update instantly.
- hasPermission restricts usage to sources with the bansystem.ban permission node.
- parseDuration converts shorthand like 1h into milliseconds using a simple regex.
- Save BanCommand.kt.
- Now create UnbanCommand.kt in the same command package with this first section:
package com.example.bansystem.command
import com.example.bansystem.cache.BanCacheService
import com.example.bansystem.pubsub.BanBroadcastService
import com.example.bansystem.repository.BanRepository
import com.velocitypowered.api.command.SimpleCommand
import kotlinx.coroutines.runBlocking
import net.kyori.adventure.text.Component
import net.kyori.adventure.text.format.NamedTextColor
class UnbanCommand(
private val banRepository: BanRepository,
private val banCacheService: BanCacheService,
private val banBroadcastService: BanBroadcastService
) : SimpleCommand {
override fun execute(invocation: SimpleCommand.Invocation) {
val source = invocation.source()
val args = invocation.arguments()
if (args.isEmpty()) {
source.sendMessage(Component.text("Usage: /unban <player>", NamedTextColor.RED))
return
}
val playerName = args[0]
- Below the val playerName = args[0] line, add the unban logic and permission check:
val ban = runBlocking { banRepository.findBanByPlayerName(playerName) }
if (ban == null) {
source.sendMessage(Component.text("$playerName is not banned.", NamedTextColor.YELLOW))
return
}
runBlocking { banRepository.deactivateBan(ban.playerUuid) }
banCacheService.removeCachedBan(ban.playerUuid)
banBroadcastService.publishUnban(ban.playerUuid, playerName)
source.sendMessage(Component.text("Unbanned $playerName.", NamedTextColor.GREEN))
}
override fun hasPermission(invocation: SimpleCommand.Invocation): Boolean {
return invocation.source().hasPermission("bansystem.unban")
}
}
What does UnbanCommand do?
It looks up the active ban by player name, deactivates it in MongoDB, removes it from the Redis cache, and broadcasts the unban so every proxy drops it from their local cache too.
- Save UnbanCommand.kt.
- Build to confirm both commands compile:
./gradlew build
You should see BUILD SUCCESSFUL again.
Seeing unresolved references to BanBroadcastService?
Double check the import at the top of each command file is com.example.bansystem.pubsub.BanBroadcastService. If your IDE did not auto-import it, add the import manually.
Why can't my command files find BanBroadcastService?
Register commands and test cross-proxy propagation
The last command to add is /baninfo, which queries MongoDB for the full ban record. After that, you will wire all three commands into the plugin and verify the full broadcast flow.
- Create BanInfoCommand.kt in the command package and paste the first section:
package com.example.bansystem.command
import com.example.bansystem.repository.BanRepository
import com.velocitypowered.api.command.SimpleCommand
import kotlinx.coroutines.runBlocking
import net.kyori.adventure.text.Component
import net.kyori.adventure.text.format.NamedTextColor
import java.time.Instant
import java.time.ZoneId
import java.time.format.DateTimeFormatter
class BanInfoCommand(
private val banRepository: BanRepository
) : SimpleCommand {
override fun execute(invocation: SimpleCommand.Invocation) {
val source = invocation.source()
val args = invocation.arguments()
if (args.isEmpty()) {
source.sendMessage(Component.text("Usage: /baninfo <player>", NamedTextColor.RED))
return
}
val playerName = args[0]
- Below the val playerName line, add the query logic and info display:
val ban = runBlocking { banRepository.findBanByPlayerName(playerName) }
if (ban == null) {
source.sendMessage(Component.text("$playerName has no active bans.", NamedTextColor.YELLOW))
return
}
val formatter = DateTimeFormatter.ofPattern("yyyy-MM-dd HH:mm:ss")
.withZone(ZoneId.systemDefault())
val info = Component.text()
.append(Component.text("--- Ban Info: $playerName ---", NamedTextColor.GOLD))
.append(Component.newline())
.append(Component.text("Reason: ", NamedTextColor.GRAY))
.append(Component.text(ban.reason, NamedTextColor.WHITE))
.append(Component.newline())
.append(Component.text("Banned by: ", NamedTextColor.GRAY))
.append(Component.text(ban.issuerName, NamedTextColor.WHITE))
.append(Component.newline())
.append(Component.text("Banned at: ", NamedTextColor.GRAY))
.append(Component.text(formatter.format(Instant.ofEpochMilli(ban.createdAt)), NamedTextColor.WHITE))
.append(Component.newline())
.append(Component.text("Expires: ", NamedTextColor.GRAY))
.append(Component.text(ban.formatExpiry(), NamedTextColor.WHITE))
.build()
source.sendMessage(info)
}
- After the closing } of the execute method, add the permission check and close the class:
override fun hasPermission(invocation: SimpleCommand.Invocation): Boolean {
return invocation.source().hasPermission("bansystem.info")
}
}
What does BanInfoCommand do?
It queries MongoDB for the active ban by player name. If found, it formats a multi-line component showing reason, issuer, timestamp, and remaining duration. The bansystem.info permission restricts who can view ban details.
- Save BanInfoCommand.kt.
- Now update BanPlugin.kt to wire everything together. Replace the entire file contents with this first section:
package com.example.bansystem
import com.google.inject.Inject
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.proxy.ProxyServer
import com.example.bansystem.cache.BanCacheService
import com.example.bansystem.command.BanCommand
import com.example.bansystem.command.BanInfoCommand
import com.example.bansystem.command.UnbanCommand
import com.example.bansystem.listener.LoginListener
import com.example.bansystem.pubsub.BanBroadcastService
import com.example.bansystem.repository.BanRepository
import org.slf4j.Logger
@Plugin(
id = "ban-system",
name = "Network Ban System",
version = "1.0-SNAPSHOT",
authors = ["Developer"]
)
class BanPlugin @Inject constructor(
private val server: ProxyServer,
private val logger: Logger
) {
private lateinit var banRepository: BanRepository
private lateinit var banCacheService: BanCacheService
private lateinit var banBroadcastService: BanBroadcastService
- Directly below the lateinit declarations, add the service initialization and listener registration:
@Subscribe
fun onProxyInitialize(event: ProxyInitializeEvent) {
banRepository = BanRepository("mongodb://localhost:27017")
logger.info("Connected to MongoDB - ban collection ready")
banCacheService = BanCacheService("redis://127.0.0.1:6379")
logger.info("Connected to Redis - ban cache ready")
banBroadcastService = BanBroadcastService(
banCacheService = banCacheService,
redisAddress = "redis://127.0.0.1:6379",
server = server,
logger = logger
)
logger.info("Ban broadcast service started")
server.eventManager.register(this, LoginListener(banCacheService, banRepository, logger))
- Directly below the listener registration line, add the command registrations, log line, and shutdown handler to close the file:
val commandManager = server.commandManager
commandManager.register(
commandManager.metaBuilder("ban").plugin(this).build(),
BanCommand(banRepository, banCacheService, banBroadcastService, server)
)
commandManager.register(
commandManager.metaBuilder("unban").plugin(this).build(),
UnbanCommand(banRepository, banCacheService, banBroadcastService)
)
commandManager.register(
commandManager.metaBuilder("baninfo").plugin(this).build(),
BanInfoCommand(banRepository)
)
logger.info("Network Ban System loaded successfully!")
}
@Subscribe
fun onProxyShutdown(event: ProxyShutdownEvent) {
banCacheService.shutdown()
banBroadcastService.shutdown()
banRepository.close()
}
}
What changed in the plugin?
- A third lateinit var for banBroadcastService was added alongside the existing repository and cache references.
- The broadcast service is instantiated after the cache service, since it depends on the cache for listener callbacks.
- All three commands are registered via commandManager.metaBuilder("ban").plugin(this).build() which associates each command alias with this plugin.
- onProxyShutdown now calls banBroadcastService.shutdown() to cleanly close the second RedissonClient.
- Save BanPlugin.kt.
- Build the full project one more time:
./gradlew build
You should see BUILD SUCCESSFUL with the shadow jar produced in build/libs/.
Before you test the full propagation flow, predict: if you start two Velocity instances both subscribed to the same RTopic, and you run /ban TestPlayer 1h griefing on instance A, what do you expect to see in instance B's logs?
- Copy the shadow jar from build/libs/ into the plugins/ folder of two separate Velocity installations (running on different ports).
- Start both Velocity instances.
- On instance A's console, run:
ban TestPlayer 1h griefing
- Check instance B's logs.
You should see Received ban broadcast for TestPlayer in instance B's console output. The ban was broadcast via RTopic and the second proxy updated its local cache without any restart.
Not seeing the broadcast log on instance B?
- Confirm both instances connect to the same Redis container. Check that redis://127.0.0.1:6379 resolves correctly from both.
- Run redis-cli SUBSCRIBE network:ban-events in a separate terminal to verify messages are actually reaching Redis.
- Check that both Velocity instances loaded the plugin by looking for Network Ban System loaded successfully! in their startup logs.
Why isn't my RTopic broadcast reaching the second Velocity instance?
Your ban system now propagates bans across every proxy in real time. Next up, you will put it to the ultimate test with a live player connection.
Secret mission
Kick Online Players on Ban
Your ban system blocks future logins, but a banned player who is already connected can keep causing damage until they log out. In this mission, you will add instant cross-proxy kick so that the moment a ban is issued, the player is disconnected from whichever proxy they are on.
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 using Docker containers and free open-source tools, so there are no ongoing cloud costs.
Resources you used:
- Redis Docker container (used for Redisson RMapCache and RTopic pub/sub).
- MongoDB Docker container (used for persistent ban storage).
- Velocity server instance (the proxy running your ban plugin).
- Minestom test server (lightweight backend for testing connections).
- Project folder on your Desktop containing all Kotlin source files and Gradle build configuration.
Keep everything running
No action needed. Choose this if you are still actively building or testing the ban system.
- Your Redis and MongoDB containers stay running and retain all ban data.
- Your project folder remains on your Desktop ready for further development.
- You can restart Velocity and Minestom at any time to resume testing.
Pause - I'll come back to this later
Shut down running processes to free up memory and CPU. Your files and Docker data persist for when you return.
- Stop the Velocity server and Minestom server by pressing Ctrl+C in their respective terminal windows.
- Stop the Redis and MongoDB containers by running:
docker stop redis mongodb
When you come back, restart the containers with:
docker start redis mongodb
Delete - I don't want to use this again
Remove all project resources and start fresh. This deletes your ban data, project files, and Docker containers permanently.
- Stop the Velocity server and Minestom server by pressing Ctrl+C in their terminal windows.
- Stop and remove the Redis and MongoDB Docker containers by running:
docker stop redis mongodb
docker rm redis mongodb
- Delete the project folder from your Desktop. On macOS or Linux, run:
cd ~/Desktop
rm -rf ban-system
On Windows, run:
cd ~\Desktop
rmdir /s /q ban-system
- Remove the Docker volumes holding Redis and MongoDB data by running:
docker volume prune
Nice Work!
Nice Work!
You just built a production-grade network ban system that enforces bans at the proxy level, propagates them across instances in real time, and auto-expires temporary bans without a single cleanup job.
You've learned how to:
- Build a Velocity proxy plugin from scratch using Kotlin and Gradle, with MongoDB as a persistent ban store and Redisson RMapCache delivering sub-millisecond ban lookups with automatic TTL-based expiry.
- Wire Redisson RTopic pub/sub to broadcast ban and unban events across multiple proxy instances in real time, so a ban issued on one proxy takes effect network-wide within milliseconds.
- Create a complete command interface (/ban, /unban, /baninfo) with permission checks, duration parsing, and formatted player-facing disconnect messages.
- Secret Mission: Implemented instant player kick on ban via cross-proxy RTopic messaging, disconnecting online players the moment a ban is issued from any proxy instance.
Ready to quiz yourself?