- Java 91.1%
- Go 6.4%
- Shell 0.7%
- Makefile 0.6%
- Dockerfile 0.6%
- Other 0.6%
| Filename | Latest commit message | Latest commit date |
|---|---|---|
- WcMcsgPlugin: guard 'Waiting on N players' broadcast with current > 0 so empty lobbies stop spamming the console every 30s - instance_controller: add RunAsNonRoot + SeccompProfile (RuntimeDefault) to pod, RunAsNonRoot + Capabilities.Drop=ALL to both containers to satisfy PodSecurity restricted:latest - pool_controller: treat 409 Conflict on status update as a silent requeue instead of an ERROR-level stack trace |
||
| .forgejo/workflows | ||
| chart | ||
| controller | ||
| docs | ||
| images | ||
| plugin | ||
| scripts | ||
| .gitattributes | ||
| .gitignore | ||
| .ignore | ||
| Makefile | ||
| README.md | ||
k8s-mcsg-uhc
A Kubernetes-managed Minecraft Survival Games network, faithful to the classic MCSG/MCGamer experience. Lobbies are ephemeral pods: an always-open lobby accepts players, the game starts, the lobby closes to new joins, the match plays out, the pod tears down, and a fresh lobby spins up to replace it. The whole system is packaged as a Helm chart so anyone can deploy it to a cluster with minimal setup.
The design is game-type-agnostic: the game type is a field on the pool CR, not hardcoded, so UHC or other minigames can be added later without restructuring (see the UHC Validation section of the design spec).
Architecture
Player connects (25565)
|
v
+-------------------+
| Velocity Proxy | (LoadBalancer)
| + wc-velocity | watches MinecraftGameInstance CRs,
| | registers/deregisters servers,
| | enforces bans/mutes, staff chat,
| | announcements, fly/invis sync
+---------+---------+
|
+-----------+-----------+
| |
v v
+-------------------+ +-------------------+
| Hub Server | | Game Pod (x N) |
| (Deployment) | | (Pod per game) |
| SportPaper 1.8 + | | SportPaper 1.8 + |
| wc-hub plugin | | wc-mcsg plugin |
| | | |
| Lists CRs, | | Runs one SG |
| renders signs, | | match, patches |
| live leaderboard | | own CR status |
| holograms | | (phase, alive) |
+---------+---------+ +---------+---------+
| |
| |
v v
+-----------+ +---------------+
| Redis | | MongoDB |
| (pub/sub | | (StatefulSet)|
| only, | | stats, coins,|
| no PVC) | | moderation, |
+-----------+ | staff state, |
| match history |
+---------------+
+-------------------+
| Controller | (Deployment, 1 replica)
| Go operator | reconciles pools + instances,
| | creates/deletes pods + services,
| | picks lobby map per instance
+-------------------+
Components:
- Controller (Go, controller-runtime): reconciles
MinecraftGamePoolandMinecraftGameInstanceCRDs. Creates pods/services for game instances, maintains the target lobby count, picks a lobby map per instance, tears down ended games. - Velocity proxy +
wc-velocityplugin: player entrypoint. Watches CRs to dynamically register/deregisters servers and transfers players between hub and lobbies. Owns cross-server concerns: ban/mute enforcement, staff chat, announcements, and fly/invis state sync across game servers. - Hub server +
wc-hubplugin: always-on lobby selector. Lists open game instances from CRs, renders clickable signs, sends transfer requests to the proxy. Displays live leaderboard holograms (armor stands) updated via Redis pub/sub. Optional (built-in or external hub). - Game pods +
wc-mcsgplugin: ephemeral, one SG match per pod. The plugin runs the full game lifecycle (voting, countdown, match, deathmatch), patches its own CR status (phase, alivePlayers, winner), and enforces in-game staff commands. - MongoDB: persistent stats, coin balances, match history, moderation records, player name index, staff state, first-seen tracking.
- Redis: ephemeral pub/sub for in-lobby map voting and live leaderboard updates. No persistence (pub/sub only; MongoDB is the source of truth).
- Helm chart (
wc-minecraft): deploys all of the above.
Repo layout
k8s-mcsg-uhc/
plugin/ Java/Gradle multi-project
wc-common/ shared: K8s client, MongoDB, Redis, state machine,
permissions, moderation, coins, stats, leaderboards
wc-velocity/ proxy plugin: server registration, transfer, ban/mute
enforcement, staff chat, announcements, staff-state sync
wc-hub/ hub plugin: lobby signs, CR listing, live leaderboard
holograms
wc-mcsg/ SG game plugin: game logic, voting, MCSG features,
CR status patching, in-game admin commands
controller/ Go operator (cmd/, internal/, api/)
images/ Dockerfiles for server, hub-server, velocity, maps
chart/ Helm chart wc-minecraft (CRDs, controller, mongodb,
redis, hub, velocity, staff/announcements ConfigMaps)
docs/specs/ design documents
docs/superpowers/ specs + implementation plans
.forgejo/workflows/ CI (commit-check, ci, release)
scripts/ kind-test.sh and utility scripts
Makefile plugin / controller / images / chart / test / lint
Game flow
Player connects -> Velocity proxy -> hub server
-> player clicks a lobby sign (or is transferred automatically)
-> joins a game pod (lobby state)
Lobby state:
- Server boots on a lobby map (random from pool.lobbyMaps)
- All game maps from the pool are available on a side volume
- Players wait; voting starts when the lobby reaches the min-vote threshold
Voting (in-lobby):
- 5 game maps randomly selected from the pool, numbered 1-5 in chat
- Players type /vote 1-5 (one vote each, changeable)
- A staff member can /veto <map> to force a specific map
Countdown:
- Starts when minPlayers is reached
- At countdown end, the voted map (or /veto override, or a random game map
if fewer than the vote threshold) is loaded via WorldCreator and all
participants are teleported to its spawn
In-game:
- Grace period (PvP off), then PvP enabled
- Tiered chests (tier 1/2), sponsor GUI (spectators spend coins to drop
items at living players), supply drop at the 10-minute mark (tier 3)
- Block interaction restricted to an allowlist; tracking compass
Deathmatch:
- Triggers at deathmatchPlayerThreshold players remaining
- Timer (default 120s); on expiry, overtime lightning strikes a random
living player every 3s until one (or zero) remain
- Border enforcement: players outside the arena radius take damage + knockback
End:
- Winner credited +10 coins; killer credits (+1 normal, +2 deathmatch)
- stats.updated event published to Redis -> hub leaderboard holograms refresh
- CR status transitions to "ending"; after endingTtl the controller tears
down the pod and spins up a replacement lobby
Features
MCSG faithfulness
- Tiered chests - tier 1 (common) and tier 2 (uncommon) loot tables,
loaded per-map from
chests.yml. Chests fill on first open. - Sponsors - spectators open a coin-backed GUI: pick an item from the sponsor catalogue (each with a coin cost), pick a living player, and the item drops at their feet. Insufficient coins grey out the item. Only spectators can sponsor; only living participants can be sponsored.
- Supply drops - at the 10-minute mark of in-game time, a tier-3 chest spawns at one of 5 preset map locations. A 10-second chat warning with coordinates precedes it; a beacon beam marks the spot. Once-per-game.
- Deathmatch - configurable timer (default 120s). On expiry with more than one player alive, overtime lightning strikes a random living player every 3 seconds until one remains (lightning is the tiebreaker, not "most kills").
- Deathmatch border - during deathmatch, players outside the arena radius take damage and knockback toward the center every second.
- Block restriction - during a game, non-op block breaks are limited to an allowlist (leaves, vines, grass, flowers, mushrooms, web). Placement is limited to flint and steel. Ops bypass for map maintenance.
- Sidebar - scoreboard sidebar on every participant/spectator: server name, map name, alive/tributes count, game phase, your kills this game, your coin balance. Refreshed on phase change, kill, death, sponsor spend, and a 2-second tick.
- Chat formatting - color-coded death broadcasts, phase announcements, sponsor notifications, supply-drop alerts, and win announcements.
- Tracking compass - points toward the nearest living player.
- Coins - per-player integer balance persisted in MongoDB. Earned: +1 per normal kill, +2 per deathmatch kill, +10 per win. Spent on sponsors.
Map voting
- The server boots onto a lobby map (randomly selected from the pool's
lobbyMaps, preferring maps not already in use). - All game maps from the pool are copied to a side volume at boot.
- Voting starts when the lobby reaches the vote threshold (default 6
players). Players type
/vote 1through/vote 5for one of 5 randomly selected game maps. One vote per player, changeable. - At countdown end, the most-voted map is loaded at runtime via
WorldCreatorfrom the side volume, and all participants teleport to its spawn. The sidebar and CR status reflect the actually-played map. - With fewer than the vote threshold at countdown, a random game map is played (chat: "Not enough players for voting; playing on .").
/veto <map>(staff) forces a specific map, canceling the vote.
Real-time leaderboards
- Game pods publish
stats.updatedevents to Redis on every kill and win. - The hub subscribes via Redis pub/sub, debounces 5 seconds, re-fetches the top-N from MongoDB, and updates armor-stand holograms at configured locations.
- Holograms are custom (no external plugin): each line is an invisible armor stand with a custom name, stacked 0.25 blocks apart.
- Redis is ephemeral (no PVC). If Redis is down, game pods are unaffected (stats still write to MongoDB) and the hub shows the last-known leaderboard until the next successful event.
- If
redis.enabled=false, the hub shows a static leaderboard fetched once on startup (no live updates).
Clans
- Players spend 1000 coins to create a clan (
/clan create <name> [tag]). Clan names are 3-16 chars (alphanumeric + underscore), tags are 1-5 chars (alphanumeric). Both are case-insensitive (stored lowercase). - Anyone can join a clan (
/clan join <name>) up to a max of 10 members. - The leader can appoint up to 3 elders (
/clan promote <player>). Elders can kick regular members but not other elders or the leader. - Kills and wins earn clan points alongside personal coins: +1 per normal kill, +2 per deathmatch kill, +10 per win. Clan leaderboards (points, kills, wins) display on the hub via holograms.
- Clan tags appear in chat (death broadcasts, win announcements) and the scoreboard sidebar.
/clansubcommands: create, join, leave, disband, info, promote, demote, kick, list, top, tag. Leader leaving disbands the clan automatically.- Clans are stored in MongoDB (
clanscollection); membership is aclanIdfield on the player document.
Admin and staff tooling
Permissions - staff.yml ConfigMap maps UUIDs to permission nodes
(* is the only wildcard). Loaded by PermissionManager (wc-common) on
the proxy, game pods, and the hub. Console always bypasses permission
checks; ops (from the OPS env var, game pods only) get implicit *
permission. Bukkit attachments are applied on join and refreshed on
/staffreload, which refreshes the proxy, game pods, and the hub.
Moderation - MongoDB-backed bans, mutes, and kicks. Moderation
documents use a compound _id (<uuid>:<type>); unbanning/unmuting sets
expiresAt to now (history is retained). Ban enforcement lives on the
Velocity proxy (PreLoginEvent) and fails open on MongoDB errors (a
transient blip must not lock everyone out). Mute enforcement is defense in
depth: the proxy cancels chat for muted players, and the game pod also
checks on PlayerChatEvent.
Staff commands - generic commands are implemented once in wc-common
and declared in each host plugin's plugin.yml; SG-specific commands stay
in wc-mcsg. /fly and /invis are also registered on the hub:
| Command | Description |
|---|---|
/sc <msg> |
Staff chat (proxy-relayed to all staff) |
/fly |
Toggle flight; state synced cross-server via wc:staffstate (game pods + hub) |
/invis |
Toggle invisibility; state synced cross-server (game pods + hub) |
/tp <player> |
Teleport to target |
/heal |
Full health + food, extinguish |
/feed |
Full food + saturation |
/clearchat |
Clear chat for all online players |
/lockchat |
Toggle chat lock (non-staff blocked when locked) |
/invsee <player> |
Open target inventory (read-only) |
/spectate <player> |
Spectate mode; excluded from alive set and game-end logic |
/veto <map> |
Override map vote |
/sgsetup <sub> |
Author a map's spawn points (load/center/add/dm/drop/finish) and export map.yml |
/gamestate |
Show phase, map, alive count/names, time, deathmatch status |
/resetstats <player> |
Delete a player's stats document |
/setcoins <player> <n> |
Set a player's coin balance |
Proxy commands (wc-velocity): /ban, /kick, /mute, /unban,
/unmute, /modhistory, /announce [scope] <msg>, /sc, /staffreload.
Announcements - timed rotating messages (sequential or random) with
per-message scope (all/hub/game) and prefix overrides. First-join welcome
message fires once per player (atomic firstSeen upsert in MongoDB). Manual
/announce <msg> and /announce game|hub <msg> for scoped broadcasts.
Staff state sync - fly and invis state persist in MongoDB
(StaffStateStore) and sync across game servers and the hub via the
wc:staffstate plugin message channel on ServerConnectedEvent. Every
host (game pods and the hub) runs the shared wc-common
StaffStateReceiver, so persisted state applies on join everywhere.
alivePlayers CRD status - the game plugin patches
status.alivePlayers (list of UUIDs) on every alive-set change (join,
death, quit-eliminate). The controller passes it through without logic;
it is observable via kubectl get minecraftgameinstances -o yaml.
OPS and whitelist
game.ops(chart values): comma-joined and injected as theOPSenv var on every game pod. The itzg minecraft-server image uses this for server ops; the wc-mcsg plugin treats op IGNs as implicit*permission holders.game.whitelist(chart values): when enabled, injectsENABLE_WHITELIST=trueandWHITELIST=<players>on every game pod. The plugin enforces the whitelist server-side (Velocity proxying already gates ingress).
Installation
Helm install
helm install wc-minecraft ./chart \
--namespace wc-minecraft --create-namespace \
--set velocity.type=LoadBalancer \
--set velocity.loadBalancerIP=192.168.1.60 \
--set pools[0].desiredOpenLobbies=5
Or, once published, from the Forgejo Helm registry:
helm repo add willowcrest https://git.willowcrest.world/api/packages/crypt/helm
helm install wc-minecraft willowcrest/wc-minecraft \
--namespace wc-minecraft --create-namespace
The release namespace is set with --namespace; the chart does not create a
Namespace resource (use --create-namespace or pre-create it). All chart
resources are namespaced into .Release.Namespace.
Verify:
kubectl -n wc-minecraft get minecraftgamepools
kubectl -n wc-minecraft get minecraftgameinstances
kubectl -n wc-minecraft get pods
See chart/README.md for the full values reference, external-hub setup,
RBAC, NetworkPolicy, secrets, and CRD upgrade notes.
Key values.yaml sections
| Section | Purpose |
|---|---|
registry |
Container registry prefix for all chart-managed images |
controller |
Operator image, replicas, resources |
hub |
Built-in hub Deployment (set enabled: false for external hub) |
velocity |
Proxy Service type (LoadBalancer/NodePort/ClusterIP), forwarding secret |
game |
Default server/world images, resources, OPS, whitelist, velocity secret |
staff |
staff.yml ConfigMap (UUID -> permission nodes); configMap for bring-your-own |
announcements |
announcements.yml ConfigMap (interval, mode, messages, welcome) |
mongodb |
StatefulSet, auth secret, PVC size/storageClass |
redis |
Ephemeral pub/sub StatefulSet (no PVC); enabled: false to disable |
pools |
Game pools (gameType, worldSource, capacity, maps, per-pool gameConfig) |
networkPolicy |
NetworkPolicy enable + API server CIDR |
imagePullSecrets |
Applied to all chart-managed workloads + the wc-game SA |
SportPaper setup
The server and hub images require sportpaper.jar (SportPaper 1.8.8,
gitignored, not vendored). SportPaper is published to the Forgejo Maven
registry at world.willowcrest:SportPaper:1.8.8-R0.1.
- In CI: the
SPORTPAPER_VERSIONrepository variable (Settings > Actions > Variables) controls which version is fetched from the Forgejo Maven registry. If unset, server/hub image builds are skipped (velocity and maps always build). - For local builds: source
sportpaper.jarfrom the Forgejo Maven registry or a local SportPaper build, then place it in bothimages/server/andimages/hub-server/. See the Dockerfiles for details.
Map images
Map files (.mca) are tracked via Git LFS (.gitattributes). The maps
image (images/maps/) has two subdirectories: lobby/ (lobby map
variations) and game/ (game maps). The init container copies one lobby
map to /data/ (for server startup) and all game maps to a side volume
for runtime world loading during voting. images/maps/ ships with one game
map (alaskan-village), one lobby map (lobby1), and a hub world; add more
maps under images/maps/maps/game/ and images/maps/maps/lobby/ as needed.
CI
Forgejo Actions (.forgejo/workflows/). ci.yml is build/lint/test only
and runs on every pull request; publishing happens on tag pushes via
release.yml.
commit-check.yml- conventional commit format on every PR.ci.yml- path-filtered stages:- plugin - Spotless check, Gradle build + test.
- controller - go vet, build (static, CGO disabled), golangci-lint, unit tests (envtest-dependent tests skipped in CI).
- chart - helm lint, kubeconform on rendered manifests, package (validates the chart packages).
release.yml- triggered by semver tag push (v*.*.*). Builds all artifacts (plugin jars, controller image, the 4 Docker images, Helm chart), tags them with the clean semver, publishes to Forgejo registries (Maven, container, Helm), and creates a Forgejo release with the chart package attached.
KinD integration test
make kind-test runs scripts/kind-test.sh, which spins up a KinD
cluster, builds the controller image, installs the chart with mock busybox
images for the Java components (no SportPaper needed), and verifies the
controller creates game instances and pods.
make kind-test # run, clean up after
KEEP=1 make kind-test # leave the cluster running for debugging
FULL=1 make kind-test # build and load real images (needs SportPaper)
Deployment
The chart is designed to be self-contained in its own namespace (wc-minecraft)
so it can be installed, upgraded, and removed independently of other cluster
workloads. MongoDB data lives on a PVC and survives undeploy by default.
Quick start
# 1. Build and publish all images + chart (triggered by a semver tag)
make release VERSION=0.1.0
# 2. Deploy to the cluster (creates namespace wc-minecraft)
make deploy
# 3. Get the proxy IP (players connect here)
kubectl -n wc-minecraft get svc wc-minecraft-velocity -o jsonpath='{.status.loadBalancer.ingress[0].ip}'
# 4. Configure staff (edit values.yaml or use --set)
helm upgrade wc-minecraft chart/ --namespace wc-minecraft \
--set 'staff.staff[0].uuid=YOUR-UUID' \
--set 'staff.staff[0].name=YOUR-IGN' \
--set 'staff.staff[0].role=admin' \
--set 'staff.staff[0].permissions[0]=*'
Homelab deployment (ArgoCD GitOps)
The willowcrest-homelab repo has an ArgoCD Application at
k8s/infra/argocd/manifests/applications/wc-minecraft.yaml that deploys
this chart from main with homelab-specific overrides (MetalLB IP,
node selectors, pool config). ArgoCD syncs automatically with self-heal.
Deploy order (important):
- Publish images first:
make release VERSION=0.1.0(triggers release workflow which builds and pushes all 5 images to the Forgejo registry) - Create the image pull secret in the wc-minecraft namespace:
kubectl -n forgejo get secret forgejo-pull -o yaml | \ sed 's/namespace: forgejo/namespace: wc-minecraft/' | \ kubectl apply -f - - Merge the homelab PR - ArgoCD syncs automatically, pods start with the already-published images
Upgrade flow:
- Merge changes to
mainin the k8s-mcsg-uhc repo (chart/code changes) - Publish a new version:
make release VERSION=0.2.0 - Update
chart.AppVersionand the ArgoCD app values (if needed) to reference the new version - ArgoCD self-heals with the new image tags
Deploy from local chart (dev)
make deploy # local chart, default values
make deploy REGISTRY=git.willowcrest.world/crypt # use Forgejo container registry
Deploy a released version
make deploy VERSION=0.1.0 # pulls chart from Forgejo Helm registry
Undeploy
make undeploy # remove all pods/services, KEEP MongoDB data
make undeploy PURGE=1 # remove everything including MongoDB data (irreversible)
Data export / import (for cluster migration or backup)
make export-data # exports to ./backups/
make export-data OUTPUT=/tmp/my-backup.tar.gz # custom path
make import-data INPUT=backups/wc-minecraft-20260927.tar.gz # restore
The export runs mongodump inside the MongoDB pod via kubectl exec - no
local MongoDB tools needed. The archive is a gzipped BSON dump of all
collections (players, moderation, playerNames, matches, counters). Use
this to migrate between clusters or back up before teardown.
Maps
Place Minecraft 1.8 world folders in images/maps/maps/lobby/ and
images/maps/maps/game/. See images/maps/maps/README.md for the
expected layout. Add map names to pools[].maps (game) and
pools[].lobbyMaps (lobby) in chart/values.yaml.
Configuration
Key values.yaml sections:
| Section | What it controls |
|---|---|
pools |
Game pools: world source, maps, capacity, per-pool game rules, desiredOpenLobbies |
game.ops |
Bukkit ops (IGN list, injected via OPS env var) |
game.whitelist |
Server whitelist (enabled + player IGN list) |
staff.staff |
Staff permissions (UUID + name + role + permissions list) |
announcements |
Timed rotating broadcasts + first-join welcome |
mongodb.persistence |
PVC storage class + size |
hub.signs |
Hub sign locations for lobby listing |
hub.leaderboards |
Hologram locations for live leaderboards |
Development
Prerequisites: JDK 17-21 (Gradle 8.5 cannot run on JDK 25), Go 1.22+,
Helm 3 or 4, Docker or buildah, and controller-gen (for CRD regeneration).
make plugin # build all Java jars (Gradle wrapper, no installed Gradle needed)
make controller # build the Go operator binary (static, CGO disabled)
make images # stage jars + fetch SportPaper + build all 4 Docker images
make push-images # push all images to the registry
make release-images # build controller + all images + push (one command)
make chart # helm lint + kubeconform + package
make test # plugin + controller unit tests
make lint # spotless + golangci-lint + helm lint
make crd # regenerate CRDs from Go types (controller-gen)
make kind-test # KinD integration test
The controller tests use controller-runtime envtest (a real kube-apiserver
- etcd). Install the assets once with:
go install sigs.k8s.io/controller-runtime/tools/setup-envtest@latest
"$(go env GOPATH)/bin/setup-envtest" use 1.30.0 --bin-dir /tmp/opencode/envtest-bin
The test harness finds them under /tmp/opencode/envtest-bin automatically.
In CI, envtest-dependent integration tests are skipped (the runner cannot
download envtest binaries); they run locally where setup-envtest is
available.
Java formatting
Spotless with google-java-format enforces consistent formatting. Run
make lint (or cd plugin && ./gradlew spotlessCheck) to verify; run
cd plugin && ./gradlew spotlessApply to auto-format. Java 8 is used for
the game plugins (SportPaper 1.8) and Java 17 for wc-velocity.
Notes
JAVA_HOMEmust point at JDK 17-21 formake plugin/make images. The default PATH JDK (often 8 or 25) will not work. Example:export JAVA_HOME=/usr/lib/jvm/java-21-openjdk.- The controller image (
controller/Dockerfile) runs the binary under distroless static, so it must be built withCGO_ENABLED=0.make controllersets this; a barego buildproduces a dynamically-linked binary that distroless cannot exec. - The K8s Java client is
io.kubernetes:client-java:25.0.0-legacy(not 27.x - the legacy variant was dropped after 25.x). Gradle Shadow shades wc-common and its dependencies into each plugin jar with package relocation, so each plugin jar is self-contained.
Documentation
- Design spec:
docs/specs/2026-09-26-mcsg-k8s-design.md - MCSG faithfulness spec:
docs/superpowers/specs/2026-09-27-mcsg-faithfulness-design.md - Map voting + leaderboard spec:
docs/superpowers/specs/2026-09-27-map-voting-leaderboard-design.md - Admin/staff tooling plan:
docs/superpowers/plans/2026-09-27-admin-staff-tooling.md - Chart reference:
chart/README.md
License
Willowcrest internal. Not for redistribution.