First bot
The bot in this chapter connects to a server, reaches the game world, and stays there: it answers keep-alive, confirms teleports, and prints what happens to it to the console. It cannot play, but everything needed for play is already in place.
The server needs online-mode=false: the library does not go to Mojang for a
session. The protocol version is an integer: here it is 772, which is 1.21.8.
The number for a given version is in the
Version to protocol.
The full example is in the repository: examples/MinimalBot.
Connection
The client opens a TCP connection, then only moves packets back and forth.
const int Pv = 772;
await using var client = new MinecraftClient(new MinecraftClientOptions
{
Host = "127.0.0.1",
Port = 25565
});
await client.ConnectAsync();
The first two packets go out right away: the handshake with the protocol number, and the login request. The number 2 at the end of the handshake is the switch into login.
await client.SendAsync(
new HandshakeSb.SetProtocolPacket(Pv, "127.0.0.1", 25565, 2), Pv);
await client.SendAsync(
new LoginSb.LoginStartPacket("McProtoBot", V764_Last: new(Guid.NewGuid())),
Pv);
Handler
A subclass of
ClientboundHandler
parses incoming packets. A method for each packet is already declared; only the
needed ones get overridden.
sealed class Bot(MinecraftClient client, int pv) : ClientboundHandler
{
protected override ValueTask OnLoginCompress(LoginCb.LoginCompressPacket packet)
{
client.CompressionThreshold = packet.Threshold;
return default;
}
protected override async ValueTask OnKeepAlive(PlayCb.KeepAlivePacket packet)
{
await client.SendAsync(new PlaySb.KeepAlivePacket(packet.KeepAliveId), pv);
}
}
A packet without an overridden method still gets parsed, then quietly dropped:
the server sends a lot that the bot does not need. A packet lands in OnUnknown
for a different reason - when its number is not registered for this version,
phase, and direction. That is also a normal state of the stream, not an error.
Read loop
Packets arrive as a stream, and the loop that reads them lives in application code.
var bot = new Bot(client, Pv);
using var cts = new CancellationTokenSource();
Console.CancelKeyPress += (_, e) => { e.Cancel = true; cts.Cancel(); };
try
{
await foreach (var packet in client.ReadPacketsAsync(cts.Token))
await bot.HandleAsync(in packet, Pv);
}
catch (OperationCanceledException)
{
// stopped on our side, by Ctrl+C
}
catch (EndOfStreamException)
{
// the server closed the connection
}
Without a token, the read waits for the next packet for as long as the server
stays silent. The loop can also be broken from the outside - Abort or
DisposeAsync from another task - but a token is the most convenient way to do
it.
The end of a session always arrives as an exception. A clean disconnect is an
EndOfStreamException. The enumeration never ends quietly.
A packet lives only until the next read: its data is a window into a buffer, not
its own copy. Parse it right away. Do not carry it across an await.
The bot switches phases
The library itself does not decide when login is over. That happens in application code, in two places.
protected override async ValueTask OnLoginSuccess(LoginCb.LoginSuccessPacket packet)
{
await client.SendAsync(new LoginSb.LoginAcknowledgedPacket(), pv);
Phase = PacketPhase.Configuration;
}
protected override async ValueTask OnFinishConfiguration(ConfCb.FinishConfigurationPacket packet)
{
await client.SendAsync(new ConfSb.FinishConfigurationPacket(), pv);
Phase = PacketPhase.Play;
}
The server can send the player back to configuration right from the game:
StartConfiguration arrives, the bot confirms it, and sets Phase back. For
more on the full path, see
Phase and direction.
Three replies the bot needs to reach the world
The server waits for a reply to three packets, and staying silent on any of them ends with the bot stuck in configuration or kicked after spawn.
Right after LoginAcknowledged, client settings go out - language, render
distance, visible skin parts:
await client.SendAsync(new ConfSb.ClientInformationPacket(
"en_us", 2, 0, true, 0x7F, 1, false, true,
V768_Last: new(ParticleStatus.All)), pv);
Next the server sends the list of data packs it knows, and waits for the client to confirm the same list:
protected override ValueTask OnSelectKnownPacks(ConfCb.SelectKnownPacksPacket packet)
=> client.SendAsync(new ConfSb.SelectKnownPacksPacket(packet.Packs), pv);
And in play, every teleport - including the first one, at spawn - needs confirmation by its number, or the server decides the client is frozen:
protected override ValueTask OnPlayerPosition(PlayCb.PlayerPositionPacket packet)
=> client.SendAsync(new PlaySb.TeleportConfirmPacket(packet.TeleportId), pv);
Encryption
Encryption turns on even on an offline server: an
EncryptionRequestPacket
arrives, the bot replies with its own key, and turns on the cipher. Since 1.20.5
the server encrypts the stream even without a session check - the protocol
describes this on the
Encryption page, in
the History section.
using var rsa = EncryptionHelpers.DecodeRSAPublicKey(packet.PublicKey)!;
var secret = EncryptionHelpers.GenerateAESPrivateKey();
await client.SendAsync(new LoginSb.EncryptionResponsePacket(
rsa.Encrypt(secret, false),
rsa.Encrypt(packet.VerifyToken, false)), pv);
client.EnableEncryption(secret);
If the server requires session confirmation with Mojang, the example stops here.
Life in play
After spawn, the bot is in the world. From there, it needs three things.
The first is keep-alive. The server sends a number and waits for it back. It treats a silent client as frozen and closes the connection. The reply already appeared above, in the Handler section.
The second is chat. A player message arrives as a
PlayerChatPacket.
The message body sits ready as a string in PlainMessage, while the sender name
and formatting sit in separate fields nearby.
protected override ValueTask OnPlayerChat(PlayCb.PlayerChatPacket packet)
{
Console.WriteLine(packet.V770_Last?.PlainMessage);
return default;
}
System strings (join, leave, command replies) arrive as a separate
SystemChatPacket
in the OnSystemChat method, and the text in it sits as an NBT component. The
library returns the component as is. Building a string out of it is application
work.
The bot's own message goes out as a
ChatMessagePacket.
Besides the text, it carries a timestamp, a salt, and a signature, followed by
acknowledgment of other messages: the offset Offset, exactly three bytes of
Acknowledged, and a checksum Checksum. A server that does not check
signatures accepts zeros.
await client.SendAsync(new PlaySb.ChatMessagePacket(
"hello", DateTimeOffset.UtcNow.ToUnixTimeMilliseconds(), 0, null,
V770_Last: new(0, new byte[3], 0)), pv);
The third is the bot's own position.
PositionPacket
carries it: three coordinates and movement flags - whether the client is on the
ground and whether it is pressed against a wall. The packet goes out not once
but for as long as the bot moves. Without it, the server keeps the bot where the
last teleport placed it.
await client.SendAsync(new PlaySb.PositionPacket(
x, y, z, V768_Last: new(new MovementFlags(true, false))), pv);