Minecraft Server List Ping in C#

Build a TCP server that speaks the Minecraft binary protocol from scratch.

Introduction

30 Second Summary

When you open Minecraft's multiplayer menu, servers appear with their names, player counts, and latency bars. Behind the scenes, your game client is speaking a custom binary protocol over TCP to discover each server.

In this project, you will build a custom Minecraft server from scratch in C# that responds to the Server List Ping protocol. Your server will appear in the real Minecraft multiplayer list with a custom name, description, and player count.

What You'll Build

A .NET console application that implements the Minecraft binary protocol layer, including VarInt encoding, packet framing, and the full Handshake/Status flow.

By the end of this project, you'll have:

  • A working TCP server that listens on Minecraft's default port and speaks its binary wire format.
  • A complete VarInt codec and packet framing layer that reads and writes length-prefixed binary packets.
  • Your server visible in Minecraft's multiplayer list with a custom MOTD, version name, and player count that a real game client accepts.
  • Secret Mission: Add a custom 64x64 server icon that shows up next to your server name in the Minecraft server list.

Are there any prerequisites?

You should have basic C# knowledge (variables, classes, async/await). Familiarity with how bytes and binary data work is helpful but not required. You also need Minecraft Java Edition installed to test your server.

Set Up the .NET Project

Every network protocol starts as a specification on paper. Building one from scratch means turning that spec into real code, and that starts with a clean project structure.

In this step, you will scaffold a .NET console application and prepare the two files that will hold your server logic and protocol utilities.

In this step, get ready to:
  • Create a .NET console application called McServer.
  • Verify the project builds with no errors.
  • Add a MinecraftProtocol.cs file for your protocol utilities.
Create the console application
  • Press Cmd+` (macOS) or Ctrl+` (Windows) to open the integrated terminal in your code editor.
  • Move to your Desktop by running this command:
cd ~/Desktop
  • Create a new .NET console application by running this command:
dotnet new console -n McServer

What does this command do?

dotnet new console scaffolds a ready-to-run C# console application. The -n McServer flag names both the folder and the project file inside it.

You should see a message that says: The template "Console App" was created successfully.

  • Move into the new project folder by running:
cd McServer
  • Verify the project builds by running:
dotnet build

You should see Build succeeded in the terminal output with no errors.

Build failed or command not found?

  • If you see "command not found", confirm the .NET SDK is installed by running dotnet --version. You need version 9.0 or higher.
  • If the build fails with errors, make sure you ran cd McServer first. The dotnet build command needs to run inside the project folder.

Help me troubleshoot my dotnet build failure

Explore the project structure

The scaffolded project contains two important files. You will replace one and add a third.

  • In your code editor's file explorer sidebar, open the McServer folder. You should see two files:
  • Program.cs contains a single Console.WriteLine("Hello, World!"); line. You will replace this file entirely in a later step.
  • McServer.csproj is the project file that tells .NET how to build your app. It targets net9.0 and enables features like implicit usings and nullable reference types.

What is a .csproj file?

A .csproj file is the project manifest for a C# project. It defines the target framework, output type, and any NuGet package references. For this project, you will not need to edit it at all.

Create the protocol file

Your protocol utilities (VarInt encoding, packet reading, and packet writing) will live in a dedicated file separate from the server logic.

  • Create an empty file called MinecraftProtocol.cs by running this command in your terminal:
touch MinecraftProtocol.cs

On Windows PowerShell?

If touch is not recognized, use this instead:

New-Item MinecraftProtocol.cs
  • Confirm both files exist by running:
ls

You should see MinecraftProtocol.cs, Program.cs, and McServer.csproj in the output.

Don't see MinecraftProtocol.cs?

  • Make sure you are inside the McServer folder (not the parent Desktop folder). Run pwd to check your current directory.
  • The file must be in the same folder as Program.cs. If you accidentally created it elsewhere, delete it and run the touch command again from inside McServer.

Help me find where my file was created

✔️ Awesome, I've got everything!

Great. Double check you have all three files saved in your McServer folder before moving on.

ⓧ I'd like to double check the full code

Here is what each file should look like at this point.

McServer.csproj (auto-generated by dotnet new):

<Project Sdk="Microsoft.NET.Sdk">

  <PropertyGroup>
    <OutputType>Exe</OutputType>
    <TargetFramework>net9.0</TargetFramework>
    <ImplicitUsings>enable</ImplicitUsings>
    <Nullable>enable</Nullable>
  </PropertyGroup>

</Project>

Program.cs (auto-generated Hello World placeholder, will be replaced):

// Hello World placeholder - will be replaced

MinecraftProtocol.cs should be empty at this point. You will fill it with your protocol classes in the next step.

