Skip to content

Configuration

The configuration system lets mods define settings files that are automatically loaded and saved. Use the withConfig() API with BuilderCodec for type-safe config values.

Create a class with your settings as fields:

public class MyConfig {
public static final BuilderCodec<MyConfig> CODEC = BuilderCodec.builder(
MyConfig.class, MyConfig::new
)
.append(new KeyedCodec<>("MaxPlayers", Codec.INTEGER),
(cfg, val) -> cfg.maxPlayers = val,
cfg -> cfg.maxPlayers)
.add()
.append(new KeyedCodec<>("WelcomeMessage", Codec.STRING),
(cfg, val) -> cfg.welcomeMessage = val,
cfg -> cfg.welcomeMessage)
.add()
.build();
private int maxPlayers = 100; // default value
private String welcomeMessage = "Welcome!"; // default value
// getters
public int getMaxPlayers() { return maxPlayers; }
public String getWelcomeMessage() { return welcomeMessage; }
}

Initialize the config as a field (must be done during field initialization, not in lifecycle methods):

public class MyMod extends JavaPlugin {
private final Config<MyConfig> config = withConfig(MyConfig.CODEC);
public MyMod(JavaPluginInit init) {
super(init);
}
@Override
protected void start() {
// access config values
MyConfig cfg = config.get();
int max = cfg.getMaxPlayers();
String msg = cfg.getWelcomeMessage();
}
}

The config file is created at:

mods/{group}_{name}/config.json

The directory name uses your mod’s group and name from manifest.json, joined with underscore.

Example content:

{
"MaxPlayers": 100,
"WelcomeMessage": "Welcome!"
}

Use Codec.STRING, Codec.INTEGER, Codec.BOOLEAN, etc. for field types. See Codecs for the full list.

Add validators between .append() and .add() to validate config values:

.append(new KeyedCodec<>("MaxPlayers", Codec.INTEGER),
(cfg, val) -> cfg.maxPlayers = val,
cfg -> cfg.maxPlayers)
.addValidator(Validators.nonNull()) // required field
.addValidator(Validators.range(1, 1000)) // must be 1-1000
.add()

Common validators:

ValidatorPurpose
nonNull()Field is required
range(min, max)Value between min and max (inclusive)
min(value)Value >= minimum
max(value)Value <= maximum
greaterThan(value)Value > threshold
lessThan(value)Value < threshold
equal(value)Must equal specific value
notEqual(value)Must not equal value
nonEmptyString()String must not be empty
nonEmptyArray()Array must have elements
nonEmptyMap()Map must have entries
arraySize(n)Array must have exactly n elements
arraySizeRange(min, max)Array size in range
uniqueInArray()No duplicate elements
or(v1, v2, ...)Any validator passes

Validation errors are logged during config loading.

Implement Validator<T> for custom validation logic:

// inline lambda
.addValidator((value, results) -> {
if (value % 2 != 0) {
results.fail("Must be an even number");
}
})
// reusable validator
public static <T extends Comparable<T>> Validator<T> exclusiveRange(T min, T max) {
return (value, results) -> {
if (value != null && (value.compareTo(min) <= 0 || value.compareTo(max) >= 0)) {
results.fail("Must be between " + min + " and " + max + " (exclusive)");
}
};
}

Use withConfig(name, codec) to register additional config files:

private final Config<GeneralConfig> general = withConfig(GeneralConfig.CODEC); // config.json
private final Config<SpawnConfig> spawns = withConfig("spawns", SpawnConfig.CODEC); // spawns.json
private final Config<RewardsConfig> rewards = withConfig("rewards", RewardsConfig.CODEC); // rewards.json

All registered configs are loaded in parallel during preLoad() and stored in mods/{group}_{name}/.

For data that isn’t tied to a specific entity — world-level settings, leaderboards, arena configs — use the codec system directly with RawJsonReader and BsonUtil:

public class MyPlugin extends JavaPlugin {
// static config for mod settings
private final Config<MyConfig> config = withConfig(MyConfig.CODEC);
// per-world data loaded at runtime
private final Map<String, WorldData> worldData = new HashMap<>();
@Override
protected void start() {
// load data for each world
for (World world : Universe.get().getWorlds().values()) {
Path path = getDataDirectory()
.resolve("worlds/" + world.getName() + ".json");
WorldData data = RawJsonReader.readSyncWithBak(
path, WorldData.CODEC, getLogger());
if (data == null) {
data = new WorldData(); // defaults if file doesn't exist
}
worldData.put(world.getName(), data);
}
}
// save a specific world's data
public void saveWorldData(String worldName) {
WorldData data = worldData.get(worldName);
if (data != null) {
Path path = getDataDirectory()
.resolve("worlds/" + worldName + ".json");
BsonUtil.writeSync(path, WorldData.CODEC, data, getLogger());
}
}
}

BsonUtil.writeSync() and BsonUtil.writeDocument() automatically create parent directories, so you don’t need to create worlds/ manually.

This is the same pattern used by built-in plugins — TeleportPlugin saves warps with BsonUtil.writeDocument(), and BarterShopState persists shop state with BsonUtil.writeSync().

MethodBehavior
RawJsonReader.readSync(path, codec, logger)Read JSON file synchronously. Throws IOException if the file doesn’t exist
RawJsonReader.readSyncWithBak(path, codec, logger)Read with .bak fallback. Returns null if neither file exists
BsonUtil.writeSync(path, codec, value, logger)Write JSON synchronously, creates .bak backup
BsonUtil.writeDocument(path, doc)Write BsonDocument asynchronously, returns CompletableFuture
BsonUtil.readDocument(path)Read BsonDocument asynchronously, returns CompletableFuture

For the async variants, encode/decode manually:

// async write
BsonDocument doc = MyData.CODEC.encode(data, new ExtraInfo());
BsonUtil.writeDocument(path, doc);
// async read
BsonDocument doc = BsonUtil.readDocument(path).join();
MyData data = MyData.CODEC.decode(doc, ExtraInfo.THREAD_LOCAL.get());

Use getDataDirectory() for all mod file storage:

Path dataDir = getDataDirectory(); // mods/{group}_{name}/

You can create any subdirectory structure under this path:

mods/mygroup_mymod/
├── config.json # from withConfig()
├── spawns.json # from withConfig("spawns", ...)
└── worlds/ # from manual file I/O
├── overworld.json
└── nether.json

The Config<T> object provides methods for runtime config management:

// get the loaded value
MyConfig cfg = config.get();
// save current state to disk (returns CompletableFuture)
config.save();

Calling config.load() again after the initial load completes will re-read the file from disk. During an in-flight load, duplicate load() calls return the same future to prevent concurrent reads.

  • Config is loaded automatically during preLoad(), so values are available in setup() and start()
  • If the config file doesn’t exist, default values from the BuilderCodec are used
  • withConfig() can only be called during field initialization — use codecs directly for runtime data