Skip to content
Journey to Code

Part 1 of 1

One World, Many Players

A Server With Nobody to Talk To

Open the First TCP Connection in Rust

· By · 2 min read

View as Markdown

Cover reading "A Server With Nobody to Talk To" beside a solitary listener beacon and an approaching connection cable.

A multiplayer server has one awkward requirement before it can become multiplayer.

Someone has to connect.

We are going to start with two small Rust programs. The server waits on 127.0.0.1:4000. The client connects, reads one welcome message and closes. By the end, two terminals will have spoken to each other through a real TCP connection.

No rooms. No players. Barely a conversation. But it crosses the wall.

This is a sequel in skills, not source code. We are starting a separate project instead of adding a server to Rustbound Idle. If you want to compare your result after building it, the article_1 branch holds this lesson's tested checkpoint.

Create the project#

Create one Cargo package with two binaries:

Terminal
cargo new one-world-many-players
cd one-world-many-players
mkdir src/bin

Remove src/main.rs, then create this shape:

text
one-world-many-players/
├── Cargo.toml
└── src/
    └── bin/
        ├── client.rs
        └── server.rs

Cargo treats every file in src/bin/ as a separate executable. We can now run the server and client without creating two projects.

Listen for one connection#

Put this in src/bin/server.rs:

src/bin/server.rs
use std::io::{self, Write};
use std::net::TcpListener;

fn main() -> io::Result<()> {
    let listener = TcpListener::bind("127.0.0.1:4000")?;
    println!("Server listening on 127.0.0.1:4000");

    let (mut stream, address) = listener.accept()?;
    println!("Connected: {address}");
    writeln!(stream, "Welcome to One World")?;

    Ok(())
}

TcpListener::bind() asks the operating system to reserve that address for our process. It can fail if another program already owns the port, so it returns a Result.

The ? after the call means:

If this worked, give us the listener. If it failed, return the error from main().

That works because main() returns io::Result<()>. We are not hiding the failure. We are letting the function that can report it do exactly that.

accept() waits until a connection arrives. When one does, it gives us a TcpStream and the remote address. The stream is the connection. We write one newline-terminated message into it with writeln!.

Connect from the other terminal#

Put this in src/bin/client.rs:

src/bin/client.rs
use std::io::{self, BufRead, BufReader};
use std::net::TcpStream;

fn main() -> io::Result<()> {
    let stream = TcpStream::connect("127.0.0.1:4000")?;
    let mut reader = BufReader::new(stream);
    let mut message = String::new();

    reader.read_line(&mut message)?;
    print!("{message}");

    Ok(())
}

TcpStream::connect() reaches the address the server reserved. TCP gives us bytes, not messages, so the client wraps the stream in a BufReader and reads until the newline the server sent.

Start the server in one terminal:

Terminal
cargo run --bin server

Leave it waiting. Open a second terminal in the same folder and run:

Terminal
cargo run --bin client

The client prints the welcome line. The server prints the connection address, then both programs end.

Why can we use ? after TcpListener::bind() in this main() function?

What we learned#

  • one Cargo package can contain several binaries
  • TcpListener waits for incoming connections
  • TcpStream represents one TCP connection
  • BufReader can read a newline-terminated message from a byte stream
  • ? passes an error to a function that already knows how to return it

Our connection works once.

That is also exactly how many messages it can carry.

One connection. Shared world.

Comments require JavaScript.