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.
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.
| Place | Runs | Can use |
|---|---|---|
region { } | RunsEvents, player commands, messages to a player or position, tasks on a player | Views of anything in the region, everything under "anywhere" |
global { } | Runs@every timers, messages to global, @load and @unload | Its own @save fields, the player directory, everything under "anywhere". No views: the global phase sees regions only through messages |
| Top level | RunsTypes, config, permissions, items, and commands the console can run | Only 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.") } }
Stresses: events that can cancel or change what happens, string handling.
plugins/samples/chatfilter/chatfilter.calx// Sample addon 2 of 5: chat filter. // Stresses events that can cancel or change what happens, and string handling. // Imagined Calx syntax from Phase 0. Nothing here compiles yet. ../README.md explains the conventions. @plugin(id: "chatfilter", name: "Chat filter", version: "0.1.0", api: 1) module chatfilter import calcite.{Player, PlayerRef, Sender, PlayerChat, Text, Permission, Instant, Duration, op} import std.text.{Matcher, MatchOptions} enum Action { Mask // Hide the matched words. The message still goes out and its signature stays valid Block // Don't send the message Strike // Don't send it, and count a strike. Enough strikes and the player is muted } @config struct Config { words: List<String> = [] action: Action = Action.Mask strikes_to_mute: i32 = 3 mute_for: Duration = Duration.minutes(10) // Computed from the fields above each time the config loads, then shared read-only by every region. // Regions hold no state of their own, so there's nothing to rebuild when they split or merge (D42). @derived matcher: Matcher = Matcher.build(words, MatchOptions { ignore_case: true, fold_accents: true, leet: true }) } @permission("chatfilter.moderate", default: op) const MODERATE: Permission // Staff who see what was filtered. @permission("chatfilter.notify", default: op) const NOTIFY: Permission // Saved, so logging out doesn't end a mute. @save(per: Player) struct Mute { until: Instant? = none } // Reset when the player logs out. @session(per: Player) struct Strikes { count: i32 = 0 } // Sent to a player's region to change their mute. A player who is offline gets it when they next join. @message struct SetMute { until: Instant? by: String } region { // Early, so plugins that run later never see a message this one cancels. @on(PlayerChat, priority: early) fn chat(e: PlayerChat) { let p = e.player if still_muted(p) { e.cancel() return } // Byte ranges into e.message, which is UTF-8 (D28). The host converts them to the UTF-16 // positions that vanilla's filter mask uses. let hits = config.matcher.find_all(e.message) if hits.is_empty() { return } server.broadcast(t"<gray>[filter] {p.name()}: {e.message}", permission: NOTIFY) match config.action { Action.Mask => { // Changing the text would break the chat signature, so filtered words are masked the way // vanilla's own filter does it: the client shows them as #### (D45). for r in hits { e.mask(r) } } Action.Block => { e.cancel() p.send(t"<red>That message wasn't sent.") } Action.Strike => { e.cancel() let strikes = p.data(Strikes) strikes.count += 1 if strikes.count < config.strikes_to_mute { p.send(t"<yellow>Watch your language ({strikes.count}/{config.strikes_to_mute}).") return } strikes.count = 0 p.data(Mute).until = server.now() + config.mute_for p.send(t"<red>You've been muted for {config.mute_for}.") } } } // Runs in the target's region with a view of the target, the next time that region runs (D50). @receive fn set_mute(p: Player, m: SetMute) { p.data(Mute).until = m.until match m.until { none => p.send(t"<green>You were unmuted by {m.by}."), some(until) => p.send(t"<red>You were muted by {m.by} for {until - server.now()}."), } } } // Moderator commands. They're outside region { } and global { } because the console can run them too: // a player's command runs in the player's region and the console's runs in the global instance, so the // body may use only what works in both, like server, send and the sender. // The literal path "chatfilter mute" becomes nested Brigadier literals. A PlayerRef argument completes // from every player the server has seen, online or not, the way vanilla's /ban does, because the target // may be offline or in another region. @command("chatfilter mute", permission: MODERATE) fn mute(sender: Sender, target: PlayerRef, length: Duration = Duration.minutes(10)) { send target SetMute { until: server.now() + length, by: sender.name() }, offline: keep sender.send(t"<green>Muted <white>{target.name()}</white> for {length}.") } @command("chatfilter unmute", permission: MODERATE) fn unmute(sender: Sender, target: PlayerRef) { send target SetMute { until: none, by: sender.name() }, offline: keep sender.send(t"<green>Unmuted <white>{target.name()}</white>.") } // Tells a muted player how long is left, and clears a mute that has run out. fn still_muted(p: Player) -> bool { let mute = p.data(Mute) let until = mute.until else return false // The wall-clock time captured at the start of this tick and recorded in the input log, so a replay // sees the same value (D44). Every handler in the tick sees the same instant. let now = server.now() if now >= until { mute.until = none return false } p.send(t"<red>You're muted for another {until - now}.") return true }
Stresses: entity and loot events, making an item.
plugins/samples/zombieheart/zombieheart.calx// Sample addon 3 of 5: zombie heart. // Stresses entity and loot events, and making an item. // Zombies killed by a player sometimes drop a zombie heart, and eating one heals. // Imagined Calx syntax from Phase 0. Nothing here compiles yet. ../README.md explains the conventions. @plugin(id: "zombieheart", name: "Zombie heart", version: "0.1.0", api: 1) module zombieheart import calcite.{EntityDeath, ItemConsumed, CustomItem, Component, EntityKind, Item, Enchantment, Particle, Rarity, Sound} @config struct Config { chance: f64 = 0.05 looting_bonus: f64 = 0.02 // added per level of Looting heal: f32 = 6.0 // health points, so three hearts } // A custom item that needs no client mod and no resource pack: a vanilla item with data components. // The host also puts {"calcite:item": "zombieheart:heart"} in the stack's custom_data, which is how // is(HEART) recognises it. That's vanilla's own component, so it survives a trip through vanilla (D36). @item("heart") const HEART = CustomItem { base: Item.red_dye, components: [ Component.item_name(t"<red>Zombie heart"), Component.lore([t"<gray>Still beating. Eat it to heal."]), Component.rarity(Rarity.uncommon), Component.enchantment_glint_override(true), Component.max_stack_size(16), // These two make the client play the eating animation and sound, with no plugin code. Component.consumable(seconds: 1.6, sound: Sound.entity_generic_eat), Component.food(nutrition: 2, saturation: 0.4, can_always_eat: true), ], } region { // Fires in the dying entity's region before its drops spawn. e.drops can be changed. @on(EntityDeath) fn died(e: EntityDeath) { if e.entity.kind != EntityKind.zombie { return } // Set when a player hurt the entity in the last 100 ticks, the rule vanilla uses for rare drops. // It's a PlayerRef, not a view, because an arrow can kill from another region. if e.player_kill == none { return } // So the event carries a snapshot of the weapon instead of making the handler look across regions. let looting = e.weapon.map(|w| w.enchantment_level(Enchantment.looting)).or(0) let chance = config.chance + config.looting_bonus * f64(looting) // This plugin's own random stream in this region. Plugins never draw from vanilla's streams, so // installing one doesn't change what vanilla rolls next (D44). if region.random().next_f64() >= chance { return } e.drops.push(HEART.stack(count: 1)) } // Fires after the client finishes eating and vanilla applies the food, in the player's region. @on(ItemConsumed) fn eaten(e: ItemConsumed) { if not e.item.is(HEART) { return } let p = e.player p.heal(config.heal) region.world.particles(Particle.heart, at: p.eye_position(), count: 5, spread: 0.4) } }
Stresses: messages between region instances, handing a player to another region.
plugins/samples/tpa/tpa.calx// Sample addon 4 of 5: teleport requests. // Stresses messages between region instances, and handing a player from one region to another. // /tpa <player> asks to teleport to someone. They can /tpaccept or /tpdeny, and the request expires. // The two players are usually in different regions, so every step is a message. // Imagined Calx syntax from Phase 0. Nothing here compiles yet. ../README.md explains the conventions. @plugin(id: "tpa", name: "Teleport requests", version: "0.1.0", api: 1) module tpa import calcite.{Player, PlayerRef, Task, Text, Permission, Ticks, TeleportError, everyone} @config struct Config { expire_after: Ticks = Ticks.seconds(60) } @permission("tpa.use", default: everyone) const USE: Permission // Messages hold plain values only: no views, no closures. A PlayerRef is a handle (D24), so it can go in one. @message struct Request { from: PlayerRef } @message struct Accepted { by: PlayerRef } @message struct Refused { by_name: String reason: Reason } enum Reason { Denied Expired } // Requests waiting for this player's answer, oldest first, each with the task that expires it. @session(per: Player) struct Incoming { requests: LinkedMap<PlayerRef, Task> = {} } region { // A PlayerRef argument completes from online players in every region, using the player directory // the host snapshots at the start of each tick. Any region can read the directory. @command("tpa", permission: USE) fn tpa(p: Player, target: PlayerRef) ! Text { if target == p.ref() { fail t"<red>You can't send a request to yourself." } if not target.online() { fail t"<red>{target.name()} isn't online." } // Delivered at the end of this tick and handled in target's region at the start of the next (D50). // If target logs out first, it's dropped. send target Request { from: p.ref() } p.send(t"<green>Request sent to <white>{target.name()}</white>.") } // Messages to a player run in the player's region, with a view of them. @receive fn request(p: Player, m: Request) { let incoming = p.data(Incoming) if let old = incoming.requests.remove(m.from) { old.cancel() } let from = m.from let expiry = p.after(ticks: config.expire_after) |p| { if p.data(Incoming).requests.remove(from) != none { send from Refused { by_name: p.name(), reason: Reason.Expired } } } incoming.requests.put(from, expiry) let accept = t"<green>[Accept]".on_click(run: "/tpaccept " + from.name()) let deny = t"<red>[Deny]".on_click(run: "/tpdeny " + from.name()) p.send(t"<white>{from.name()}</white> <gray>wants to teleport to you. " + accept + t" " + deny) } // With no name, answers the newest request. @command("tpaccept", permission: USE) fn tpaccept(p: Player, from: PlayerRef? = none) ! Text { let who = take_request(p, from) else fail t"<red>No request to accept." send who Accepted { by: p.ref() } p.send(t"<green>Accepted. <white>{who.name()}</white> is on the way.") } @command("tpdeny", permission: USE) fn tpdeny(p: Player, from: PlayerRef? = none) ! Text { let who = take_request(p, from) else fail t"<red>No request to deny." send who Refused { by_name: p.name(), reason: Reason.Denied } p.send(t"<gray>Denied.") } @receive fn accepted(p: Player, m: Accepted) { // The host works out where m.by is at the tick barrier, so p lands on their position at that // moment, not where they were when they accepted. If m.by is in another region or dimension, // p is handed over there, chunks loading first. If it can't happen, on_fail runs in p's region. p.teleport_to(m.by, on_fail: |p, why| { match why { TeleportError.TargetOffline => p.send(t"<red>{m.by.name()} logged out."), _ => p.send(t"<red>The teleport didn't work."), } }) p.send(t"<green>Teleporting to <white>{m.by.name()}</white>.") } @receive fn refused(p: Player, m: Refused) { match m.reason { Reason.Denied => p.send(t"<red>{m.by_name} denied your request."), Reason.Expired => p.send(t"<gray>Your request to {m.by_name} expired."), } } } // Removes a waiting request and cancels its expiry. With no name, takes the newest. fn take_request(p: Player, from: PlayerRef?) -> PlayerRef? { let incoming = p.data(Incoming) let who = from ?? incoming.requests.last_key() else return none let expiry = incoming.requests.remove(who) else return none expiry.cancel() return who }
Stresses: scheduler, the global instance, broadcasting to every region.
plugins/samples/supplydrop/supplydrop.calx// Sample addon 5 of 5: supply drops. // Stresses the scheduler, the global instance and broadcasting to every region. // Every so often a chest of loot lands near a random player, and everyone is told where. // It goes away after a while if nobody opens it. // Imagined Calx syntax from Phase 0. Nothing here compiles yet. ../README.md explains the conventions. @plugin(id: "supplydrop", name: "Supply drops", version: "0.1.0", api: 1) module supplydrop import calcite.{Chunk, BlockPos, WorldKey, Tick, Ticks, LootTableKey, Block, Chest, Heightmap, Sound, ContainerOpen} @config struct Config { every: Ticks = Ticks.minutes(30) min_players: i32 = 2 radius: i32 = 300 // how far from the chosen player it can land lifetime: Ticks = Ticks.minutes(10) // The plugin ships this loot table in its own data pack, plugins/supplydrop/data/. loot: LootTableKey = LootTableKey("supplydrop:crate") } // What the global instance knows about a crate. struct Crate { id: i64 world: WorldKey at: BlockPos expires: Tick } @message struct Place { id: i64 world: WorldKey near: BlockPos } @message struct Placed { id: i64 world: WorldKey at: BlockPos } @message struct Remove { id: i64 at: BlockPos } @message struct Looted { id: i64 by_name: String } // Saved with the chunk, so the marker loads and unloads with the chest it marks. @save(per: Chunk) struct Markers { crates: Map<BlockPos, i64> = {} } // There is one global instance per plugin. It's the only place a plugin keeps world-wide state, and // its fields with @save are written beside the world when the server saves (D36, D42). global { @save var active: List<Crate> = [] @save var next_id: i64 = 1 // Runs in the global phase. The interval is read again after each run, so a config reload takes // effect from the next drop. @every(ticks: config.every) fn drop() { // The player directory as it was at the start of this tick, in a fixed order. let players = server.players() if players.len() < config.min_players { return } let rng = global.random() let who = players[rng.next_int(players.len())] let r = config.radius let near = who.block_position() + BlockPos(rng.range(-r, r), 0, rng.range(-r, r)) let id = next_id next_id += 1 // region_at routes by chunk when the message is delivered, so it reaches whichever region owns // that chunk then. load: true asks the host to load the chunk and deliver once it's in. A // finished chunk load is in the input log (D21), so the delivery tick replays the same. send region_at(who.world, near, load: true) Place { id, world: who.world, near } } @receive fn placed(m: Placed) { active.push(Crate { id: m.id, world: m.world, at: m.at, expires: server.tick() + config.lifetime }) // Fans out to every region in the message exchange, so every player sees it on the same tick. server.broadcast(t"<gold>A supply drop landed near <white>{m.at.x}, {m.at.z}</white>!") server.broadcast_sound(Sound.ui_toast_challenge_complete) } @receive fn looted(m: Looted) { if active.remove_where(|c| c.id == m.id) == 0 { return } server.broadcast(t"<gold>{m.by_name}</gold> <yellow>found the supply drop.") } @every(ticks: 200) fn sweep() { let now = server.tick() for c in active.drain_where(|c| c.expires <= now) { // No point loading a chunk just to take a chest out of it. With load: false the host keeps // the message with the chunk's saved data and delivers it the next time the chunk loads. send region_at(c.world, c.at, load: false) Remove { id: c.id, at: c.at } } } } region { @receive fn place(m: Place) { let world = region.world // The same heightmap vanilla uses to decide where falling things land. let y = world.top_y(m.near.x, m.near.z, Heightmap.motion_blocking_no_leaves) let at = BlockPos(m.near.x, y, m.near.z) world.set_block(at, Block.chest) // A block entity is a handle. Asking for one as a type gives none if it isn't that type. let chest = world.block_entity(at, Chest) else return // Vanilla rolls the loot the first time someone opens the chest, as it does for dungeon chests. chest.set_loot_table(config.loot, seed: region.random().next_i64()) world.chunk_at(at).data(Markers).crates.put(at, m.id) world.play_sound(Sound.block_beacon_activate, at: at, volume: 4.0) send global Placed { id: m.id, world: m.world, at } } @receive fn remove(m: Remove) { let world = region.world let markers = world.chunk_at(m.at).data(Markers) // Gone already if it was opened, or replaced by another crate in the same spot. if markers.crates.get(m.at) != m.id { return } markers.crates.remove(m.at) if world.block(m.at).is(Block.chest) { world.set_block(m.at, Block.air, drops: false) } } @on(ContainerOpen) fn opened(e: ContainerOpen) { // Minecarts and other entity containers have no block position. let at = e.block_position else return let id = region.world.chunk_at(at).data(Markers).crates.remove(at) else return // Once opened, it's an ordinary chest and stays. send global Looted { id, by_name: e.player.name() } } }
What the samples settled
Writing them settled how the API has to work. The points that became decisions are in the decision log.
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.
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.PlayerRefand 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.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.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).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.Messages have explicit delivery rules (D46, D50). A message is handled the next time its destination runs: a region's message to
globalsent 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 withoffline: keep. To a position it goes to whichever region owns that chunk at delivery, and if the chunk isn't loaded the sender choosesload: true(load it, then deliver) orload: false(deliver when it next loads).Events carry snapshots of facts from other regions. A zombie can die to an arrow shot from another region, so
EntityDeathcarries the killer as aPlayerRefand a copy of the weapon, instead of the handler reaching across regions.Teleporting is a request resolved at the tick barrier.
p.teleport(location)andp.teleport_to(player_ref)hand the player over to another region or dimension if needed, loading chunks first.teleport_touses the target's position at the barrier. Failures come back to a callback in the player's region.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.
Coarse events keep handlers cheap.
PlayerMoveBlockfires only when a player enters a new block. Movement handlers are the most common source of plugin lag on other servers.Attributes need a codegen mechanism in Calx. Nearly every host API feature here is an attribute (
@command,@save,@messageand 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.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.