API

Config and networking

Settings that the player or the server owner can change without touching the code, and your own data between client and server. Both work from CraftScript and from Kotlin.

Config in CraftScript

A file of its own, settings.craft, declares the settings with their default values; the rest of the mod imports them:

settings.craftCraftScript
// Values the server owner can change in config/rubies/settings.toml
const SETTINGS = config("settings", {
    bonus_chance: 0.25,
    welcome_rubies: 3
})
events/mining.craftCraftScript
import { give_rubies } from "/lib/rewards"
import { SETTINGS } from "/settings"

on break(e) {
    let p = e.player
    if p == null { return }
    if e.block.type == "minecraft:diamond_ore" and chance(SETTINGS["bonus_chance"]) {
        give_rubies(p, 1)
        p.play_sound("rubies:ruby_chime")
        p.world.spawn_particle("rubies:ruby_spark", e.block.location, 12, 0.4, 0.02)
    }
}
  • config("settings", { … }) creates config/rubies/settings.toml the first time and returns the current values. Each setting's type comes from its default value: an integer stays an integer.
  • config("settings"), with no values, reads them again after they are declared.
  • A third argument true makes the server send its values to clients when they connect.
  • The name uses lowercase letters, digits, _ or -. A mod can have several settings files.

Config in Kotlin

One class per mod with the adjustable values, their defaults and a description:

RubiesConfig.ktKotlin
package com.example.rubies

class RubiesConfig : Config("rubies", synced = true) {
    var dropChance by double("drop_chance", 0.25, "Chance that diamond ore also drops a ruby")
    var armorReduction by double("armor_reduction", 0.2, "Damage reduction while holding a ruby")
}
RubiesMod.ktKotlin
override fun onLoad(ctx: ModContext) {
    val config = Crafting.configs.load(RubiesConfig())
    MiningEvents.register(config)     // each part gets the config it needs
}

Crafting creates config/rubies.toml the first time:

config/rubies.tomlTOML
# rubies — generated by Crafting. Edit freely; comments are regenerated.

# Chance that diamond ore also drops a ruby
# default: 0.25
drop_chance = 0.25

# Damage reduction while holding a ruby
# default: 0.2
armor_reduction = 0.2
  • Types: int, long, double, bool, string, stringList.
  • Comments are regenerated; values the user wrote are kept.
  • An invalid value breaks nothing: Crafting warns in the log and uses the default value.
  • Crafting.configs.reload(config) reads the file again; save(config) writes it.
  • With synced = true, the server sends its values to clients that have Crafting when they connect, so everyone plays by the same rules.

For your own files, ctx.configDir (config/<mod>/) and ctx.dataDir (data/<mod>/) are created when you ask for them.

Who can join a server

On connect, client and server compare their mods. The rules:

Situation Result
The server only has plugins or server-side mods; the client has no mods Joins
The server has mods that add content; the client has no mods Can't join, and sees the exact list of mods it needs
The client is missing a mod that adds content Can't join: “Missing mods: rubies 1.2.0”
Different major version (1.x and 2.x) Can't join, with both versions shown
Different minor version (1.2 and 1.3) Joins, with a warning in the server log
Client-only mods Always allowed

That's why you should version your mod carefully: bump the major version only when a client and a server on different versions can no longer play together.

Channels in CraftScript

A channel carries data between client and server: numbers, strings, booleans, lists and maps. The name "ping" becomes rubies:ping, so it doesn't clash with other mods.

net/ping.craftCraftScript
// Every side listens to what it receives: a packet only reaches a side that listens.

// Server: say hello to each player that joins.
on join {
    send(player, "ping", { n: 1 })
}

// Client: answer.
listen("ping", fun(data, from) {
    send_to_server("pong", { n: data.n + 1 })
})

// Server: read the answer (from is the player who sent it).
listen("pong", fun(data, from) {
    log("{from?.name} answered {data.n}")
})
  • listen(channel, fun(data, player) { … }) receives; player is the sender on the server, and null on the client.
  • send(player, channel, data) sends from the server to a player; send_to_server(channel, data) sends from the client to the server.
  • A packet only reaches a side that listens on that channel: each side announces its channels on connect. That's why the mod calls listen on both sides for whatever each one receives.
  • When the mod is reloaded or disabled, its listen calls go away with it.

Channels in Kotlin

A channel sends values of one type between client and server. It works the same in a single-player world as on a server.

net/SparkleChannel.ktKotlin
package com.example.rubies.net

object SparkleChannel {
    private val channel = Crafting.network.channel(Identifier.of("rubies", "sparkle"), PacketCodec.STRING)

    fun register() {
        channel.onReceive { message, ctx ->
            // ctx.player: who sent it, on the server; null on the client
        }
    }

    fun announce(text: String) = channel.broadcast(text)         // server → every client
    fun tell(player: Player, text: String) = channel.sendTo(player, text)
}
  • Data is encoded in compact binary with PacketWriter and PacketReader.
  • Crafting never sends its packets to a client without Crafting: that would disconnect it. sendTo sends nothing to a player that didn't announce that channel; Crafting.network.isChannelAvailable(player, id) tells you in advance.
  • A malformed packet is logged and discarded; it never takes down the server.

Custom types

net/BountyChannel.ktKotlin
package com.example.rubies.net

data class Bounty(val target: String, val reward: Int)

object BountyChannel {
    private val CODEC = PacketCodec.codec<Bounty>(
        { b, out -> out.string(b.target).varInt(b.reward) },
        { input -> Bounty(input.string(), input.varInt()) },
    )
    val channel = Crafting.network.channel(Identifier.of("rubies", "bounty"), CODEC)
}