Events & Packets
Your module reacts to the game by subscribing through ctx, at one of two
levels:
- Game events (
ctx.on): high-level, decoded signals, like a tick passing, an entity spawning, or a block changing. - Packets (
ctx.onPacket/ctx.onInjectedPacket): the raw protocol, one handler per packet type.
All three only fire while the module is enabled, and they're torn down with the session, so you never unsubscribe by hand. See The Context.
Game events
ctx.on("entity_spawn", (entity) => {
console.log("spawned", entity.name);
});
The full GameSessionEvents map:
| Event | Payload | Fires when |
|---|---|---|
open | (address: string) | The session connected. |
close | () | The session closed. |
local_player_initialized | () | The local player finished initializing. |
teleport | () | The local player was teleported. |
dimension_change | () | The dimension changed. |
tick | () | Each game tick, while its auth-input packet is being handled. |
post_tick | () | Each game tick, after its auth-input packet was handled. |
input_tick | (state: InputState) | The game takes the player's input for a tick, before movement_tick. Performance-critical, see below. |
movement_tick | (state: MovementState) | The game's own movement tick, before the client travels. Performance-critical, see below. |
entity_spawn | (entity: AbstractEntity) | An entity appeared. |
entity_despawn | (entity: AbstractEntity) | An entity was removed. |
chunk_load | (x, z, y, subchunk: SubChunk) | A subchunk loaded. |
chunk_unload | (x, z) | A chunk unloaded. |
block_update | (x, y, z, layer, block: BlockState) | A block changed. |
start_break | (position: Vector3, face, isInjected) | Block breaking began. |
progress_break | (breakingProgress, tickProgress) | Break progress ticked. |
form_open / form_close | () | A modal form opened/closed. |
container_open / container_close | () | A container opened/closed. |
hotbar_slot | (event: HotbarSlotEvent) | The held hotbar slot changed. |
target_active | (entity: EntityNetworkPlayer) | A combat target became active. |
target_inactive | (entity: EntityNetworkPlayer) | A combat target became inactive. |
target_killed | (entity: EntityNetworkPlayer) | A combat target was killed. |
player_list_add | (entry: PlayerListEntry) | A player joined the tab list. |
player_list_remove | (entry: PlayerListEntry) | A player left the tab list. |
HotbarSlotEvent is { from: number; to: number; isIncoming: boolean }.
tick vs. post_tick
tickfires while the proxy handles the tick'splayer_auth_inputpacket, before it's sent on. Good for editing that packet's input flags.post_tickfires after every listener has handled it. Good for reading the resulting state.
For a one-off action on the next tick, session.events.once("post_tick", fn)
is handy. See The Session.
Both run on the packet path: they fire while the proxy handles the client's
player_auth_input packet, off the game thread. That's where expensive work
belongs.
movement_tick — the game's own tick
movement_tick is a different beast. It's fired from native code inside the
client's movement tick, before the player travels. It gives you a
MovementState whose position and motion you can write, and
your changes apply in that same tick. Everything that moves the local player
goes through it.
ctx.on("movement_tick", (state) => {
state.strafe(ctx.options.speed.value, 1);
});
movement_tick is performance-criticalThe handler runs synchronously on the game thread, which is blocked while it runs. Any time you spend here comes straight out of the client's frame budget. If the hook hasn't answered after 200 ms, the runtime gives up and the tick goes ahead unmodified.
Do heavy work in the packet tick event instead: pathfinding, raytracing,
entity scans, block searches. Cache the result there, and have movement_tick
only apply it. Full pattern:
Deferring heavy work.
input_tick — the player's input
input_tick fires just before movement_tick, at the moment the game takes
the player's input. It gives you an InputState with the move
keys, the analog stick and the sneak key. What you write there is that tick's
input, as if the player had pressed it.
ctx.on("input_tick", (input) => {
input.setSneak(true);
});
It runs on the game thread too, so the same performance rules apply.
Order within a tick
input_tick: the game takes the input. Sprint and sneak changes made here apply on this tick.- The game's sprint and sneak systems run, then its own jump logic.
movement_tick: position, motion and jump writes apply on this tick. Sprint and sneak intents requested here apply on the next tick.- The client travels.
tick: the resulting auth-input packet is being handled.post_tick: that packet has been handled.
So input_tick and movement_tick see the tick before it happens, and tick
sees what it produced. The
Movement page has the full sequence
and which fields are fresh at each step.
Packets
ctx.onPacket(name, cb)subscribes to a packet on the real connection, in either direction.packet.isIncomingtells you which way it's going.ctx.onInjectedPacket(name, cb)subscribes to a locally-injected one.
ctx.onPacket("add_player", (packet) => {
console.log("add_player", packet);
});
name can be any key of FullPacketEvents. That's the full generated protocol
packet map, plus one extra event, post_packet, which fires after any packet is
processed. Your editor autocompletes every packet name and its payload.
There's no catch-all event: you subscribe to each packet by name. To see every packet, register a queue checker.
Some packets come in several variants. text, for example, has one per message
type, and not every variant has a message. Check the variant first, and
TypeScript narrows the packet to that variant's fields.
Mutating packets
Every packet handler gets the parsed packet with a small PacketWrapper
contract mixed in:
| Member | Description |
|---|---|
isIncoming | Whether the packet came from the server. |
isCancelled | Set to true to drop the packet (it won't be forwarded). |
markDirty() | Call after editing the packet's fields so your change is re-encoded and forwarded. |
To drop a packet, set isCancelled:
ctx.onPacket("text", (packet) => {
if (packet.type !== "chat") return;
if (packet.message.includes("spam")) {
packet.isCancelled = true;
}
});
To edit a packet, change its fields, then call markDirty():
ctx.onPacket("text", (packet) => {
if (packet.type !== "chat") return;
packet.message = packet.message.toUpperCase();
packet.markDirty(); // without this, the original bytes are sent unchanged
});
markDirty() is required for editsChanging a field on the packet object does nothing by itself. The connection
forwards the original bytes unless you call markDirty(), which tells it to
re-encode from your modified object. (Cancelling with isCancelled doesn't
need markDirty().)
To send packets instead of reacting to them, or to hold and flush the packet
queue, use session.connection.
onPacket sees packets travelling between the real client and server.
onInjectedPacket sees packets that your code (or another module's) injected
with connection.sendIncomingPacket or
sendOutgoingPacket. Subscribe to whichever
matches the source you care about.