Your project skeleton is ready. Next up, you will implement the binary protocol layer that lets your server speak Minecraft's wire format.

Implement the Minecraft Binary Protocol Layer

Your project skeleton is ready with two files waiting to be filled. Now you need to teach your server how to speak Minecraft's language.

Minecraft clients and servers communicate using a custom binary protocol. Every number, string, and packet is encoded in a specific byte format that's different from plain text. In this step, you'll build the entire protocol layer from scratch in MinecraftProtocol.cs.

In this step, get ready to:
  • Build a VarInt codec that reads and writes Minecraft's variable-length integers.
  • Create PacketReader and PacketWriter classes for binary data types.
  • Implement MinecraftStream for packet framing over TCP.
Build the packet reader and writer classes

Minecraft uses a special integer encoding called VarInt. Instead of always using 4 bytes for every number, VarInt uses 1 to 5 bytes depending on the number's size. Each byte contributes 7 data bits, and the highest bit (MSB) signals whether another byte follows.

You'll build two classes: PacketReader parses VarInts, strings, and numbers from a byte array. PacketWriter encodes them back into bytes for sending.

  • Open MinecraftProtocol.cs in your code editor.
  • Replace the empty file with the following code to create the PacketReader class:
using System.Net.Sockets;
using System.Text;

public class PacketReader
{
    private readonly byte[] _data;
    private int _position;

    public PacketReader(byte[] data)
    {
        _data = data;
        _position = 0;
    }

    // Read a variable-length integer (1-5 bytes)
    public int ReadVarInt()
    {
        int value = 0;
        int position = 0;

        while (true)
        {
            byte currentByte = _data[_position++];
            // Extract the 7 data bits and shift them into place
            value |= (currentByte & 0x7F) << position;

            // If MSB is 0, this is the last byte
            if ((currentByte & 0x80) == 0) break;

            position += 7;
            if (position >= 32) throw new InvalidDataException("VarInt is too big");
        }

        return value;
    }

    // Read a UTF-8 string prefixed with its VarInt byte length
    public string ReadString()
    {
        int length = ReadVarInt();
        string result = Encoding.UTF8.GetString(_data, _position, length);
        _position += length;
        return result;
    }

    // Read an unsigned short (2 bytes, big-endian)
    public ushort ReadUShort()
    {
        ushort value = (ushort)((_data[_position] << 8) | _data[_position + 1]);
        _position += 2;
        return value;
    }

    // Read a long (8 bytes, big-endian)
    public long ReadLong()
    {
        long value = 0;
        for (int i = 0; i < 8; i++)
        {
            value = (value << 8) | _data[_position++];
        }
        return value;
    }
}

What does this code do?

PacketReader wraps a byte array and reads structured data from it, advancing its internal position after each read.

  • ReadVarInt() decodes Minecraft's variable-length integers by reading 7 bits at a time until the MSB is 0.
  • ReadString() reads a VarInt for the string's byte length, then decodes that many bytes as UTF-8.
  • ReadUShort() and ReadLong() read big-endian multi-byte numbers by shifting bytes into position.

Why big-endian?

The Minecraft protocol stores multi-byte numbers (shorts, ints, longs) in big-endian order, meaning the most significant byte comes first. C# on most platforms uses little-endian, so you read the bytes manually instead of using BitConverter.

You won't see a change yet. The next chunk adds PacketWriter which completes the pair.

  • Add the following PacketWriter class below your PacketReader class in the same file:
public class PacketWriter
{
    private readonly MemoryStream _stream = new();

    // Write a variable-length integer (1-5 bytes)
    public void WriteVarInt(int value)
    {
        byte[] bytes = GetVarIntBytes(value);
        _stream.Write(bytes);
    }

    // Write a UTF-8 string prefixed with its VarInt byte length
    public void WriteString(string value)
    {
        byte[] bytes = Encoding.UTF8.GetBytes(value);
        WriteVarInt(bytes.Length);
        _stream.Write(bytes);
    }

    // Write a long (8 bytes, big-endian)
    public void WriteLong(long value)
    {
        for (int i = 7; i >= 0; i--)
        {
            _stream.WriteByte((byte)(value >> (i * 8)));
        }
    }

    // Return all written bytes as an array
    public byte[] ToArray() => _stream.ToArray();

    // Static helper to encode a VarInt into bytes
    public static byte[] GetVarIntBytes(int value)
    {
        var bytes = new List<byte>();
        while (true)
        {
            if ((value & ~0x7F) == 0)
            {
                bytes.Add((byte)value);
                return bytes.ToArray();
            }
            // Set MSB to 1 (more bytes follow), keep 7 data bits
            bytes.Add((byte)((value & 0x7F) | 0x80));
            value >>>= 7;
        }
    }
}

