Skip to the page
calcite

Plugins

Calcite has one plugin API and two ways to run a plugin. Native plugins are written in Calx and compiled, and run at full speed. WASM plugins can be written in any language that builds to WASM, and run in a sandbox.

Not built yetThe plugin host is Phase 7 of the plan. Until then the API on this page is a sketch, and the samples are in Calx's draft syntax. Phase 7 is done when all five samples run on it.

Native Calx plugins

  • Each one compiles to a shared library against the host API, and they load in parallel as the server starts.
  • The compiler checks where code may run, so a handler can’t touch another region’s part of the world.
  • The compiler puts cheap budget checks in loops, so a plugin stuck in one is stopped without a sandbox. A panic stops only the plugin that panicked.
  • A new build can be swapped in between ticks, with a hook to carry the old one’s state over.

WASM plugins

  • The same API, as imports, in Wasmtime. Each plugin is granted its capabilities one by one.
  • The runtime keeps the budgets, so a plugin from someone you don’t know can’t hold up the tick.
  • Any language that builds to WASM can be used. SDKs are generated from the API, starting with Rust.
  • A Calx plugin that stays within the API can be built either way from the same source.

What the API covers

The API was sketched against one rule: anything plugins commonly need should take a few lines, and work the same in both kinds of plugin.

Typed events

With priorities and cancellation, run in the region where the event happened

Commands

Declared as Brigadier-style trees and sent to the client, so plugin commands get the same tab completion and argument checking as vanilla's

Persistent data

Per player, entity, chunk and world, stored in Calcite's own files beside the world. Data that has to travel with an item or entity uses vanilla's custom_data component

Text

Built from components, with a short formatting syntax for colors and styles

Scheduling

By tick, in the region or the global instance

Messaging

Between a plugin's instances, including handing a player from one region to another

Inter-plugin APIs

A plugin can export typed functions and messages for other plugins

Permissions

Permission nodes with defaults by op level

Custom content without client mods

Data components, item models, dialogs and data-pack registries

Profiling

Tick time per plugin and per handler, always on

Where code runs

Calcite splits the world into regions that tick on every core. A plugin has one instance in each region and one global instance, and they talk by messages, so plugins scale with the server and never tie it to one thread.

PlaceRunsCan use
region { }RunsEvents, player commands, messages to a player or position, tasks on a playerViews of anything in the region, everything under "anywhere"
global { }Runs@every timers, messages to global, @load and @unloadIts own @save fields, the player directory, everything under "anywhere". No views: the global phase sees regions only through messages
Top levelRunsTypes, config, permissions, items, and commands the console can runOnly what works in both: config, server, send, the sender

A player's command runs in the player's region during the packet phase. A console command runs at the barrier (D50). That's why a console-capable command only gets what works in both places.

The five samples

These were written before Calx or the API existed, to find out what both would need. Phase 7 ports them to the real syntax and tests the plugin host with them, and Phase 9 turns them into tutorials.

Stresses: commands, persistent per-player data, teleporting.

plugins/samples/homes/homes.calx
// Sample addon 1 of 5: homes.
// Stresses commands, persistent per-player data and teleporting.
// Imagined Calx syntax from Phase 0. Nothing here compiles yet. ../README.md explains the conventions.

@plugin(id: "homes", name: "Homes", version: "0.1.0", api: 1)
module homes

import calcite.{Player, Location, Task, Text, Argument, Brigadier, Suggest, Permission, PlayerMoveBlock, MoveCause, everyone, op}

// Loaded from plugins/homes/config.toml. On reload, the new values are swapped in at the tick barrier,
// so every region sees the same config during a tick.
@config
struct Config {
    max_homes: i32 = 3
    warmup_ticks: i32 = 60        // 0 teleports at once
    cancel_on_move: bool = true
}

@permission("homes.use", default: everyone)
const USE: Permission

// Lifts the max_homes limit.
@permission("homes.unlimited", default: op)
const UNLIMITED: Permission

// Saved per player, keyed by UUID, in Calcite's own files beside the world (D36).
// It travels with the player between regions, so whichever region they're in can read it.
// New fields take their default when older data loads.
@save(per: Player)
struct Homes {
    homes: SortedMap<HomeName, Location> = {}
}

// Per player, never saved, dropped when they log out. It travels with the player too.
@session(per: Player)
struct Pending {
    warmup: Task? = none
}

// A home's name. Making it an argument type gives the command tree its client-side parser,
// its validation and its tab completion, and every command that takes one gets all three.
struct HomeName(String)

impl Argument for HomeName {
    const parser = Brigadier.word

    fn parse(raw: String) -> HomeName ! Text {
        if raw.len() > 16 or not raw.all(|c| c.is_ascii_alphanumeric() or c == '_' or c == '-') {
            fail t"<red>Home names are up to 16 letters, digits, _ or -."
        }
        return HomeName(raw.to_lower())
    }

    // Runs in the sender's region, where their saved data is.
    fn suggest(s: Suggest, sender: Player) {
        for name in sender.data(Homes).homes.keys() {
            s.add(name.0)
        }
    }
}

