Titan is a complete Minestom-based Minecraft lobby server that provides various quality-of-life features to enhance player experience. It contains everything needed to run a fully functional Minestom server.
- Sitting System: Allows players to sit on specific blocks like stairs
- Tickle Mechanic: Players can tickle each other using feathers, with cooldown periods
- Elytra Boost: Provides boost functionality for players using elytra
- Height Teleportation: Automatically teleports players when they exceed certain height limits
- Java 24 or higher
Titan is built as two app variants, one Gradle module each under apps/:
apps/cloudnetbuildstitan-cloudnet.jar- the production variant, deployed behind CloudNet, shipped with a JDK 25 AOT cache (titan-cloudnet.aot) for faster startup.apps/localbuildstitan-local.jar- the development variant, started standalone without CloudNet, no AOT cache.
Both variants bundle the same lobby feature columns (features/*) and behave the same for
players and operators, except for permissions - see "Permissions" below. Building from source
(./gradlew build) produces both jars under apps/<variant>/build/libs/.
Migration note: older releases built a single jar from the previous :app module. A
deployment must switch to apps/cloudnet's titan-cloudnet.jar and retrain its AOT cache against
it (see "Running the Server" below) - the old single-jar build is no longer produced or
published.
Player and console permission checks go through a PermissionService a permission platform
module provides via dependency injection - LuckPerms in production, nothing by default in
development:
apps/cloudnetalways bundles the LuckPerms platform module and refuses to start without it (the startup check aborts, naming the missing module) - a deployment always has real permissions.apps/localships without LuckPerms by default: every player permission check is denied, while the console can still run every command it's allowed to run regardless of permissions (e.g.stop). Build with LuckPerms included for local permission testing:./gradlew :apps:local:build -Ptitan.luckperms.- Every start logs the active service:
Permissions resolved by luckpermsorPermissions resolved by deny-all. - With the LuckPerms platform, the lobby starts and stops LuckPerms itself, in-process - it is no
longer loaded as a Minestom extension. Do not deploy
extensions/luckperms.jar- a leftover one makes the lobby refuse to start (it would otherwise load LuckPerms twice). LuckPerms' own data and configuration stay indata/next to the jar, unchanged from before - keep that directory across an upgrade. - Butterfly (tab list, name prefix, chat): Titan 2.0 removed the built-in Butterfly, which
provided the tab-list sorting (via teams), the coloured prefix above players' heads and the chat
format (LuckPerms group prefix). It comes back as the Minestom extension shipped in the
net.onelitefeather:butterfly-minestomartifact itself; Titan needs no code for it - the extension uses the in-process LuckPerms throughLuckPermsProvider.get().- Put exactly ONE
butterfly-minestomjar intoextensions/, of version 1.1.1 or newer (1.1.0 was the first with the extension but spams "Error creating missing file flags.properties" on every spawn and does not update team prefixes of players who are already online; not on Maven yet, use the GitHub release asset butterfly-minestom-1.1.1.jar, sha256033c7ddf02daae27635a1467d4f9055ea52fb1133a2b3eb1a22b3030fbb3226e). - Older versions have no
extension.jsonand abort startup withMissing extension.json in extension butterfly-minestom-...jar. - Without it, the lobby runs without tab sorting, prefix and chat format.
- Optional settings live in
extensions/Butterfly/flags.properties(defaults apply when absent), not inapplication.yaml. - If LuckPerms is not available, Butterfly logs an error and stays inactive (no crash).
- Put exactly ONE
- A CloudNet permission query for a player returns the same result as a permission check inside
the lobby, including LuckPerms contexts (e.g. a permission granted only for
server=lobby). titan-cloudnet.jar's classpath changed with this permission platform - retrain its AOT cache against the new jar before deploying it (see "Running the Server" above). ThegenerateAotCachetraining run itself starts real LuckPerms too, against a disposabledata/created fresh in its own training directory, never the deployment'sdata/.
- Download the latest release from the releases page (
titan-cloudnet.jarfor a CloudNet deployment,titan-local.jarfor standalone/development use) - Run the server using:
java -jar titan-cloudnet.jar(ortitan-local.jar) - The server runs with every module's shipped defaults if no configuration file is present; copy
application.example.yamlfrom the distribution (next to the jar) toapplication.yamland edit it to customize (see Configuration below)
Once installed, you can:
- Start the server with additional memory:
java -Xmx2G -jar titan-cloudnet.jar - Start the production variant with its AOT cache for faster startup:
java -XX:AOTCache=titan-cloudnet.aot -jar titan-cloudnet.jar(both files ship together in the same release;apps/localships no AOT cache). After upgrading to a new build, retrain the cache against the new jar rather than reusing an older.aotfile - the build's owngenerateAotCacheGradle task does this by running the jar againstworlds/for a short training window (seebuildSrc/src/main/kotlin/titan.app-variant.gradle.kts). Builds without aworlds/directory (such as CI releases) skip that task and publish the jar without the.aot; train it at deployment in the service directory withjava -XX:AOTCacheOutput=titan-cloudnet.aot -Dtitan.aot.trainSeconds=20 -jar titan-cloudnet.jar, then start with-XX:AOTCache=titan-cloudnet.aotas above. - Use the console to manage the server while it's running
- Stop the server safely by typing
stopin the console
You can configure server properties like port, MOTD, and more in the generated configuration files.
Configuration lives in application.yaml in the lobby's working directory (next to the jar), one
named section per lobby feature module, keys following the <module-id>.<field> schema. The
shipped defaults live inside the jar; a commented application.example.yaml listing every section
and key with its default also ships in the distribution, next to the jar - copy it to
application.yaml and edit only the values that should differ. A missing file, a missing section
or a missing key falls back to the shipped default, listed below. The lobby never creates or writes
a configuration file itself.
A profile file application-<profile>.yaml, next to application.yaml, only needs to set the keys
that differ for that profile - everything else still comes from the base file. Activate one or more
profiles with the environment variable AVAJE_PROFILES (e.g. AVAJE_PROFILES=dev) or the system
property -Davaje.profiles=dev. Every start logs the active profiles at INFO:
Active configuration profiles: [...].
An external file can be layered in via the environment variable CONFIG_FILE or the system
property -Dconfig.file=... (e.g. for a Kubernetes ConfigMap or a CloudNet template file outside
the working directory).
Every key can also be set directly via an environment variable or a system property. Rank order, low to high:
- the shipped default,
application.yaml,- the active profile's
application-<profile>.yaml, - the external file selected via
CONFIG_FILE/config.file, - an environment variable,
- a system property (
-D...).
An environment variable's name is the dotted key, upper-cased, with . replaced by _ and -
dropped - e.g. spawn.simulationDistance becomes SPAWN_SIMULATIONDISTANCE, and the matching
system property is -Dspawn.simulationDistance=.... A list value is set as a single
comma-separated value via an environment variable or system property, e.g.
SIT_ALLOWEDBLOCKS=minecraft:oak_stairs,minecraft:spruce_stairs.
An invalid value - from application.yaml, a profile or an override - aborts startup with a
message naming the full key (<module-id>.<field>) and the reason (e.g. a negative cooldown, or
spawn.minHeight not less than spawn.maxHeight). An unknown or misspelled key is no longer
reported - it is silently ignored, and the lobby starts using the shipped default for that key.
spawn:
minHeight: -64
maxHeight: 310
simulationDistance: 2
sit:
offset:
x: 0.5
y: 0.25
z: 0.5
allowedBlocks:
- minecraft:spruce_stairs
tickle:
cooldownMillis: 4000
elytra:
burnDurationTicks: 30
cooldownTicks: 40
features:
NAVIGATOR_CREATIVE: false
NAVIGATOR_SLENDER: false
NAVIGATOR_MANIS: false
NAVIGATOR_SURVIVAL: false
NAVIGATOR_ELYTRA: falsespawn.minHeight/spawn.maxHeight: height bounds a player is teleported back to spawn outside ofspawn.simulationDistance: simulation distance sent to a player on spawn - also the only key the setup server reads (see "Setup server" below)sit.offset: offset from the clicked block's position to the seat (x, y, z)sit.allowedBlocks: block keys players may sit down on, e.g.minecraft:spruce_stairstickle.cooldownMillis: duration of the tickle cooldown in millisecondselytra.burnDurationTicks: how many ticks a lit firework rocket boosts a flying player for - the boost itself is Vanilla's own client-side firework impulse (ported from Voyager'sFireworkBoostTracker/Rockets), not a server-applied velocity, so there is no multiplier to configureelytra.cooldownTicks: how many ticks after a boost starts before the player may use another rocket; must be strictly greater thanelytra.burnDurationTicks, since it is measured from the burn's startfeatures: plain booleans, one per feature flag, with the same sources and override order as every other key (see "Feature flags" below).
The navigator - a feather in hotbar slot 4 opening a shared inventory with ElytraRace, Survival,
Slender and Creative - has no configuration section: its title and destinations are fixed in code
(NavigatorModule/Destination), not read from application.yaml. A navigator.* key set here
or anywhere else has no effect. Only Slender is gated behind a flag, features.NAVIGATOR_SLENDER
(see "Feature flags" below) - toggling it takes effect the next time a player opens the navigator,
with no restart of the navigator or the lobby.
| Key | Environment variable |
|---|---|
spawn.minHeight |
SPAWN_MINHEIGHT |
spawn.maxHeight |
SPAWN_MAXHEIGHT |
spawn.simulationDistance |
SPAWN_SIMULATIONDISTANCE |
sit.offset.x |
SIT_OFFSET_X |
sit.offset.y |
SIT_OFFSET_Y |
sit.offset.z |
SIT_OFFSET_Z |
sit.allowedBlocks (comma-separated) |
SIT_ALLOWEDBLOCKS |
tickle.cooldownMillis |
TICKLE_COOLDOWNMILLIS |
elytra.burnDurationTicks |
ELYTRA_BURNDURATIONTICKS |
elytra.cooldownTicks |
ELYTRA_COOLDOWNTICKS |
features.<NAME> |
FEATURES_<NAME> |
<NAME> is a feature flag's own name, upper-cased - e.g. features.NAVIGATOR_SLENDER becomes
FEATURES_NAVIGATOR_SLENDER. There is no environment variable for the navigator's title or
destinations - they are fixed in code, not configuration (see "Configuration Options Explained"
above).
The lobby can pick up a change to its configuration files without a restart, using avaje-config's
own built-in file watcher. It is off by default; an operator turns it on in their own
working-directory application.yaml, an active profile's application-<profile>.yaml, or the
file selected via CONFIG_FILE/config.file, with:
config.watch.enabled: trueOnce enabled, it watches only the configuration files that already existed on disk at startup - a
file created afterwards is picked up only on the next restart. Every config.watch.period seconds
(default 10, the first check happening config.watch.delay seconds after startup, also default
10) it re-reads every watched file and applies whatever changed behind the
io.avaje.config.Config facade.
No module, and no part of the lobby, ever restarts to pick up a change. Instead, every module
reads its settings live, at the moment it needs them, rather than once at startup: tickle reads
tickle.cooldownMillis on every attack, sit reads sit.offset.*/sit.allowedBlocks on every block
interaction, elytra reads elytra.burnDurationTicks/elytra.cooldownTicks on every boost, spawn
reads spawn.minHeight/spawn.maxHeight on every height check and spawn.simulationDistance on
every join, and the navigator evaluates features.NAVIGATOR_SLENDER every time it is opened - its
title and destinations are otherwise fixed in code, not read from configuration at all.
Once the watcher applies a change behind the facade, the very next such read sees the new value.
Because nothing restarts, no in-flight, per-player state is ever lost - a player who is already
sitting stays sitting even if sit.offset.* changes underneath them, and a player mid-elytra-boost
keeps that boost even if elytra.burnDurationTicks/elytra.cooldownTicks change; the new value
only applies the next time each is used.
Configuration is validated only once, at startup - an invalid value found there still aborts the
start, unchanged from before. A live read, at the point of use, is never re-validated and never
falls back to a shipped classpath default: an invalid value simply takes effect (e.g. a negative
tickle.cooldownMillis) until an operator corrects the file - validate a value before saving it.
If a watched file is not valid YAML after a change, avaje-config itself logs the
file and the location of the error at ERROR (over java.util.logging, not the lobby's own
SLF4J-backed logs) and applies no value from it; every other changed file is still applied.
Accepted limits of this built-in watcher (see design.md, decision 1, in
openspec/changes/config-reload-feature-flags):
- an environment variable or system property override for a key is displaced by a changed file's value for that same key, until the lobby is next restarted;
- a key deleted from a changed file stays active with its old value until the next restart;
- a file created after startup is only picked up on the next restart;
- there is no manual trigger - a change takes effect only once the watcher notices it, at most
config.watch.delayplusconfig.watch.periodafter it was made; - a syntactically broken file is logged by avaje-config itself over
java.util.logging, not SLF4J, so that ERROR line may not appear alongside the rest of the lobby's own logs.
Feature flags are plain booleans under the features section, one per flag name (e.g.
features.NAVIGATOR_SLENDER: true), with the same sources and override order as every other
configuration key - a profile's file, an external file, an environment variable
(FEATURES_NAVIGATOR_SLENDER), or a system property. The five flags the lobby ships with, all
false by default: NAVIGATOR_CREATIVE, NAVIGATOR_SLENDER, NAVIGATOR_MANIS,
NAVIGATOR_SURVIVAL and NAVIGATOR_ELYTRA. Only NAVIGATOR_SLENDER currently gates anything -
the navigator's Slender destination. Changing its value takes effect the next time a player opens
the navigator, without restarting any module or the lobby.
flags.properties and Togglz are no longer read. Move every line over by hand:
flags.properties |
application.yaml |
or environment variable |
|---|---|---|
NAME=true |
features.NAME: true |
FEATURES_NAME=true |
Remove flags.properties from the working directory once its values are migrated - a leftover
copy has no effect any more.
For local testing, create an application-local.yaml next to application.yaml with every flag
turned on. It also turns on the file watcher from "Runtime reloading" above, so a flag flipped
back off in the file takes effect without a restart while testing:
features:
NAVIGATOR_CREATIVE: true
NAVIGATOR_SLENDER: true
NAVIGATOR_MANIS: true
NAVIGATOR_SURVIVAL: true
NAVIGATOR_ELYTRA: true
config.watch.enabled: trueActivate the local profile with the environment variable AVAJE_PROFILES=local or the system
property -Davaje.profiles=local (see "Profiles and overrides" above).
app.json is no longer read or converted. Before upgrading a server that still has one, either
start the previous release once - it switches app.json over to application.yaml on its own, as
described in that release's docs - or transfer the values by hand into a new application.yaml
(same sections and keys as before). A leftover app.json or app.json.migrated next to the jar is
ignored and does not affect startup; once application.yaml is in place, either file can be
deleted.
The setup server no longer edits configuration - the /setup app ... commands have been removed.
It only reads spawn.simulationDistance (default 2) from the same configuration.
Lobby portals (see docs/lobby-modules.md) are edited with /setup portal
(players only, like all /setup commands) and stored in the map.json of the loaded world. Ids
consist of lower-case letters, digits, - and _; list, show and create are reserved.
/setup portal list: saved portals (id, shape, task, permission) and, separately, your open drafts/setup portal show: outlines of all saved portals as particles, only for you, for a few seconds/setup portal create <id>: guided flow in chat with clickable buttons for each step (shape, corners or centre and radius, task, permission, then save or cancel). A button runs exactly one of the commands below; buttons that need free input put the command into your chat box./setup portal <id> pos1|pos2: box corners at your block/setup portal <id> shape box|ring/setup portal <id> centre: ring centre and normal from your eyes and view direction/setup portal <id> radius <r>: ring radius only/setup portal <id> disc <r>: ring from your eyes, view direction and radius in one step/setup portal <id> task <task>/setup portal <id> permission <permission|none>/setup portal <id> save|cancel|remove
Every edit only changes your draft and answers with what is still missing, or "complete" with a
[save] button. Nothing is written until /setup portal <id> save, which validates the portal;
cancel (or disconnecting) discards the draft. While a draft is open, a live particle preview is
shown to you only. Tab completion suggests portal and draft ids, verbs, known tasks, none, box
and ring.
The lobby reads portals only at startup: restart it to pick up changed portals.
A CloudNet template, a Docker image or a Kubernetes deployment delivers application.yaml (or an
external file referenced via CONFIG_FILE) into the working directory and sets AVAJE_PROFILES
for the environment it runs in.
Before rolling this change out to an existing deployment, migrate every flags.properties line to
features.* in application.yaml or to an environment variable, as described in "Migrating from
flags.properties" above, then remove flags.properties from the template. Add
config.watch.enabled: true to the deployment's own application.yaml/profile file/CONFIG_FILE
if it should pick up configuration changes without a restart.
After rolling out, the start log's "Active configuration profiles" line confirms which profiles are
active. If the file watcher is enabled, changing a watched key and waiting up to
config.watch.delay plus config.watch.period confirms the reload works: exercising the affected
module afterwards (e.g. triggering a tickle attack after changing tickle.cooldownMillis) shows
the new value took effect, with no restart of any kind appearing in the log. Rollback: deploy
the previous jar and restore flags.properties - a leftover features.* section or
config.watch.* setting in application.yaml does not affect the previous jar.
The cloudnet variant can run the lobby in a season-specific world during a time window, for
example a winter map in December. To add one:
-
Build the world in the setup server.
-
Put it in
worlds/<name>/with itsmap.json, next toworlds/world/. -
Configure it in
application.yaml:seasons: zone: Europe/Berlin # default; time zone of the windows winter: # any id ("zone" is reserved) world: winter # directory under worlds/ from: "2026-12-01T00:00:00" # inclusive, local time in seasons.zone to: "2027-01-07T00:00:00" # exclusive enabled: true # kill switch, no restart of the configuration needed
Date-times must be quoted (from: "2026-12-01T00:00:00"); the YAML loader drops an unquoted
value, and it is reported as missing.
An invalid enabled season aborts startup with the key and the reason. Switching worlds needs a
restart: once a minute (and after every disconnect) the lobby compares its started world with the
one the calendar wants, and stops only when nobody is online so the supervisor starts it again. A
busy service therefore waits; an operator can end it sooner with /stop. apps/local does not
include this feature.
- Clone the repository
- Build using Gradle:
./gradlew clean build
Run tests using:
./gradlew test
Code coverage reports are generated using JaCoCo and can be found in build/reports/jacoco/.
A lobby feature is a column: its own Gradle module under features/<name>/, depending only on
core, discovered automatically by Avaje Inject once the app variant includes it - there is no
central feature list to edit:
- New module
features/<name>/, with abuild.gradle.ktsthat applies thetitan.columnconvention plugin and apackage-info.javadeclaring@InjectModule(name = "<name>Column", requires = {...})for the platform types the column needs (e.g. the shared event node) - seedocs/lobby-modules.mdfor the exact pattern. Copy an existing column (e.g.features/tickle/) or the template feature atapps/cloudnet/src/test/java/net/onelitefeather/titan/runtime/feature/example/(ExampleModuleand friends) as a starting point. - The
<Name>Moduleclass is a plain@jakarta.inject.Singletonbean with a uniquestatic final int EVENT_PRIORITY- it decides the order in which two features process the same event, not a start order; the seven event-driven features use gaps of 100 (protection 100, spawn 200, respawn 300, navigator 400, sit 500, tickle 600, elytra 700). A class with an@PostConstructmethod that is missing@Singletonfails the build (a shared ArchUnit rule every column applies to itself via its ownColumnArchitectureTest); two features sharing anEVENT_PRIORITYfail the lobby's start instead (FeatureNode.attachthrows, naming both features and the position).@PostConstruct start()attaches the feature's ownFeatureNode;@PreDestroy stop()detaches it again. - Dependencies (a platform service such as
Deliver, anInstance, aClock, theScheduler, ...) are requested through the constructor;@jakarta.inject.Injectis only needed on a constructor when the class has more than one. A brand-new shared platform service is added as another@Beaninruntime's platform bean factory, or, if it carries feature-spanning logic of its own rather than wrapping a platform type, as its own@Singletonclass. - A hotbar or equipment item is a
@Bean LobbyItemfrom the feature's own, package-private@Factoryclass, collected by thehotbarcolumn'sLobbyItemsbean. - Zero changed lines outside the new module - except a brand-new shared platform service, which
necessarily touches
runtime. - A dependency nothing provides fails building the
BeanScope(and with it, the lobby's start), naming the missing type, instead of the lobby quietly running without that feature. Likewise, if an app variant expects the new column but it failed to load, the start aborts naming the missing column (seedocs/lobby-modules.md, "Erwartete Columns einer Variante"). - The actual start order is visible at runtime in one INFO log line:
Lobby features started in event order: {}. - A feature that reads configuration reads it live, at the point it is used, not just once in
start()- seedocs/lobby-modules.mdfor the pattern (a directConfig.<method>(key)call at the use site, unvalidated - configuration is validated only once, at startup). That is what makes the runtime reload described under "Runtime reloading" above apply to a feature without it ever restarting. A column with configuration ships its own defaults infeatures/<name>/src/main/resources/titan/defaults/<name>.yaml, merged into the shippedapplication.yamlwhen an app variant is built.
See docs/lobby-modules.md (German) for the full walkthrough - the
module graph, feature anatomy, FeatureNode, items and tasks as beans, tick-thread rules, test
setup without a harness, the shared ArchUnit rules, and a copyable template feature with its
tests.
This project is licensed under the Apache License 2.0 - see the LICENSE file for details.
Developed by OneLiteFeather Network.