What does this code do?

PacketWriter builds up a byte array by writing values into a MemoryStream.

  • WriteVarInt() encodes an integer into 1-5 bytes, setting the MSB on every byte except the last.
  • WriteString() first writes the string's byte length as a VarInt, then the UTF-8 bytes.
  • GetVarIntBytes() is a static helper so other classes (like MinecraftStream) can encode VarInts without creating a full writer.
  • The >>> operator is an unsigned right shift. It fills with zeros instead of copying the sign bit, which is critical for encoding negative VarInt values correctly.
  • Save MinecraftProtocol.cs.
  • Verify both classes compile by running:
dotnet build

You should see Build succeeded with no errors.

Seeing build errors?

  • If you see an error about >>> not being recognized, confirm your project targets net9.0 in McServer.csproj. The unsigned right shift operator requires C# 11 or later.
  • If you see a missing semicolon or brace error, check that the closing brace of PacketReader is on its own line before PacketWriter starts.
  • help me debug my build error in MinecraftProtocol.cs
Implement the MinecraftStream framing layer

Raw TCP is a continuous stream of bytes with no built-in boundaries between messages. Minecraft solves this with packet framing: every packet is prefixed with a VarInt that tells the receiver how many bytes to read next.

The MinecraftStream class wraps a NetworkStream and handles this framing. It turns a raw byte stream into discrete packets your server can process one at a time.

  • In MinecraftProtocol.cs, find the line that says public class PacketReader.
  • Add the following MinecraftStream class above it, between the using directives and the PacketReader class:
public class MinecraftStream
{
    private readonly NetworkStream _stream;

    public MinecraftStream(NetworkStream stream)
    {
        _stream = stream;
    }

    // Read a complete packet: VarInt length prefix, then that many bytes
    public async Task<byte[]> ReadPacketAsync()
    {
        int length = await ReadVarIntFromStreamAsync();
        byte[] data = new byte[length];
        int totalRead = 0;
        // Keep reading until we have all the bytes (TCP may deliver in chunks)
        while (totalRead < length)
        {
            int read = await _stream.ReadAsync(data.AsMemory(totalRead, length - totalRead));
            if (read == 0) throw new EndOfStreamException();
            totalRead += read;
        }
        return data;
    }

    // Write a packet: prepend VarInt length, then the packet data
    public async Task WritePacketAsync(byte[] data)
    {
        byte[] lengthBytes = PacketWriter.GetVarIntBytes(data.Length);
        await _stream.WriteAsync(lengthBytes);
        await _stream.WriteAsync(data);
    }

    // Read a VarInt one byte at a time directly from the network stream
    private async Task<int> ReadVarIntFromStreamAsync()
    {
        int value = 0;
        int position = 0;
        byte[] buffer = new byte[1];

        while (true)
        {
            int bytesRead = await _stream.ReadAsync(buffer.AsMemory(0, 1));
            if (bytesRead == 0) throw new EndOfStreamException();

            value |= (buffer[0] & 0x7F) << position;

            if ((buffer[0] & 0x80) == 0) break;

            position += 7;
            if (position >= 32) throw new InvalidDataException("VarInt is too big");
        }

        return value;
    }
}

What does this code do?

  • ReadPacketAsync() first reads a VarInt from the stream (the packet length), then reads exactly that many bytes. The while loop handles the fact that TCP may deliver data in smaller chunks than requested.
  • WritePacketAsync() prepends the VarInt-encoded length before writing the actual packet data. This is how the receiver knows where one packet ends and the next begins.
  • ReadVarIntFromStreamAsync() reads the VarInt one byte at a time directly from the network. It uses the same 7-bits-per-byte logic as PacketReader.ReadVarInt(), but reads from a live stream instead of a byte array.

Why two VarInt readers?

MinecraftStream reads VarInts from a network stream (async, one byte at a time). PacketReader reads VarInts from a byte array (sync, already in memory). They do the same decoding logic in different contexts.

  • Save MinecraftProtocol.cs.
  • Verify the complete file compiles by running:
dotnet build

You should see Build succeeded with no errors.

Getting a 'does not contain a definition' error?

  • If the error mentions PacketWriter.GetVarIntBytes, make sure your PacketWriter class has the public static byte[] GetVarIntBytes(int value) method.
  • If you see a NetworkStream not found error, confirm using System.Net.Sockets; is at the very top of the file.
  • help me fix my MinecraftStream build error
Verify your VarInt encoding against the protocol spec

The Minecraft protocol wiki documents exact byte values for specific VarInt encodings. You can use these to mentally verify your implementation handles the edge cases.