region {
    // The signature is the command tree: /sethome [name], where name is a HomeName defaulting to "home".
    // Failing with a Text sends it to the sender in place of a success message.
    @command("sethome", permission: USE)
    fn sethome(p: Player, name: HomeName = HomeName("home")) ! Text {
        // A view of the player's data: writes go straight to it, and it can't outlive this handler.
        let data = p.data(Homes)
        let full = data.homes.len() >= config.max_homes
        if full and not data.homes.has(name) and not p.has(UNLIMITED) {
            fail t"<red>You already have {config.max_homes} homes. Delete one with /delhome."
        }
        data.homes.put(name, p.location())
        p.send(t"<green>Home <white>{name.0}</white> set.")
    }

    @command("home", permission: USE)
    fn home(p: Player, name: HomeName = HomeName("home")) ! Text {
        let to = p.data(Homes).homes.get(name) else fail t"<red>No home called <white>{name.0}</white>."
        let pending = p.data(Pending)
        if let old = pending.warmup {
            old.cancel()
        }
        if config.warmup_ticks == 0 {
            p.teleport(to)
            return
        }
        p.send(t"<gray>Teleporting in {config.warmup_ticks / 20} seconds. Don't move.")
        // A task on a player runs in whichever region the player is in when it fires, and is dropped if
        // they log out first. It may capture only values that can cross regions (to is a plain
        // Location), and it gets the player back as a fresh view.
        pending.warmup = p.after(ticks: config.warmup_ticks) |p| {
            p.data(Pending).warmup = none
            // Teleporting is a request. If the home is in another region or dimension, the player is
            // handed over at the tick barrier, and the host loads the destination chunk first.
            p.teleport(to)
            p.send(t"<green>Welcome home.")
        }
    }

    @command("delhome", permission: USE)
    fn delhome(p: Player, name: HomeName) ! Text {
        if p.data(Homes).homes.remove(name) == none {
            fail t"<red>No home called <white>{name.0}</white>."
        }
        p.send(t"<green>Deleted home <white>{name.0}</white>.")
    }

    @command("homes", permission: USE)
    fn homes(p: Player) {
        let homes = p.data(Homes).homes
        if homes.is_empty() {
            p.send(t"<gray>No homes yet. Set one with /sethome.")
            return
        }
        // Clicking a name runs /home <name>.
        let names = homes.keys().map(|n| t"<white>{n.0}".on_click(run: "/home " + n.0).on_hover(t"Teleport"))
        p.send(t"<green>Homes ({homes.len()}): " + Text.join(t"<gray>, ", names))
    }

    // Fires only when a player enters a different block, not for every small step or turn of the head,
    // so a handler like this costs almost nothing on a busy server.
    @on(PlayerMoveBlock)
    fn moved(e: PlayerMoveBlock) {
        if not config.cancel_on_move or e.cause != MoveCause.Walk {
            return
        }
        let pending = e.player.data(Pending)
        let task = pending.warmup else return
        task.cancel()
        pending.warmup = none
        e.player.send(t"<red>Teleport cancelled because you moved.")
    }
}

What the samples settled

Writing them settled how the API has to work. The points that became decisions are in the decision log.

  1. Regions keep no plugin state (D42). Regions split and merge (D22), so state kept per region would need plugin code to split and merge it. None of the five needed it. State lives on players, chunks and entities (and moves with them), in the global instance, or in the config, whose derived values are shared read-only.

  2. Views and handles (D43). Player, Region, chunk data and other live objects are views: references that can't be stored, captured by a closure or sent. They're valid only in the handler that got them, which is what makes region scoping a compile-time check. PlayerRef and other handles (D24) can be stored and sent, and resolving one gives a view only in the region where the object is. Plugin code never writes a lifetime.

  3. Closures that run later capture only values that can be sent. A task or callback can fire after the player has moved to another region, so it gets the player back as a fresh view (p.after(...) |p| { ... }) instead of capturing the old one. The compiler checks the captures.

  4. Randomness and time come from the host (D44). Each plugin has its own random stream per region and one in the global instance, never vanilla's, so installing a plugin doesn't change what vanilla rolls next. server.now() is the wall-clock time captured once at the start of the tick and recorded in the input log, so replays match (D21).

  5. Chat is filtered by masking (D45). Changing a signed chat message breaks its signature. The filter masks ranges with vanilla's own filter mask, which the client shows as ####. The host converts UTF-8 byte ranges to the UTF-16 positions the protocol uses.

  6. Messages have explicit delivery rules (D46, D50). A message is handled the next time its destination runs: a region's message to global sent while handling packets is handled later in the same tick, a global instance's message to a region is handled in that region's world phase of the same tick, and a message between regions is handled in the next tick. To a player it goes wherever they are, and if they're offline it's dropped, or kept until they join with offline: keep. To a position it goes to whichever region owns that chunk at delivery, and if the chunk isn't loaded the sender chooses load: true (load it, then deliver) or load: false (deliver when it next loads).

  7. Events carry snapshots of facts from other regions. A zombie can die to an arrow shot from another region, so EntityDeath carries the killer as a PlayerRef and a copy of the weapon, instead of the handler reaching across regions.

  8. Teleporting is a request resolved at the tick barrier. p.teleport(location) and p.teleport_to(player_ref) hand the player over to another region or dimension if needed, loading chunks first. teleport_to uses the target's position at the barrier. Failures come back to a callback in the player's region.

  9. Every region can read a player directory. Names, handles, worlds and positions, snapshotted at the start of each tick, in a fixed order. Commands use it to resolve player arguments, and the global instance uses it to pick players.

  10. Coarse events keep handlers cheap. PlayerMoveBlock fires only when a player enters a new block. Movement handlers are the most common source of plugin lag on other servers.

  11. Attributes need a codegen mechanism in Calx. Nearly every host API feature here is an attribute (@command, @save, @message and the rest). Calx needs a way for a library to define attributes that generate code at compile time, the same mechanism packet codegen (P4.1) needs, so the host API isn't hard-coded into the compiler.

  12. Vanilla parity issues for the global systems inventory (P0.4). Entity selectors (@a[distance=..10], @p) and player-name arguments have to see players in every region, and broadcasts have to reach every region on the same tick.