Modular event plugins for a dedicated Paper/Folia Minecraft event server.
The base plugin owns the parts that every event needs: commands, player selection, session state, the shared lobby, late joins, leaving, kicking and stopping. Each event plugin only implements its own gameplay.
This README is written for two audiences:
- Server staff who need to run and configure events.
- Developers who want to create a new event, even if they are new to this project.
- Project structure
- How an event starts
- Build and deployment
- Base plugin configuration
- Base commands and permissions
- Creating a new event
- Important concepts
- Race event
surf-survival-events
|-- surf-survival-events-base
| Shared base plugin. Must be installed on the event server.
|
|-- surf-survival-events-events
| |-- surf-survival-event-example
| | Copy this module when creating a new event.
| |
| `-- surf-survival-event-race
| Current race event implementation.
|
`-- surf-survival-events-freebuild
Plugin for the freebuild/survival server side integration.
Every event is its own Gradle subproject below
surf-survival-events-events. Event jars are deployed next to the base jar.
Players join the dedicated event server.
|
v
If no event is running, the base plugin sends them to the server lobby.
|
v
A community manager runs /survivalevents start <game>.
|
v
The base plugin:
1. finds the registered GameHandler for <game>
2. reads its GameOptions
3. checks that enough eligible players are online
4. loads or creates the default event world
5. splits players into active players, reserves and spectators
6. calls the event handler hooks
|
v
The event plugin teleports players, gives items and runs the actual game.
|
v
The event is stopped by command or by the handler itself.
Only one base event session can be active at a time.
Build the project with Gradle:
.\gradlew.bat buildFor a normal event server deployment, install at least:
surf-survival-events-base- one or more event plugins, for example
surf-survival-event-race
Event plugins declare a required server dependency on
surf-survival-events-base, so the base plugin must be present and enabled.
The base config is generated at:
plugins\surf-survival-events-base\config.yml
Current structure:
# Lobby of the event server. Players wait here before an event starts.
serverLobby:
world_key: minecraft:overworld
x: 0.0
y: 64.0
z: 0.0
pitch: 0.0
yaw: 0.0
# How online players are selected when /survivalevents start <game> is used.
start:
# Players with any of these permissions are ignored at event start.
excludedPermissions: [ ]
# Broadcasts a message after the event was handed to the handler.
announceStart: true
# Behaviour for players joining the event server.
join:
# Send players to the server lobby if they join while no event is active.
teleportToServerLobbyWhenIdle: true
# Auto-register players who join while an event is already running.
autoJoinRunningEvent: true
# Send a hint if a player joins during an event but cannot auto-join.
announceRunningEventOnJoin: trueUse /survivalevents set-server-lobby in game to update serverLobby.
Handlers can send a player back to this lobby with:
GameService.teleportToServerLobby(player)Root command: /survivalevents
| Command | Permission | Description |
|---|---|---|
/survivalevents start <game> |
surf.survival.events.command.community_manager |
Start a registered event. |
/survivalevents stop |
surf.survival.events.command.community_manager |
Stop the active event. |
/survivalevents spectator |
surf.survival.events.command.spectator |
Join the active event as spectator. |
/survivalevents leave |
surf.survival.events.command.player |
Leave the active event or spectator list. |
/survivalevents participants [page] |
surf.survival.events.command.spectator |
Show active, reserve and spectator players. |
/survivalevents kick <targetPlayer> |
surf.survival.events.command.spectator |
Remove a player from the active event. |
/survivalevents menu |
surf.survival.events.command.community_manager |
Open the event overview menu. |
/survivalevents set-server-lobby |
surf.survival.events.command.community_manager |
Save your current position as server lobby. |
/survivalevents reload |
surf.survival.events.command.community_manager |
Reload the base config. |
The fastest and safest path is to copy
surf-survival-events-events\surf-survival-event-example and rename it.
The example module already shows the intended patterns:
ExampleConfigfor event-specific config.ExampleGamefor lifecycle hooks.PaperMainfor config loading and handler registration.
Create a new folder:
surf-survival-events-events\surf-survival-event-my-event
Add it to the root settings.gradle.kts:
include(":surf-survival-events-events:surf-survival-event-my-event")Create gradle.properties in your new module:
main=dev.slne.surf.survival.events.myevent.PaperMain
authors=your-nameThe parent build file surf-survival-events-events\build.gradle.kts already
applies the Paper plugin and adds compileOnly(project(":surf-survival-events-base"))
for every event subproject. Your event module still needs a build.gradle.kts,
but it can be empty unless you need extra dependencies.
Every event needs a GameKey.
val KEY = GameKey.builder<MyEventGame>()
.key("my_event")
.displayName("MY EVENT")
.skullTexture("base64 skull texture")
.build()The key is important because it is used for:
- the start command:
/survivalevents start my_event - registry lookup in
GameRegistry - the default event world
Do not rename the key after the event is released unless you also migrate configs, commands and world data.
The base plugin creates or loads the default event world with
GameWorldService.worldKeyFor(KEY). For key my_event, that world key is
surf-event:my_event. In handler hooks, use context.eventWorld instead of
hardcoding a world name.
Use GamePosition for locations inside the event world. It stores only
coordinates and rotation. The world comes from the current GameContext.
package dev.slne.surf.survival.events.myevent
import dev.slne.surf.api.core.config.SpongeYmlConfigClass
import dev.slne.surf.survival.events.base.game.RunningJoinPolicy
import dev.slne.surf.survival.events.base.game.StartOverflowPolicy
import dev.slne.surf.survival.events.base.util.GamePosition
import org.spongepowered.configurate.objectmapping.ConfigSerializable
@ConfigSerializable
data class MyEventConfig(
var gameplay: GameplayConfig = GameplayConfig(),
var playerSpawn: GamePosition = GamePosition(),
var spectatorSpawn: GamePosition = GamePosition(),
var reserveSpawn: GamePosition = GamePosition()
) {
companion object : SpongeYmlConfigClass<MyEventConfig>(
MyEventConfig::class.java,
plugin.dataPath,
"config.yml"
)
@ConfigSerializable
data class GameplayConfig(
var minPlayersToStart: Int = 1,
var activePlayerLimit: Int? = null,
var overflowPolicy: StartOverflowPolicy = StartOverflowPolicy.SPECTATOR,
var runningJoinPolicy: RunningJoinPolicy = RunningJoinPolicy.SPECTATOR,
var autoJoinRunningPlayers: Boolean = true
)
}Generated config path:
plugins\surf-survival-event-my-event\config.yml
GameHandler is the event lifecycle contract. Simple events usually override:
optionsonStartedonRunningSpectatorJoinonParticipantRemoveonStop
Minimal beginner-friendly example:
package dev.slne.surf.survival.events.myevent
import dev.slne.surf.survival.events.base.game.*
import dev.slne.surf.survival.events.base.service.GameService
import kotlinx.coroutines.future.await
import org.bukkit.Bukkit
import org.bukkit.GameMode.ADVENTURE
import org.bukkit.entity.Player
import java.util.UUID
class MyEventGame : GameHandler {
companion object {
val KEY = GameKey.builder<MyEventGame>()
.key("my_event")
.displayName("MY EVENT")
.skullTexture("base64 skull texture")
.build()
}
override val options: GameOptions
get() {
val gameplay = MyEventConfig.getConfig().gameplay
return GameOptions(
minPlayersToStart = gameplay.minPlayersToStart,
mode = if (gameplay.activePlayerLimit == null) {
GameMode.ALL_AT_ONCE
} else {
GameMode.BATCHED
},
start = GameStartOptions(
activePlayerLimit = gameplay.activePlayerLimit,
overflow = gameplay.overflowPolicy
),
spectatorsEnabled = true,
runningJoinPolicy = gameplay.runningJoinPolicy,
autoJoinRunningPlayers = gameplay.autoJoinRunningPlayers
)
}
context(context: GameContext)
override suspend fun onStarted() {
context.gamePlayers.forEach { uuid ->
Bukkit.getPlayer(uuid)?.let { setupPlayer(it) }
}
context.reservePlayers.forEach { uuid ->
Bukkit.getPlayer(uuid)?.let { setupReserve(it) }
}
context.spectators.forEach { uuid ->
Bukkit.getPlayer(uuid)?.let { setupSpectator(it) }
}
}
context(context: GameContext)
override suspend fun onRunningSpectatorJoin(player: Player) {
setupSpectator(player)
}
context(context: GameContext)
override suspend fun onParticipantRemove(
uuid: UUID,
player: Player?,
role: ParticipantRole,
reason: PlayerRemoveReason
) {
player?.inventory?.clear()
if (player != null && reason != PlayerRemoveReason.DISCONNECT) {
GameService.teleportToServerLobby(player)
}
}
context(context: GameContext)
override suspend fun onStop(reason: GameStopReason) {
context.onlineEventPlayers.forEach { player ->
player.inventory.clear()
GameService.teleportToServerLobby(player)
}
}
context(context: GameContext)
private suspend fun setupPlayer(player: Player) {
player.gameMode = ADVENTURE
player.teleportAsync(MyEventConfig.getConfig().playerSpawn.toLocation()).await()
}
context(context: GameContext)
private suspend fun setupReserve(player: Player) {
player.gameMode = ADVENTURE
player.teleportAsync(MyEventConfig.getConfig().reserveSpawn.toLocation()).await()
}
context(context: GameContext)
private suspend fun setupSpectator(player: Player) {
player.teleportAsync(MyEventConfig.getConfig().spectatorSpawn.toLocation()).await()
}
}Important beginner notes:
context.gamePlayerscontains UUIDs of active players.context.reservePlayerscontains UUIDs that wait for the event handler.context.spectatorscontains UUIDs of spectators.context.onlineGamePlayers,context.onlineReservePlayersandcontext.onlineSpectatorsskip offline players for you.- Always check
reason != PlayerRemoveReason.DISCONNECTbefore teleporting a removed player. A disconnected player cannot be teleported.
package dev.slne.surf.survival.events.myevent
import com.github.shynixn.mccoroutine.folia.SuspendingJavaPlugin
import dev.slne.surf.survival.events.base.game.GameRegistry
import dev.slne.surf.survival.events.base.game.GameStopReason
import dev.slne.surf.survival.events.base.service.GameService
import org.bukkit.plugin.java.JavaPlugin
val plugin get() = JavaPlugin.getPlugin(PaperMain::class.java)
class PaperMain : SuspendingJavaPlugin() {
override suspend fun onEnableAsync() {
MyEventConfig.init()
GameRegistry.register(MyEventGame.KEY, MyEventGame())
}
override suspend fun onDisableAsync() {
if (GameService.isActiveGame(MyEventGame.KEY)) {
GameService.stopGameAndWait(GameStopReason.PLUGIN_DISABLE)
}
GameRegistry.unregister(MyEventGame.KEY)
}
}- Build and deploy the base jar and your event jar.
- Start the server and confirm both plugins enable without errors.
- Configure the base lobby with
/survivalevents set-server-lobby. - Configure your event spawns in your event config.
- Join with at least
minPlayersToStarteligible players. - Run
/survivalevents start my_event. - Test leaving, disconnecting, spectating and stopping.
GameOptions controls the generic behaviour that the base plugin can handle
for every event.
GameOptions(
minPlayersToStart = 1,
mode = GameMode.ALL_AT_ONCE,
start = GameStartOptions(
activePlayerLimit = null,
overflow = StartOverflowPolicy.SPECTATOR
),
spectatorsEnabled = true,
runningJoinPolicy = RunningJoinPolicy.SPECTATOR,
autoJoinRunningPlayers = true
)| Option | Meaning |
|---|---|
minPlayersToStart |
Minimum eligible online players required to start. |
mode |
Descriptive category for the handler: all at once, batched, elimination or custom. |
start.activePlayerLimit |
Maximum active players at session start. null means all selected players. |
start.overflow |
What happens to eligible players beyond the active limit. |
spectatorsEnabled |
Whether the base plugin may register spectators. |
runningJoinPolicy |
Default result for players joining after the event started. |
autoJoinRunningPlayers |
Whether join events may auto-call the running join flow. |
GameOptions is read when the session starts. It is safe to read your config
inside the options getter.
| Value | Behaviour |
|---|---|
SPECTATOR |
Extra selected players become spectators. |
RESERVE |
Extra selected players wait in context.reservePlayers. |
IGNORE |
Extra selected players are not registered in the session. |
| Value | Behaviour |
|---|---|
DENY |
Late joiners cannot join the running event. |
SPECTATOR |
Late joiners become spectators. |
PLAYER |
Late joiners become active players. |
RESERVE |
Late joiners join the reserve list. |
CUSTOM |
Override onRunningJoin(player) and return a RunningJoinResult. |
| Hook | When it runs |
|---|---|
customizeWorldCreator(creator) |
Before the default event world is created. |
customizeEventWorld(world) |
After the default event world was created or loaded. |
onStarting() |
Session exists, world is loaded, status is still STARTING. |
onStarted() |
Session is RUNNING; teleport and initialize players here. |
onRunningJoin(player) |
A player joined after start; return a RunningJoinResult. |
onRunningPlayerJoin(player) |
Late join was registered as active player. |
onRunningReserveJoin(player) |
Late join was registered as reserve. |
onRunningSpectatorJoin(player) |
Late join was registered as spectator. |
onParticipantRoleChange(uuid, previousRole, newRole) |
GameService.setParticipantRole changed a role. |
onParticipantRemove(uuid, player, role, reason) |
Player left, was kicked, disconnected or the event stopped. |
onStop(reason) |
Event is stopping; clean up tasks, entities, scoreboards and players. |
All hook functions that declare context(context: GameContext) can access the
current snapshot as context.
GameContext is an immutable snapshot of the base session. If you need the
latest state after joins, kicks or role changes, call GameService.snapshot()
again.
Common properties:
| Property | Description |
|---|---|
key |
The active event key. |
options |
Options captured when the event started. |
status |
Current base lifecycle status. |
eventWorld |
Default world loaded for this event. |
eventWorldName |
Name of eventWorld. |
gamePlayers |
Active player UUIDs. |
reservePlayers |
Reserve player UUIDs. |
spectators |
Spectator UUIDs. |
participantPlayers |
Active plus reserve UUIDs. |
allEventPlayers |
Active, reserve and spectator UUIDs. |
onlineGamePlayers |
Online active Bukkit players. |
onlineReservePlayers |
Online reserve Bukkit players. |
onlineSpectators |
Online spectator Bukkit players. |
onlineEventPlayers |
All online event players, including spectators. |
Round or bracket events can keep their own state and update the base snapshot:
GameService.setParticipantRole(uuid, ParticipantRole.PLAYER)
GameService.setParticipantRole(uuid, ParticipantRole.RESERVE)
GameService.setParticipantRole(uuid, ParticipantRole.SPECTATOR)This changes only the base role. Your event still needs to teleport players, give or remove items and update its own state.
The race event is registered with key race.
Start it through the base system:
/survivalevents start race
The base plugin starts a BATCHED event:
playersPerRoundplayers become active racers.- all other selected players become reserves.
- spectators are enabled.
- late joins are handled by custom race logic.
/survivalevents start racestarts the base session./race startmoves racers to the start area./race startagain starts the countdown when the race is waiting./race next-round [advance-count]evaluates the current heat or final.- Repeat
/race startand/race next-rounduntil the final is finished.
Generated at:
plugins\surf-survival-event-race\config.yml
Important fields:
gameplay:
playersPerRound: 10
qualifiersPerRound: 1
finalWinnerCount: 3
laps: 2
lateJoinAsSpectator: true
roundLobbyLocation:
x: 0.0
y: 0.0
z: 0.0
yaw: 0.0
pitch: 0.0
spectatorLocation:
x: 0.0
y: 0.0
z: 0.0
yaw: 0.0
pitch: 0.0
checkpoints: [ ]
barriers: [ ]
starts: [ ]Like other events, race locations use GamePosition and do not store a world.
They are resolved in the race event world from GameContext.
Root command: /race
| Command | Permission | Description |
|---|---|---|
/race start |
surf.survival.events.race.command.community_manager |
Prepare racers or start the countdown, depending on race state. |
/race next-round [advance-count] |
surf.survival.events.race.command.community_manager |
Evaluate the current heat/final and prepare the next step. |
/race stop |
surf.survival.events.race.command.community_manager |
Stop the active race event. |
/race reload |
surf.survival.events.race.command.community_manager |
Reload race config. |
/race leaderboard |
surf.survival.events.race.command.spectator |
Show the current race placement list. |
/race kick <player> |
surf.survival.events.race.command.spectator |
Remove a player from the race. |
/race set-laps <value> |
currently no explicit permission in code | Set lap count. |
/race set-players-per-round <value> |
surf.survival.events.race.command.community_manager |
Set active racers per heat. |
/race set-qualifiers-per-round <value> |
surf.survival.events.race.command.community_manager |
Set default qualifiers for normal heats. |
/race set-final-winner-count <value> |
surf.survival.events.race.command.community_manager |
Set default winner count for the final. |
/race set-lobby <location> <rotation> |
currently no explicit permission in code | Set the round lobby location. |
/race set-spectator <location> <rotation> |
currently no explicit permission in code | Set the spectator location. |
| `/race set-checkpoint <pos1 | pos2 | create> ` |
/race remove-checkpoint <checkpointNumber> |
surf.survival.events.race.command.community_manager |
Remove a checkpoint by id. |
| `/race set-start <pos1 | pos2 | create> ` |
| `/race set-barrier <pos1 | pos2 | create> ` |
| `/race list <checkpoints | start | barrier>` |
- Disconnecting racers keep their race state.
- Reconnecting players are restored to their previous race role when possible.
- New late joiners can join as reserves while the race still accepts late participants.
- Later new joiners become spectators.