VarInt reference values from the protocol spec

  • 0 encodes as 0x00 (1 byte).
  • 1 encodes as 0x01 (1 byte).
  • 127 encodes as 0x7f (1 byte, maximum single-byte value).
  • 128 encodes as 0x80 0x01 (2 bytes, first value that needs a continuation).
  • 25565 (Minecraft's default port) encodes as 0xdd 0xc7 0x01 (3 bytes).
  • -1 encodes as 0xff 0xff 0xff 0xff 0x0f (5 bytes, maximum VarInt size).

Notice how 127 fits in one byte (7 data bits), but 128 spills into two. The >>>= 7 shift in your GetVarIntBytes method is what causes this split at powers-of-128 boundaries.

What is VarInt used for in practice?

VarInt is the same encoding concept used by Protocol Buffers (Google's serialization format). Small numbers like packet IDs (0, 1, 2) only cost 1 byte on the wire, while the format can still represent the full 32-bit integer range when needed.

✔️ Awesome, I've got everything!

Great. Double check you've saved MinecraftProtocol.cs and that dotnet build passes with no errors.

ⓧ I'd like to double check the full code

Here is the complete MinecraftProtocol.cs file. Make sure your code matches this exactly:

using System.Net.Sockets;
using System.Text;

public class MinecraftStream
{
    private readonly NetworkStream _stream;

    public MinecraftStream(NetworkStream stream)
    {
        _stream = stream;
    }

    public async Task<byte[]> ReadPacketAsync()
    {
        int length = await ReadVarIntFromStreamAsync();
        byte[] data = new byte[length];
        int totalRead = 0;
        while (totalRead < length)
        {
            int read = await _stream.ReadAsync(data.AsMemory(totalRead, length - totalRead));
            if (read == 0) throw new EndOfStreamException();
            totalRead += read;
        }
        return data;
    }

    public async Task WritePacketAsync(byte[] data)
    {
        byte[] lengthBytes = PacketWriter.GetVarIntBytes(data.Length);
        await _stream.WriteAsync(lengthBytes);
        await _stream.WriteAsync(data);
    }

    private async Task<int> ReadVarIntFromStreamAsync()
    {
        int value = 0;
        int position = 0;
        byte[] buffer = new byte[1];

        while (true)
        {
            int bytesRead = await _stream.ReadAsync(buffer.AsMemory(0, 1));
            if (bytesRead == 0) throw new EndOfStreamException();

            value |= (buffer[0] & 0x7F) << position;

            if ((buffer[0] & 0x80) == 0) break;

            position += 7;
            if (position >= 32) throw new InvalidDataException("VarInt is too big");
        }

        return value;
    }
}

public class PacketReader
{
    private readonly byte[] _data;
    private int _position;

    public PacketReader(byte[] data)
    {
        _data = data;
        _position = 0;
    }

    public int ReadVarInt()
    {
        int value = 0;
        int position = 0;

        while (true)
        {
            byte currentByte = _data[_position++];
            value |= (currentByte & 0x7F) << position;

            if ((currentByte & 0x80) == 0) break;

            position += 7;
            if (position >= 32) throw new InvalidDataException("VarInt is too big");
        }

        return value;
    }

    public string ReadString()
    {
        int length = ReadVarInt();
        string result = Encoding.UTF8.GetString(_data, _position, length);
        _position += length;
        return result;
    }

    public ushort ReadUShort()
    {
        ushort value = (ushort)((_data[_position] << 8) | _data[_position + 1]);
        _position += 2;
        return value;
    }

    public long ReadLong()
    {
        long value = 0;
        for (int i = 0; i < 8; i++)
        {
            value = (value << 8) | _data[_position++];
        }
        return value;
    }
}

public class PacketWriter
{
    private readonly MemoryStream _stream = new();

    public void WriteVarInt(int value)
    {
        byte[] bytes = GetVarIntBytes(value);
        _stream.Write(bytes);
    }

    public void WriteString(string value)
    {
        byte[] bytes = Encoding.UTF8.GetBytes(value);
        WriteVarInt(bytes.Length);
        _stream.Write(bytes);
    }

    public void WriteLong(long value)
    {
        for (int i = 7; i >= 0; i--)
        {
            _stream.WriteByte((byte)(value >> (i * 8)));
        }
    }

    public byte[] ToArray() => _stream.ToArray();

    public static byte[] GetVarIntBytes(int value)
    {
        var bytes = new List<byte>();
        while (true)
        {
            if ((value & ~0x7F) == 0)
            {
                bytes.Add((byte)value);
                return bytes.ToArray();
            }
            bytes.Add((byte)((value & 0x7F) | 0x80));
            value >>>= 7;
        }
    }
}

Your entire binary protocol layer is complete. Next up, you'll use these classes to build an async TCP server that listens for real Minecraft clients and parses their Handshake packets.

Build the Async TCP Server and Parse the Handshake

In the previous step, you built the binary protocol layer that can encode and decode Minecraft's wire format. You now have tools to read VarInts, strings, and length-prefixed packet frames from a raw TCP stream.

In this step, you will put those tools to work by creating an actual TCP server. When Minecraft connects to your server, it sends a Handshake packet that declares what it wants. Your server will read that packet, decode its fields, and log them to the console.

In this step, get ready to:
  • Create a TCP listener that accepts connections on port 25565.
  • Parse the Minecraft Handshake packet from incoming clients.
  • Verify your server appears in Minecraft's multiplayer screen.
Create the TCP listener and connection loop

Every network server starts by binding to a port and waiting for incoming connections. Your server will listen on port 25565 (Minecraft's default) and hand off each new client to an async handler.

  • In your code editor, open Program.cs and delete all of its contents.
  • Replace it with the following TCP listener code:
using System.Net;
using System.Net.Sockets;

// Create a TCP listener on Minecraft's default port
var listener = new TcpListener(IPAddress.Any, 25565);
listener.Start();
Console.WriteLine("Minecraft server listening on port 25565...");
Console.WriteLine("Open Minecraft, go to Multiplayer, and add server: localhost");

// Accept connections forever, handling each one concurrently
while (true)
{
    var client = await listener.AcceptTcpClientAsync();
    _ = HandleClientAsync(client);
}

What does this code do?

TcpListener binds to port 25565 on all network interfaces (IPAddress.Any). Once Start() is called, it begins accepting incoming TCP connections.

The while (true) loop calls AcceptTcpClientAsync() which waits until a client connects, then returns a TcpClient representing that connection. The _ = pattern is "fire-and-forget". It launches the handler without blocking the loop, so multiple clients can connect simultaneously.

You won't see a change yet. The next chunk adds the HandleClientAsync method that this loop calls.

Parse the Handshake packet in HandleClientAsync

The Minecraft Handshake packet (ID 0x00 in the Handshaking state) is the first packet every client sends. It contains the protocol version, server address, port, and an intent field that declares whether the client wants a Status ping or a Login.

  • Add the following two methods below the while (true) loop in Program.cs:
async Task HandleClientAsync(TcpClient client)
{
    try
    {
        using (client)
        {
            // Wrap the raw stream in our protocol layer
            var stream = client.GetStream();
            var mcStream = new MinecraftStream(stream);

            // Read the first packet (length-prefixed by VarInt)
            var packet = await mcStream.ReadPacketAsync();
            var reader = new PacketReader(packet);

            // First field is always the packet ID
            int packetId = reader.ReadVarInt();
            if (packetId != 0x00) return;

            // Parse the four Handshake fields in order
            int protocolVersion = reader.ReadVarInt();
            string serverAddress = reader.ReadString();
            ushort serverPort = reader.ReadUShort();
            int nextState = reader.ReadVarInt();

            Console.WriteLine($"Handshake: protocol={protocolVersion}, address={serverAddress}, port={serverPort}, state={nextState}");

            // nextState=1 means the client wants a Status ping
            if (nextState == 1)
            {
                await HandleStatusAsync(mcStream);
            }
        }
    }
    catch (Exception ex)
    {
        Console.WriteLine($"Client disconnected: {ex.Message}");
    }
}

async Task HandleStatusAsync(MinecraftStream mcStream)
{
    // To be implemented in the next step
    await Task.CompletedTask;
}

What does this code do?

HandleClientAsync wraps the raw NetworkStream in your MinecraftStream from the previous step. This gives you length-prefixed packet reading out of the box.

It then reads the Handshake fields in the exact order the Minecraft protocol defines: Protocol Version (VarInt), Server Address (String), Server Port (Unsigned Short), and Next State (VarInt). When nextState equals 1, the client is requesting a Status ping. That flow will be handled in the next step.

The using (client) block ensures the TCP connection is properly disposed when the handler finishes or throws. The try/catch prevents a single misbehaving client from crashing your server.

  • Save Program.cs.
  • Confirm the project compiles by running:
dotnet build
  • You should see Build succeeded with 0 errors in the output.

Seeing a build error?

  • Check that both methods (HandleClientAsync and HandleStatusAsync) are placed after the while (true) loop, not inside it.
  • Make sure you kept the using System.Net; and using System.Net.Sockets; lines at the top of the file.
  • Verify that MinecraftProtocol.cs is still in the same folder. The MinecraftStream and PacketReader classes come from that file.

Help me fix my build error in Program.cs

✔️ Awesome, I've got everything!

Great. Make sure you have saved Program.cs before moving on.

ⓧ I'd like to double check the full code

Here is the complete Program.cs at this point:

using System.Net;
using System.Net.Sockets;

var listener = new TcpListener(IPAddress.Any, 25565);
listener.Start();
Console.WriteLine("Minecraft server listening on port 25565...");
Console.WriteLine("Open Minecraft, go to Multiplayer, and add server: localhost");

while (true)
{
    var client = await listener.AcceptTcpClientAsync();
    _ = HandleClientAsync(client);
}

async Task HandleClientAsync(TcpClient client)
{
    try
    {
        using (client)
        {
            var stream = client.GetStream();
            var mcStream = new MinecraftStream(stream);

            var packet = await mcStream.ReadPacketAsync();
            var reader = new PacketReader(packet);

            int packetId = reader.ReadVarInt();
            if (packetId != 0x00) return;

            int protocolVersion = reader.ReadVarInt();
            string serverAddress = reader.ReadString();
            ushort serverPort = reader.ReadUShort();
            int nextState = reader.ReadVarInt();

            Console.WriteLine($"Handshake: protocol={protocolVersion}, address={serverAddress}, port={serverPort}, state={nextState}");

            if (nextState == 1)
            {
                await HandleStatusAsync(mcStream);
            }
        }
    }
    catch (Exception ex)
    {
        Console.WriteLine($"Client disconnected: {ex.Message}");
    }
}

async Task HandleStatusAsync(MinecraftStream mcStream)
{
    // To be implemented in the next step
    await Task.CompletedTask;
}
Run the server and test with Minecraft

Your server is ready to accept connections. Time to see it respond to a real Minecraft client.

  • Start the server by running:
dotnet run
  • You should see this output in your terminal:
Minecraft server listening on port 25565...
Open Minecraft, go to Multiplayer, and add server: localhost
  • Open Minecraft Java Edition.
  • Click Multiplayer.
  • Click Add Server.
  • In the Server Address field, type localhost and click Done.

Switch back to your terminal. You should see a handshake log line appear, something like:

Handshake: protocol=767, address=localhost, port=25565, state=1

The server in Minecraft's list will show as unreachable (since the status response is not implemented yet). That is expected. The important thing is the handshake log proving your server received and parsed the binary packet.

What do the handshake fields mean?

  • protocol=767 is the protocol version for Minecraft 1.21.
  • address=localhost is the server address the client typed in.
  • port=25565 is the port the client connected to.
  • state=1 means the client wants a Status ping (not a Login).
  • Press Ctrl+C in your terminal to stop the server when you are done testing.

Not seeing the handshake log?

  • If you see an error about port 25565 already being in use, you likely have a real Minecraft server or another instance of your app running. Close it first, then run dotnet run again.
  • Make sure you typed localhost (not 127.0.0.1 or a different address) in the Minecraft server address field.
  • If Minecraft shows "Can't connect to server" without any log in your terminal, your firewall may be blocking the connection. Check that no firewall rule is blocking port 25565 for local traffic.

Why isn't my Minecraft server handshake showing up in the terminal?

Your server just decoded a real binary protocol packet from a real game client. Next up, you will implement the Status Response so Minecraft displays your custom server name and player count in the server list.

Implement Status Response and Ping/Pong

Your server can accept connections and parse the Handshake packet. The client has introduced itself and said "I want status information." Now you need to actually respond.

The Status ping protocol is a short conversation: the client sends an empty Status Request, your server replies with a JSON payload describing itself, then the client sends a Ping with a timestamp that you echo back. Once you implement this exchange, your server will appear in Minecraft's multiplayer list with a custom name, player count, and latency bars.

In this step, get ready to:
  • Read the Status Request and send a JSON response with your server's info.
  • Handle the Ping/Pong latency measurement exchange.
  • Test the full flow by seeing your server in Minecraft's multiplayer list.
Read the Status Request and send a JSON response

After the Handshake, the Minecraft client sends a Status Request packet. This packet has ID 0x00 and contains no fields at all. Your server reads it, then replies with a JSON string describing the server's version, player count, and message of the day (MOTD).

  • In Program.cs, find the HandleStatusAsync method at the bottom of the file.
  • Find these two placeholder lines inside the method body:
    // To be implemented in the next step
    await Task.CompletedTask;
  • Delete those two lines and replace them with the following code to read the Status Request and send your server's JSON response:
    // Read the Status Request packet (ID 0x00, no fields)
    var requestPacket = await mcStream.ReadPacketAsync();
    var requestReader = new PacketReader(requestPacket);
    int requestId = requestReader.ReadVarInt();
    if (requestId != 0x00) return;

    // Build the JSON status response with version, players, and MOTD
    string json = """{"version":{"name":"McSharp 1.0","protocol":767},"players":{"max":20,"online":0,"sample":[]},"description":{"text":"A Minecraft server built from scratch in C#!"}}"""

    // Write packet ID 0x00 + the JSON string to the client
    var responseWriter = new PacketWriter();
    responseWriter.WriteVarInt(0x00);
    responseWriter.WriteString(json);
    await mcStream.WritePacketAsync(responseWriter.ToArray());

What does this code do?

The code reads one packet from the stream and checks that its ID is 0x00 (the Status Request). If it's anything else, the method exits early.

The JSON string uses C# raw string literals (triple quotes) so you don't have to escape all the inner double quotes. It tells Minecraft your server is called "McSharp 1.0", supports protocol version 767, has capacity for 20 players with 0 currently online, and displays your custom MOTD.

The response is written as packet ID 0x00 followed by the JSON encoded as a Minecraft String (VarInt length prefix + UTF-8 bytes). Your MinecraftStream then wraps the whole thing in the packet-length framing.

You won't see a change yet. The protocol requires the Ping/Pong exchange to complete before Minecraft displays anything in the server list.

Handle the Ping Request and Pong Response

After receiving the Status Response, the client immediately sends a Ping Request containing a Long timestamp. Your server must echo that same timestamp back in a Pong Response. This is how Minecraft measures latency and decides whether to show green, yellow, or red connection bars.

  • Still inside HandleStatusAsync, add the following code directly below the line await mcStream.WritePacketAsync(responseWriter.ToArray()); that you just wrote:
    // Read the Ping Request packet (ID 0x01, contains a Long timestamp)
    var pingPacket = await mcStream.ReadPacketAsync();
    var pingReader = new PacketReader(pingPacket);
    int pingId = pingReader.ReadVarInt();
    if (pingId != 0x01) return;
    long timestamp = pingReader.ReadLong();

    // Echo the timestamp back as a Pong Response (ID 0x01)
    var pongWriter = new PacketWriter();
    pongWriter.WriteVarInt(0x01);
    pongWriter.WriteLong(timestamp);
    await mcStream.WritePacketAsync(pongWriter.ToArray());

    Console.WriteLine("Status ping complete!");

What does this code do?

The Ping Request (packet ID 0x01) carries a single Long (8 bytes, big-endian). Your ReadLong() method from PacketReader extracts it.

The Pong Response echoes the exact same timestamp. The client subtracts its original send time from the current time to calculate round-trip latency. If you changed the value, the latency display would be wrong.

  • Save Program.cs.
  • Confirm the project still compiles by running:
dotnet build
  • You should see "Build succeeded" with 0 errors.

Seeing a build error?

  • Check that the raw string literal uses exactly three double-quote characters on each side ("""). Missing one quote breaks the syntax.
  • Make sure the new code is inside the HandleStatusAsync method body and the closing brace } is still in place.
  • Verify you deleted both placeholder lines (// To be implemented in the next step and await Task.CompletedTask;).

Why won't my HandleStatusAsync code compile?

✔️ Awesome, I've got everything!

Great. Double check you've saved your file before moving on to the live test.

ⓧ I'd like to double check the full code

using System.Net;
using System.Net.Sockets;

var listener = new TcpListener(IPAddress.Any, 25565);
listener.Start();
Console.WriteLine("Minecraft server listening on port 25565...");
Console.WriteLine("Open Minecraft, go to Multiplayer, and add server: localhost");

while (true)
{
    var client = await listener.AcceptTcpClientAsync();
    _ = HandleClientAsync(client);
}

async Task HandleClientAsync(TcpClient client)
{
    try
    {
        using (client)
        {
            var stream = client.GetStream();
            var mcStream = new MinecraftStream(stream);

            var packet = await mcStream.ReadPacketAsync();
            var reader = new PacketReader(packet);

            int packetId = reader.ReadVarInt();
            if (packetId != 0x00) return;

            int protocolVersion = reader.ReadVarInt();
            string serverAddress = reader.ReadString();
            ushort serverPort = reader.ReadUShort();
            int nextState = reader.ReadVarInt();

            Console.WriteLine($"Handshake: protocol={protocolVersion}, address={serverAddress}, port={serverPort}, state={nextState}");

            if (nextState == 1)
            {
                await HandleStatusAsync(mcStream);
            }
        }
    }
    catch (Exception ex)
    {
        Console.WriteLine($"Client disconnected: {ex.Message}");
    }
}

async Task HandleStatusAsync(MinecraftStream mcStream)
{
    var requestPacket = await mcStream.ReadPacketAsync();
    var requestReader = new PacketReader(requestPacket);
    int requestId = requestReader.ReadVarInt();
    if (requestId != 0x00) return;

    string json = """{"version":{"name":"McSharp 1.0","protocol":767},"players":{"max":20,"online":0,"sample":[]},"description":{"text":"A Minecraft server built from scratch in C#!"}}""";

    var responseWriter = new PacketWriter();
    responseWriter.WriteVarInt(0x00);
    responseWriter.WriteString(json);
    await mcStream.WritePacketAsync(responseWriter.ToArray());

    var pingPacket = await mcStream.ReadPacketAsync();
    var pingReader = new PacketReader(pingPacket);
    int pingId = pingReader.ReadVarInt();
    if (pingId != 0x01) return;
    long timestamp = pingReader.ReadLong();

    var pongWriter = new PacketWriter();
    pongWriter.WriteVarInt(0x01);
    pongWriter.WriteLong(timestamp);
    await mcStream.WritePacketAsync(pongWriter.ToArray());

    Console.WriteLine("Status ping complete!");
}
Test your server in Minecraft

Your server now implements the complete Server List Ping protocol. Time to see it in action with a real Minecraft client.

  • Start your server by running:
dotnet run
  • You should see "Minecraft server listening on port 25565..." in your terminal.
  • Open Minecraft Java Edition.
  • Click Multiplayer from the main menu.
  • Click Add Server.
  • Enter localhost in the Server Address field.
  • Click Done.

Your server should appear in the list showing "McSharp 1.0" as the version, "0/20" as the player count, your custom MOTD "A Minecraft server built from scratch in C#!", and green latency bars.

In your terminal, you should see the Handshake log line followed by "Status ping complete!" each time Minecraft refreshes the server list.

Why does Minecraft keep pinging?

The Minecraft client periodically re-pings servers in your list to update the latency bars and player count. Each refresh triggers the full Handshake, Status Request, Status Response, Ping, and Pong sequence again. You'll see multiple "Status ping complete!" lines in your terminal as it refreshes.

Server not appearing in Minecraft?

  • Make sure your terminal shows "Minecraft server listening on port 25565..." before you try to add the server in Minecraft.
  • If you have a real Minecraft server already running, stop it first. Only one process can bind to port 25565 at a time.
  • Check that you entered localhost (not "localhost:25565"). Minecraft uses port 25565 by default, so you don't need to specify it.
  • If the terminal shows a Handshake log but no "Status ping complete!", there may be a mismatch in your packet IDs. Double check that the Status Response uses 0x00 and the Pong uses 0x01.

My Minecraft server isn't showing up in the multiplayer list. Help me debug.

Secret mission

Add a Custom Server Icon

Your server shows up in the Minecraft multiplayer list, but it's missing something. Every real server has a custom icon next to its name. Can you serve a 64x64 PNG image as a Base64-encoded data URI in the status response?

Clean Up Your Resources

Clean Up Your Resources

Decide whether to keep your resources running, pause them to come back later, or delete them entirely. This project runs entirely locally, so there are no ongoing costs.

Resources you used:

  • The McServer project folder on your Desktop (contains Program.cs, MinecraftProtocol.cs, McServer.csproj, and server-icon.png).

Keep everything running

No action needed. Choose this if you are still actively building or want to experiment with extending your Minecraft server further.

  • Your project files stay on your Desktop in the McServer folder.
  • The server only runs when you explicitly start it with dotnet run. It does not consume resources when stopped.

Pause - I'll come back to this later

Shut down the running server process to free up the port. Your files stay on disk so you can pick up where you left off.

  • If your server is still running in the terminal, press Ctrl+C to stop it.
  • That is all. The project files remain in McServer on your Desktop. Run dotnet run from that folder when you want to start again.

Delete - I don't want to use this again

Remove all project files from your machine. This cannot be undone.

  • If your server is still running, press Ctrl+C to stop it.
  • Delete the project folder by running the appropriate command for your system.

macOS

cd ~/Desktop
rm -rf McServer

Windows

cd ~\Desktop
rmdir /s /q McServer
  • Confirm the folder is gone by running ls (macOS) or dir (Windows) on your Desktop. You should no longer see McServer in the listing.

Nice Work!

Nice Work!

You just built a TCP server from scratch that speaks a real-world binary protocol. A genuine Minecraft client connected to your code and displayed your custom server in its multiplayer list.

You've learned how to:

  • Implement VarInt encoding and a packet framing layer that reads and writes length-prefixed binary data over a raw TCP stream.
  • Build an async TCP server with TcpListener and handle concurrent clients using fire-and-forget tasks in .NET.
  • Parse and respond to the Minecraft Server List Ping protocol including the Handshake, Status Request/Response, and Ping/Pong exchange.
  • Secret Mission: Served a custom 64x64 server icon via a Base64-encoded data URI in the status response JSON.

Ready to quiz yourself?