Your first client
Connect to a server, ping it, or log a bot in.
Client opens a TCP connection and speaks the protocol from the client side. Unlike Server, it does not automate login for you: you send Intension, Hello, and the acknowledgements yourself. The state machine still switches automatically as those packets go out.
Options
import { Client } from "mc-protocol";
const client = new Client({
host: "localhost", // default
port: 25565, // default
username: "bot", // default; not sent automatically — include it in Hello
version: 776, // default; protocol number for the packet tables and Intension
});
The socket connects in the constructor. Wait for Socket:Ready before sending.
version must be a built-in protocol number (only 776 today; unknown values throw a RangeError). It selects the packet tables for both directions and fills protocolVersion in Intension when you leave it out. If you send Intension with a different protocolVersion that is also built in, the client switches to that version’s tables. An unknown number is sent as is and the tables stay on version, which is enough for pinging a server of another version.
Ping a server
The shortest useful client. Ask for status, print it, leave.
client.on("Socket:Ready", async () => {
await client.sendPacket("Intension", {
// protocolVersion defaults to the client's `version` option
serverHost: "localhost",
serverPort: 25565,
nextState: 1, // status
});
await client.sendPacket("StatusRequest");
});
client.on("Status/StatusResponse", (data) => {
const status = JSON.parse(data.jsonResponse);
console.log(status.version.name, `${status.players.online}/${status.players.max}`);
client.disconnect();
});
Every parsed packet is emitted as packet with the full { id, name, data }, under its own name with data, packet, and under a state-qualified name such as Status/StatusResponse. A listener such as client.on("KeepAlive", …) is state-agnostic and receives every incoming KeepAlive, including packets from both configuration and play. Use state-qualified listeners for lifecycle handling so similarly named packets from another state cannot reach the handler. Valid prefixes are Handshake, Status, Login, Config, and Play.
Log a bot in
Logging in means answering three prompts from the server. Against an mc-protocol server with autoInit, this is the complete exchange:
client.on("Socket:Ready", async () => {
await client.sendPacket("Intension", {
protocolVersion: 776,
serverHost: "localhost",
serverPort: 25565,
nextState: 2, // login
});
await client.sendPacket("Hello", {
username: "bot",
playerUUID: "00000000000000000000000000000000", // 32 hex chars, no dashes
});
});
// login state
client.on("Login/LoginFinished", async () => {
await client.sendPacket("LoginAcknowledged"); // -> config, emits "login"
});
// config state
client.on("Config/SelectKnownPacks", async (data) => {
await client.sendPacket("SelectKnownPacks", { knownPacks: data.knownPacks });
});
client.on("Config/FinishConfiguration", async () => {
await client.sendPacket("FinishConfiguration"); // -> play, emits "playerJoin"
});
// play state
client.on("Play/KeepAlive", async (data) => {
await client.sendPacket("KeepAlive", { keepAliveId: data.keepAliveId });
});
client.on("playerJoin", (session) => {
console.log(`in game as ${session.username}`);
});
Compression is handled for you: when LoginCompression arrives the client switches both directions and emits compression. There is no client-side threshold option; the server chooses it. A positive threshold compresses packets at or above that many bytes (packet ID plus payload), 0 compresses every packet, and a negative threshold disables compression. client.compression and the compression event report null when disabled. The switch also works when the announcement and the next packet arrive in the same TCP chunk.
Wrapping an existing socket
Client.fromSocket(socket, options?) builds a Client around a net.Socket the server side already accepted. Its side is "server", so lifecycle packets are interpreted from the server’s point of view. Server uses this internally with { versions }; you need it for a proxy or a custom listener. Without versions, only the version option (default 776) is accepted at the handshake.
import net from "node:net";
import { Client } from "mc-protocol";
net.createServer((socket) => {
const player = Client.fromSocket(socket);
player.on("packet", (packet) => console.log(packet.name));
}).listen(25565);
Session and state
| Property | Meaning |
|---|---|
client.side |
"client" or "server" |
client.version |
Protocol number the packet tables use. Server side: negotiated from Intension |
client.session |
{ protocol?, serverAddress?, serverPort?, username?, uuid?, profile? }. Intension fills the first three; Hello fills username/uuid from either write names (username/playerUUID) or parsed names (name/uuid); LoginFinished overwrites them from gameProfile |
client.incomingState / outgoingState |
Current connection state per direction |
client.compression |
Threshold in bytes, or null when off |
client.loggedIn |
true after LoginAcknowledged |
client.joined |
true after entering play |
Closing
client.disconnect() ends the socket politely. client.destroy() tears it down immediately. Both emit Socket:Close.
Sending and intercepting packets
sendPacket(name, data) looks the packet up by name in the current outgoing state. You can also pass a numeric ID for that state: sendPacket(0, handshake) is the same as sendPacket("Intension", handshake) during handshake.
addPacketHandler(handler) runs before packet and named events. The handler receives { id, name, data } and may be async. It returns an unsubscribe function. Server and Proxy use this internally so their work finishes before your listeners see the packet. You rarely need it in application code.
AcknowledgeConfiguration is accepted as an alias of ConfigurationAcknowledged when switching play → config.