---
title: Your first server
description: 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

```ts
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.version` reports the result.
- An unsupported protocol with `nextState: 1` keeps 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: 2` or `3` gets a `LoginDisconnect` with the reason `Unsupported protocol version 999; this server supports 776 (26.2)`, the socket is ended, and `Server` emits `versionRejected` with the client and the protocol number.

See [Versions](/docs/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:

1. **Hello → LoginFinished**

    Builds an offline-mode game profile from the username and UUID the client sent. No Mojang session check, no encryption.

2. **LoginAcknowledged → SelectKnownPacks**

    Offers the single known pack `minecraft:core` for the negotiated version (`26.2`).

3. **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:

```ts
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:

```ts
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](/docs/world-generation) for the options, or place blocks yourself with `chunks.setBlock(x, y, z, stateId)` as described in [Sending chunks](/docs/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:

```ts
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:

1. **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.
2. **`GameEvent` 13 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:

```ts
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](/docs/events) for the full list.
