Your first server
Accept a real Minecraft 26.2 client and put it on generated terrain.
This walks through examples/flat-world.ts from the repository: a server that answers the server-list ping, logs players in, and sends enough of a world that the client leaves the loading screen. Run it, connect with Minecraft 26.2, and you will stand on seeded noise terrain with a sea and a few trees.
Options
import { Server, getVersion } from "mc-protocol";
const server = new Server({
host: "0.0.0.0", // default
port: 25565, // default
autoInit: true, // default: handle login + configuration for you
autoKeepAlive: true, // default: ping every 10 s, drop after 30 s of silence
compressionThreshold: 256, // default: same as vanilla
versions: [776], // default: protocol numbers accepted at the handshake
});
Server starts listening in its constructor. There is no listen() call; wait for Server:Ready if you need to know when the port is open.
Versions
versions lists the protocol numbers the server accepts. Every entry must be a built-in version, otherwise the constructor throws a RangeError. Today the only built-in version is 776 (Minecraft 26.2), so the default is the only valid value. server.versions returns the list.
The version is picked per connection from Intension.protocolVersion:
- A supported protocol switches that connection’s packet tables before the next packet is decoded.
client.versionreports the result. - An unsupported protocol with
nextState: 1keeps the default tables so the server-list ping still works. Use it to tell old clients which versions you accept (see below). - An unsupported protocol with
nextState: 2or3gets aLoginDisconnectwith the reasonUnsupported protocol version 999; this server supports 776 (26.2), the socket is ended, andServeremitsversionRejectedwith the client and the protocol number.
See Versions for the layout behind this.
Compression
Compression is on by default with the vanilla threshold of 256: packets whose uncompressed packet ID and payload total at least 256 bytes are deflated. Smaller packets remain uncompressed inside the compression framing. 0 compresses every packet after negotiation; -1 disables compression. The option must be an integer from -1 through 2147483647.
Keep it on. A lit LevelChunkWithLight compresses well under zlib, which is why vanilla enables it. Set -1 only when you debug raw frames.
With autoInit: true, the server sends LoginCompression after receiving Hello, before LoginFinished. The announcement uses the old framing; subsequent packets use the negotiated framing in both directions. Handshake and server-list status traffic remain uncompressed.
With autoInit: false, you own negotiation too. During login, await client.sendPacket("LoginCompression", { threshold: 64 }) before sending LoginFinished. Sending the announcement updates the server-side client’s compression automatically; do not enable compression before sending it.
What autoInit does
With autoInit on, the server answers the lifecycle packets so you do not have to:
Hello → LoginFinished
Builds an offline-mode game profile from the username and UUID the client sent. No Mojang session check, no encryption.
LoginAcknowledged → SelectKnownPacks
Offers the single known pack minecraft:core for the negotiated version (26.2).
SelectKnownPacks → registries
Sends one RegistryData per vanilla registry, then UpdateEnabledFeatures (minecraft:vanilla), UpdateTags, and FinishConfiguration.
If the client does not report the core pack as known, initialization throws and the error surfaces on Client:Error.
Automatic responses finish before Server emits packet, so a listener’s reply uses the negotiated compression framing. Incoming packet processing waits for these responses too. If initialization fails, the client is destroyed and that packet is not emitted on Server.
Turn autoInit off when you want to send custom registries or implement online mode yourself. You then handle Hello, LoginAcknowledged, and SelectKnownPacks through their named events.
Every incoming packet can be handled by name with server.on("<PacketName>", (client, data, packet) => …). This form is state-agnostic and receives every packet with that name in every state. For lifecycle handling, prefix the name with the decoding state so a similarly named packet from another state cannot reach the handler: server.on("Config/KeepAlive", …). Valid prefixes are Handshake, Status, Login, Config, and Play. The generic packet event remains available when one handler intentionally needs every packet.
Server list ping
Status requests arrive before login, on the status state. Reply with JSON and echo the ping timestamp:
server.on("Status/StatusRequest", (client) => {
void client.sendPacket("StatusResponse", {
jsonResponse: {
version: {
name: getVersion(server.versions.at(-1)!).name,
protocol: server.versions.at(-1)!,
},
players: { max: 20, online: server.clients.size },
description: { text: "My server", color: "aqua" },
enforcesSecureChat: false,
},
})
.catch((error) => {
console.error(error);
client.destroy();
});
});
server.on("Status/PingRequest", (client, data) => {
void client.sendPacket("PongResponse", { timestamp: data.timestamp })
.catch((error) => {
console.error(error);
client.destroy();
});
});
jsonResponse accepts an object or a pre-serialized string. A client whose protocol is not in server.versions still gets this far, so version.protocol is what the client uses to show “Incompatible” in the list. client.session.protocol is the protocol the client asked for.
Building the world
helpers.ChunkHelper stores blocks and produces lit chunk packets. helpers.createNoiseWorld fills one with seeded terrain. Build it once, at startup:
import { helpers } from "mc-protocol";
const RADIUS = 3; // chunks on each side of the origin: 7 x 7 = 49 chunks
const SEA_LEVEL = 63;
const chunks = helpers.createNoiseWorld({
seed: "mc-protocol", // same seed and options, same world
radius: RADIUS,
seaLevel: SEA_LEVEL,
baseHeight: 70,
amplitude: 32,
terrain: { ridgeStrength: 0.25, warpAmplitude: 24 },
caves: true,
ores: true,
trees: { density: 0.02 },
structures: [
{
id: "boulders",
structure: helpers.createBoulderStructure(),
spacing: 24,
separation: 8,
chance: 0.7,
offsetY: -1,
maxSlope: 4,
minDistanceFromOrigin: 24,
},
{
id: "ruins",
structure: helpers.createRuinStructure(),
spacing: 48,
separation: 12,
chance: 0.5,
maxSlope: 3,
foundation: "cobblestone",
minDistanceFromOrigin: 24,
},
{
id: "cabins",
structure: helpers.createCabinStructure(),
spacing: 80,
separation: 16,
chance: 0.35,
maxSlope: 3,
foundation: "cobblestone",
minDistanceFromOrigin: 24,
},
],
});
// One block above the surface at the origin, or above the water there.
const SPAWN_Y = Math.max(chunks.generator.heightAt(0, 0), SEA_LEVEL) + 1;
See World generation for the options, or place blocks yourself with chunks.setBlock(x, y, z, stateId) as described in Sending chunks.
The join sequence
playerJoin fires when the client acknowledges FinishConfiguration. From here on you are in the play state and the client is showing “Loading terrain”. This exact order is what gets it to close that screen:
const WORLD = "minecraft:overworld";
let nextEntityId = 1;
server.on("playerJoin", async (client, session) => {
console.log(`${session.username} joined`);
await client.sendPacket("Login", {
entityId: nextEntityId++,
isHardcore: false,
worldNames: [WORLD],
maxPlayers: 20,
viewDistance: RADIUS,
simulationDistance: 2,
reducedDebugInfo: false,
enableRespawnScreen: true,
doLimitedCrafting: false,
worldState: {
dimension: 0,
name: WORLD,
hashedSeed: 0n,
gamemode: 1,
previousGamemode: 255,
isDebug: false,
isFlat: false,
death: null,
portalCooldown: 0,
seaLevel: SEA_LEVEL,
},
onlineMode: false,
enforcesSecureChat: false,
});
await client.sendPacket("PlayerAbilities", {
flags: 0x0f, flyingSpeed: 0.05, walkingSpeed: 0.1,
});
// Position first: the client uses it to decide which chunk
// ends the loading screen.
await client.sendPacket("SynchronizePlayerPosition", {
teleportId: 1, x: 0.5, y: SPAWN_Y, z: 0.5, yaw: 0, pitch: 0, flags: 0x08,
});
await client.sendPacket("DefaultSpawnPosition", {
dimension: WORLD, position: { x: 0, y: SPAWN_Y - 1, z: 0 }, yaw: 0, pitch: 0,
});
await client.sendPacket("SetCenterChunk", { chunkX: 0, chunkZ: 0 });
// 13 = "start waiting for level chunks"
await client.sendPacket("GameEvent", { event: 13, value: 0 });
await client.sendPacket("ChunkBatchStart");
let sent = 0;
for (let cx = -RADIUS; cx <= RADIUS; cx++) {
for (let cz = -RADIUS; cz <= RADIUS; cz++) {
const chunk = chunks.getLevelChunkWithLight(cx, cz);
await client.sendPacket("LevelChunkWithLight", chunk);
sent++;
}
}
await client.sendPacket("ChunkBatchFinished", { batchSize: sent });
});
Two details matter more than they look:
- Send the outer ring of chunks too. The client will not render a chunk until its eight horizontal neighbors are loaded and lit. The example sends every generated chunk, so only the outermost ring renders without neighbors.
GameEvent13 before the chunk batch. Without it the client stays on the loading screen even with chunks in hand.
Errors and shutdown
Async listeners should catch their own errors so a bad packet does not become an unhandled rejection:
server.on("playerJoin", (client, session) => {
sendInitialWorld(client).catch((error) => {
console.error("join failed:", error);
client.destroy();
});
});
server.on("Server:Ready", () => console.log("listening on :25565"));
server.on("Server:Error", (error) => console.error(error));
server.on("Client:Error", (client, error) => console.error(error));
server.on("keepAlive", (client, { latency }) => console.log(`${latency} ms`));
server.on("playerLeave", (client) => {
console.log(`${client.session.username} left`);
});
server.on("versionRejected", (client, protocol) => {
console.log(`rejected protocol ${protocol}`);
});
process.on("SIGINT", () => server.close());
server.close() disconnects every client and closes the listening socket. See Events for the full list.