Subscribe to before and after events, cancel actions, and follow the rule about reads inside before events.
Events are how a plugin finds out what is happening. You subscribe a function to an event, and Basalt calls it each time the event fires.
Events live in three places:
| Object | What its events cover |
|---|---|
world |
Players, entities, blocks, items and weather. Mostly Mojang’s events, plus Basalt’s own. |
system |
The plugin’s own life cycle, script events and packets. |
server |
Basalt-only events about joining, commands and deaths. server is exported by @minecraft/server on Basalt and does not exist on BDS. |
Subscribing
Section titled “Subscribing”Every event has subscribe and unsubscribe. subscribe returns the function you passed, so you can keep it to unsubscribe later.
import { world } from "@minecraft/server";
const onChat = world.afterEvents.chatSend.subscribe((event) => { console.log(`<${event.sender.name}> ${event.message}`);});
// Later, to stop listening:world.afterEvents.chatSend.unsubscribe(onChat);An error thrown in a handler is logged and does not affect other handlers or other plugins. One error is not logged: an InvalidEntityError from a player or entity that left while the handler ran. The handler just stops there.
Before and after
Section titled “Before and after”Most things that happen have two events.
After events (world.afterEvents) fire once the thing has happened. The game has already moved on, so you cannot change the outcome, but you are free to read and change anything.
Before events (world.beforeEvents, server.beforeEvents) fire while the game waits for your answer. You can cancel the action with event.cancel = true, and some events let you change a value, such as the chat message or the damage.
import { world } from "@minecraft/server";
const banned = ["badword", "worseword"];
world.beforeEvents.chatSend.subscribe((event) => { if (banned.some((word) => event.message.toLowerCase().includes(word))) { event.cancel = true; event.sender.sendMessage("§cWatch your language."); return; } // Change what everyone sees. event.message = event.message.replaceAll(":)", "☺");});Because the game is waiting, a before handler must be quick:
- Basalt waits at most 50 ms for a handler. One that has not answered by then is no longer waited for, so its
cancelor changes may come too late to count. - A handler that takes more than 10 ms earns the plugin a warning in the log and in
/status.
Do the minimum in the handler and move anything slow into system.run.
Reads inside before events
Section titled “Reads inside before events”This is the one rule that catches people out.
Normally, when a plugin reads something (player.location, dimension.getBlock(...), an inventory slot), it asks the game and waits for the answer. Inside a before event the game is already waiting for the plugin, so it cannot answer. Basalt handles the two cases differently:
| Where | What happens when you read |
|---|---|
| Block break, block place and mob hurt before events | Reading the world throws: blocks, entities, anything through dimension. |
| Player before events and custom command callbacks | A player is read as the plugin last saw them. If the plugin has never read that player before, the read throws. |
What always works inside any before event:
- The event’s own fields.
event.message,event.damage,event.block.typeId,event.block.location,event.player.nameand so on are delivered with the event. - Changes.
sendMessage,teleport,applyKnockback,playSoundand other changes are sent without waiting. - Your own data. Variables,
Maps andSets in your plugin.
When you need a real read, defer it with system.run. The callback runs on the next tick, outside the event, where every read works:
import { system, world, GameMode } from "@minecraft/server";
world.beforeEvents.playerBreakBlock.subscribe((event) => { const { player, block } = event;
// Fine: these fields come with the event. if (block.typeId !== "minecraft:diamond_ore") return; const { x, y, z } = block.location;
// Not here: player.getGameMode() or block.above() could throw. system.run(() => { if (!player.isValid) return; if (player.getGameMode() === GameMode.Survival) { world.sendMessage(`${player.name} found diamonds at ${x}, ${y}, ${z}`); } });});If the decision to cancel depends on something you would have to read, keep that state in your plugin and update it from after events:
import { world } from "@minecraft/server";
const muted = new Set<string>();
world.afterEvents.playerSpawn.subscribe(({ player, initialSpawn }) => { // After events can read freely. if (initialSpawn && player.hasTag("muted")) muted.add(player.name);});
world.afterEvents.playerLeave.subscribe(({ playerName }) => muted.delete(playerName));
world.beforeEvents.chatSend.subscribe((event) => { // No read needed: the answer is already in the plugin. if (muted.has(event.sender.name)) event.cancel = true;});Common recipes
Section titled “Common recipes”Protect an area
Section titled “Protect an area”import { world } from "@minecraft/server";
const spawnRadius = 32;
function nearSpawn(location: { x: number; z: number }): boolean { return Math.abs(location.x) <= spawnRadius && Math.abs(location.z) <= spawnRadius;}
world.beforeEvents.playerBreakBlock.subscribe((event) => { if (nearSpawn(event.block.location)) event.cancel = true;});
world.beforeEvents.playerPlaceBlock.subscribe((event) => { if (nearSpawn(event.block.location)) event.cancel = true;});Change damage
Section titled “Change damage”import { world, Player } from "@minecraft/server";
world.beforeEvents.entityHurt.subscribe((event) => { // Players take half damage from everything. if (event.hurtEntity instanceof Player) event.damage = event.damage / 2;});For players, entityHurt also has knockback, which you can change, and cancelKnockback.
Turn a player away at login
Section titled “Turn a player away at login”server.beforeEvents.beforeJoin fires before the player is in the world, so there is no Player yet: the event carries the name, XUID and address instead.
import { server } from "@minecraft/server";
const maintenance = true;const staff = new Set(["Steve", "Alex"]);
server.beforeEvents.beforeJoin.subscribe((event) => { if (maintenance && !staff.has(event.name)) { event.kickMessage = "Down for maintenance. Back soon!"; event.cancel = true; }});Keep items on death
Section titled “Keep items on death”import { server } from "@minecraft/server";
server.beforeEvents.playerDie.subscribe((event) => { event.keepInventory = true; event.deathMessage = `${event.player.name} had a bad day`;});Run setup once the world is ready
Section titled “Run setup once the world is ready”world.afterEvents.worldLoad fires on the first tick after the plugin has started, once the top-level code of every module has run. It is the place for one-time setup such as creating scoreboard objectives.
import { world } from "@minecraft/server";
world.afterEvents.worldLoad.subscribe(() => { console.log(`World ready, ${world.getAllPlayers().length} players online`);});world.beforeEvents.worldLoad (Basalt’s own) fires on the same tick, just before it. The old name worldInitialize works for both, and logs a deprecation warning.
Event reference
Section titled “Event reference”Events marked Basalt do not exist in Mojang’s API. Fields marked changeable can be assigned in the before event.
Chat, joining and leaving
Section titled “Chat, joining and leaving”| Event | Before | After | Fields |
|---|---|---|---|
world chatSend |
Yes | Yes | sender, message (changeable), cancel |
world playerJoin |
Yes | playerId, playerName |
|
world playerSpawn |
Yes | player, initialSpawn, location |
|
world playerLeave |
Yes | Yes | Before: player. After: playerId, playerName |
server beforeJoin (Basalt) |
Yes | name, xuid, address, kickMessage (changeable), cancel |
|
server playerCommand (Basalt) |
Yes | player, command (changeable), cancel |
Damage and death
Section titled “Damage and death”| Event | Before | After | Fields |
|---|---|---|---|
world entityHurt |
Yes | Yes | hurtEntity, damage (changeable), damageSource, cancel; for players also knockback (changeable) and cancelKnockback |
world playerHurt (Basalt) |
Yes | Yes | The same as entityHurt, for players only |
world entityDie |
Yes | deadEntity, damageSource |
|
server playerDie (Basalt) |
Yes | player, damageSource, keepInventory, drops, deathMessage (all three changeable) |
|
server entityDie (Basalt) |
Yes | deadEntity, damageSource, drops (changeable) |
|
world effectAdd |
Yes | Yes | Before: entity, effectType, duration (changeable), cancel. After: entity, effect |
world entityHitEntity |
Yes | damagingEntity, hitEntity: a melee hit landing, on a mob or a player |
|
world entityHitBlock |
Yes | damagingEntity, hitBlock, blockFace: a player’s attack landing on a block. hitBlockPermutation is not sent. |
|
world entityHealthChanged |
Yes | entity, oldValue, newValue. Fires for players, and for mobs when they take damage. |
|
world projectileHitBlock, projectileHitEntity |
Yes | projectile, source, location, hitVector, and getBlockHit() or getEntityHit() |
Blocks and the world
Section titled “Blocks and the world”| Event | Before | After | Fields |
|---|---|---|---|
world playerBreakBlock |
Yes | Yes | player, block, cancel. After: brokenBlockPermutation |
world playerPlaceBlock |
Yes | Yes | player, block, permutationBeingPlaced, cancel |
world playerInteractWithBlock |
Yes | Yes | player, block, blockFace, itemStack, isFirstEvent, cancel |
world explosion |
Yes | Yes | dimension, source, getImpactedBlocks(). Before: setImpactedBlocks(), cancel |
world blockExplode |
Yes | block, dimension, explodedBlockPermutation, source |
|
world weatherChange |
Yes | Yes | Before: previousWeather, newWeather and duration (both changeable), cancel |
world buttonPush, leverAction, pressurePlatePush, pressurePlatePop, targetBlockHit, tripWireTrip, pistonActivate |
Yes | block, dimension and event-specific fields. The fields naming who pressed, stepped or shot are empty. |
|
world worldLoad |
Yes (Basalt) | Yes | Before: itemComponentRegistry. After: none |
| Event | Before | After | Fields |
|---|---|---|---|
world itemUse |
Yes | Yes | source, itemStack, cancel |
world itemStartUse, itemCompleteUse, itemReleaseUse, itemStopUse |
Yes | source, itemStack, useDuration: eating, drinking, and drawing a bow or trident |
|
world playerPickupItem (Basalt) |
Yes | Yes | player, entity, itemStack, cancel |
world playerDropItem (Basalt) |
Yes | Yes | player, itemStack, cancel |
world itemSpawn (Basalt) |
Yes | entity, itemStack, location |
|
world itemComponentUse (Basalt) |
Yes | componentId, source, itemStack: a player used an item with a custom component. itemComponentRegistry uses it to run onUse. |
|
world playerHotbarSelectedSlotChange |
Yes | player, newSlotSelected, previousSlotSelected, itemStack |
Players
Section titled “Players”| Event | Before | After | Fields |
|---|---|---|---|
world playerInteractWithEntity |
Yes | Yes | player, target, itemStack, cancel |
world playerGameModeChange |
Yes | Yes | player, fromGameMode, toGameMode, cancel |
world playerMove (Basalt) |
Yes | Yes | player, fromLocation, toLocation (changeable), cancel puts them back |
world playerTeleport (Basalt) |
Yes | Yes | player, fromLocation, toLocation (changeable), cause, cancel |
world playerRespawn (Basalt) |
Yes | Yes | player, location (changeable before) |
world playerToggleSneak, playerToggleSprint (Basalt) |
Yes | player, and isSneaking or isSprinting |
|
world playerEmote |
Yes (Basalt) | Yes | player, personaPieceId; cancelling hides the emote from others |
world playerChangeSkin (Basalt) |
Yes | Yes | player, skin, isTrusted; cancelling keeps the old skin |
world playerViolation (Basalt) |
Yes | Yes | player, check, detail; cancelling stops the anti-cheat kick |
world playerFormResponded (Basalt) |
Yes | player, formId, response, cancelationReason |
Entities
Section titled “Entities”| Event | Before | After | Fields |
|---|---|---|---|
world entitySpawn |
Yes | entity, cause |
|
world entityRemove |
Yes | Yes | Before: removedEntity. After: removedEntityId, typeId |
System
Section titled “System”| Event | Before | After | Fields |
|---|---|---|---|
system startup |
Yes | customCommandRegistry, itemComponentRegistry. See Custom commands. |
|
system shutdown |
Yes | Fires before the plugin is unloaded or Basalt stops | |
system scriptEventReceive |
Yes | See Script events | |
system packetReceive, packetSend (Basalt) |
Yes | packetId, packetSize, and sender or recipients |
playerMove fires very often. Subscribe to it only when you need it, and keep the handler tiny.
Events the typings do not list yet
Section titled “Events the typings do not list yet”A few events and fields work in Basalt but are not declared in the typings yet, so TypeScript reports an error where you use them:
entityHitBlock,entityHealthChangedanditemComponentUseonworld.afterEvents,worldLoadonworld.beforeEvents,itemComponentRegistryon thestartupevent.
The event classes themselves, such as EntityHealthChangedAfterEvent, are declared. Until the typings catch up, reach the signal through a cast:
import { world, EntityHealthChangedAfterEvent, EventSignal } from "@minecraft/server";
const afterEvents = world.afterEvents as typeof world.afterEvents & { entityHealthChanged: EventSignal<EntityHealthChangedAfterEvent>;};
afterEvents.entityHealthChanged.subscribe(({ entity, oldValue, newValue }) => { if (newValue < oldValue && newValue <= 4) console.log(`${entity.typeId} is low on health`);});Plain JavaScript needs no cast.
The exact type of every event is in the typings: see @minecraft/server.