Update: This is now a library! Check it out here
With Gleam's 1.0 release hot off the presses and coverage by Twitch and Youtube giants ThePrimeagen and Theo, many folks are being drawn to the language as a gentle but powerful introduction to functional programming, and I'm no exception.
One of my go-to projects for learning a new language is implementing Valve's Source RCON Protocol and in this article we're gonna learn how to do it in Gleam!
We're going to base our implementation off of the excellent RCON package for Go known as gorcon.
Let's get started!
What the hell is Gleam?
Gleam is an impure functional programming language built off of Erlang's VM, BEAM with a focus on simplicity and developer experience. It has interoperability with other BEAM languages (e.g., Erlang and Elixir) and can even compile to Javascript!
For more information, I highly encourage you to check out the language tour.
What the hell is RCON?
RCON stands for Remote Connection, and is a binary protocol developed by Steam as part of Source. It is used by many popular game servers such as Rust, Minecraft, and PalWorld, and allows you to execute commands like ShowPlayers
(PalWorld) from a remote system (such as the game client).
It has a simple wire format. In order, the fields are:
-
size
- A 32-bit little-endian signed integer representing the total size of the packet (minus itself) -
id
- Another i32-LE that can be set to any positive value. This allows a response to be matched to a request. -
type
- Another i32-LE representing what this packet is meant to do. There are four types that are widely supported:-
SERVERDATA_AUTH (3)
- Sent by the client with the password as the body to authenticate the connection to the RCON server. -
SERVERDATA_AUTH_RESPONSE (2)
- Sent by the server to indicate the auth status following aSERVERDATA_AUTH
request. -
SERVERDATA_EXECCOMMAND (2)
- Sent by the client to execute a command (such asShowPlayers
) on the server.- That's not a typo--both
SERVERDATA_AUTH_RESPONSE
andSERVERDATA_EXECCOMMAND
are represented by a2
!
- That's not a typo--both
-
SERVERDATA_RESPONSE_VALUE (0)
- Sent by the server to indicate the result of aSERVERDATA_EXECCOMMAND
request.
-
-
body
- An ASCII string that is terminated by two null bytes1
All of these fields together make a packet!
Implementing Packets
To begin, let's define some constants and helper functions that will help us later:
//// src/packet.gleam
/// How many bytes the padding (i.e., <<0x00, 0x00>>) takes up
const packet_padding_size_bytes: Int = 2
/// How many bytes the header (i.e., the id and type) takes up
const packet_header_size_bytes: Int = 8
/// Returns the byte size of the smallest possible packet, which is a packet
/// with an empty body.
fn min_packet_size_bytes() -> Int {
packet_padding_size_bytes + packet_header_size_bytes
}
/// Returns the byte size of the largest possible packet, which is a packet
/// with a 4KB body. This is a limitation set by the protocol.
fn max_packet_size_bytes() -> Int {
4096 + min_packet_size_bytes
}
Next, we'll define some types and helper functions that will make the public interface a bit nicer:
//// src/packet.gleam
// ...
/// Represents valid RCON packet types.
pub type PacketType {
ServerDataAuth
ServerDataAuthResponse
ServerDataExecCommand
ServerDataResponseValue
}
pub fn packet_type_to_int(pt: PacketType) -> Int {
case pt {
ServerDataAuth -> 3
ServerDataAuthResponse | ServerDataExecCommand -> 2
ServerDataResponseValue -> 0
}
}
/// Represents an RCON packet.
pub type Packet {
Packet(size: Int, id: Int, typ: Int, body: BitArray)
}
/// Constructs a new Packet.
pub fn new(
packet_type: PacketType,
packet_id: Int,
body: String,
) -> Result(Packet, String) {
let size =
string.byte_size(body)
+ packet_header_size_bytes
+ packet_padding_size_bytes
let max = max_packet_size_bytes()
// note: it would be good to check if the body is ASCII here too!
case size {
_ if size > max -> Error("body is larger than 4096 bytes")
_ -> {
let bytes =
body
|> bytes_builder.from_string
|> bytes_builder.to_bit_array
Ok(Packet(size, packet_id, packet_type_to_int(packet_type), bytes))
}
}
}
Great! Now we have a solid representation of a Packet in Gleam. As a bonus, as long as we use new(...)
, any Packet we construct is guaranteed to work with the protocol.
One cool bit of Gleam syntax we used is the pipe operator |>
, and it would be an injustice to not go over it quickly:
let bytes =
body
|> bytes_builder.from_string // parens optional when fn takes 1 arg
|> bytes_builder.to_bit_array
// is equivalent to:
let bytes = bytes_builder.to_bit_array(
bytes_builder.from_string(
body
)
)
This allows us to "chain" function calls in a way where that feels like a builder pattern but is really just syntactic sugar for nested function calls. Neat, right?
Now, let's get to the meat of it and implement the binary bits2! First, let's take advantage of the pipe operator again to convert a Packet into its binary format:
//// src/packet.gleam
// ...
pub fn to_bytes(packet: Packet) -> BitArray {
bytes_builder.new()
|> bytes_builder.append(<<packet.size:int-size(32)-little>>)
|> bytes_builder.append(<<packet.id:int-size(32)-little>>)
|> bytes_builder.append(<<packet.typ:int-size(32)-little>>)
|> bytes_builder.append(packet.body)
|> bytes_builder.append(<<0x00, 0x00>>)
|> bytes_builder.to_bit_array()
}
To again avoid grave injustices, let's examine the syntax for BitArray
literals:
let size_bytes = <<packet.size:int-size(32)-little>>
What we're saying here is:
-
packet.size
- Write packet.size as binary to theBitArray
-
int
- Represent it as an int -
size(32)
- With a size of 32 bits -
little
- As little-endian
Finally, let's use Gleam's excellent pattern matching to read a Packet
from a BitArray
. If you're familiar with Rust, you'll find this to be very similar to match
:
//// src/packet.gleam
// ...
pub fn from_bytes(bytes: BitArray) -> Result(Packet, String) {
let min_ps = min_packet_size_bytes()
let max_ps = max_packet_size_bytes()
// notice how pattern matching allows us to do this declaritively!
// we're basically saying "if the data is this shape, do this"
// instead of worrying about the details
case bytes {
// pull off an i32-LE called `size` and leave the `rest` as a BitArray.
// we need to read the size first so we can know how many bytes the body
// should be!
<<size:size(32)-little-int, rest:bits>> -> {
case size {
_ if size < min_ps -> {
Error("size cannot be less than min_packet_size")
}
_ if size > max_ps ->
Error("size cannot be greater than max_packet_size")
_ -> {
let body_size_bits =
{ size - packet_header_size_bytes - packet_padding_size_bytes } * 8
case rest {
// pull off the rest of the fields!
// note that this is also ensuring there isn't any "extra"
// data left over in the BitArray.
<<
id:int-size(32)-little,
typ:int-size(32)-little,
body:size(body_size_bits)-bits,
padding:size(16)-bits,
>> -> {
case typ {
3 | 2 | 0 -> {
case padding {
<<0x00, 0x00>> -> {
Ok(Packet(size, id, typ, body))
}
_ -> Error("padding must be <<0x00, 0x00>>")
}
}
_ -> Error("type must be 3, 2, or 0")
}
}
_ -> {
Error("invalid packet format")
}
}
}
}
}
_ -> Error("invalid packet format")
}
}
And that's all there is to it! Let me know in the comments if you'd like to see a part 2 where we can write the TCP logic to make a full-fledged RCON client in Gleam.
Better at Gleam than I am? Leave me a comment about how I could improve!
Who the hell are you anyway?
I'm Chandler, and my background is in Go, Rust, Zig, and begrudgingly, Typescript. I started out as a frontend engineer, but was quickly captivated by the realm of backend and systems programming. Professionally, I work as a core engineer at a healthcare startup. In my free time, I enjoy playing video games, learning new programming languages, and playing guitar.
You can catch me on đť•Ź Formerly Known As Twitter and check out my project graveyard on Github.
Top comments (1)
Nice! Math like
{size - packet_header_size_bytes - packet_padding_size_bytes } * 8
can actually go straight into thesize
modifier. That means you can match your entire binary in one go, you don't need to first extract and calculate the size.