Case Study 1: Building a Chat Application

Overview

In this case study, we build a TCP-based chat system with a server that relays messages between connected clients. This demonstrates persistent connections, a text-based protocol, and the challenges of managing multiple simultaneous clients — a preview of the threading concepts covered in Chapter 36.


Problem Statement

Build a chat system where:

  1. A server listens on a configurable port
  2. Multiple clients can connect and join a chat room
  3. When a client sends a message, the server broadcasts it to all other connected clients
  4. Clients can set a nickname with /nick <name>
  5. The server announces when clients join and leave
  6. The server displays a log of all messages

Protocol Design

Client → Server:
  /nick Rosa            Set nickname to "Rosa"
  Hello everyone!       Send a message
  /quit                 Disconnect

Server → Client:
  [Server] Welcome! Use /nick <name> to set your nickname.
  [Server] Rosa has joined the chat.
  [Rosa] Hello everyone!
  [Server] Tomas has left the chat.

All messages are single lines terminated by \r\n. The server prefixes each broadcast with the sender's nickname in square brackets.


The Server

The server maintains a list of connected clients, each with a nickname and a socket stream:

type
  TChatClient = record
    Stream: TSocketStream;
    Nickname: string;
    Active: Boolean;
  end;

var
  Clients: array[0..31] of TChatClient;
  ClientCount: Integer;

procedure BroadcastMessage(const Msg: string; ExcludeIndex: Integer = -1);
var
  I: Integer;
  Line: string;
begin
  Line := Msg + #13#10;
  for I := 0 to ClientCount - 1 do
    if Clients[I].Active and (I <> ExcludeIndex) then
    begin
      try
        Clients[I].Stream.Write(Line[1], Length(Line));
      except
        { Client disconnected — mark as inactive }
        Clients[I].Active := False;
      end;
    end;
end;

procedure HandleClient(Index: Integer);
var
  Buffer: array[0..1023] of Byte;
  BytesRead: Integer;
  Msg, Trimmed: string;
begin
  { Send welcome message }
  BroadcastMessage('[Server] ' + Clients[Index].Nickname + ' has joined the chat.');

  while Clients[Index].Active do
  begin
    BytesRead := Clients[Index].Stream.Read(Buffer, SizeOf(Buffer));
    if BytesRead <= 0 then Break;

    SetString(Msg, PChar(@Buffer[0]), BytesRead);
    Trimmed := Trim(Msg);

    if Trimmed = '' then Continue;

    { Handle commands }
    if Copy(Trimmed, 1, 6) = '/nick ' then
    begin
      Clients[Index].Nickname := Copy(Trimmed, 7, Length(Trimmed));
      BroadcastMessage('[Server] Nickname changed to ' + Clients[Index].Nickname);
    end
    else if Trimmed = '/quit' then
      Break
    else
    begin
      { Broadcast the message }
      WriteLn(Clients[Index].Nickname, ': ', Trimmed);
      BroadcastMessage('[' + Clients[Index].Nickname + '] ' + Trimmed, Index);
    end;
  end;

  { Client disconnected }
  Clients[Index].Active := False;
  BroadcastMessage('[Server] ' + Clients[Index].Nickname + ' has left the chat.');
end;

The Client

The client is simpler — it reads user input and sends it to the server, while displaying messages received from the server:

procedure RunChatClient(const Host: string; Port: Word);
var
  Socket: TInetSocket;
  UserInput: string;
  Buffer: array[0..4095] of Byte;
  BytesRead: Integer;
begin
  Socket := TInetSocket.Create(Host, Port);
  try
    WriteLn('Connected to chat server.');
    WriteLn('Commands: /nick <name>, /quit');
    WriteLn;

    { Note: A real client would use threads or select()
      to read from the socket and stdin simultaneously.
      This simplified version alternates between sending
      and receiving. }

    repeat
      { Check for incoming messages }
      { In practice, this needs non-blocking I/O or a thread }

      Write('You: ');
      ReadLn(UserInput);
      if UserInput = '' then Continue;

      UserInput := UserInput + #13#10;
      Socket.Write(UserInput[1], Length(UserInput));

    until Trim(UserInput) = '/quit'#13#10;
  finally
    Socket.Free;
  end;
end;

Key Challenge: Simultaneous Read and Write

The fundamental challenge of a chat client is that it needs to do two things at once:

  1. Read user input from the keyboard (blocking)
  2. Read messages from the server socket (also blocking)

A single thread cannot block on both simultaneously. Solutions:

  • Two threads: One reads the keyboard, one reads the socket (Chapter 36)
  • Non-blocking I/O: Set the socket to non-blocking mode and poll it between keyboard reads
  • Select/poll: Use fpSelect to wait on multiple file descriptors simultaneously

The server has the same problem — it needs to listen for new connections while handling existing clients. The standard solution is one thread per client (Chapter 36).


Lessons Learned

  1. Protocol design comes first. Define the message format before writing code. A well-designed protocol makes the implementation straightforward.

  2. Blocking I/O limits concurrency. A single thread can block on only one operation. Real chat applications require threading or asynchronous I/O.

  3. Clients disconnect unexpectedly. Network connections can drop at any time. The server must handle write failures gracefully (mark clients as inactive rather than crashing).

  4. Broadcast is O(n). Sending a message to all clients requires iterating the client list. For large-scale chat, this becomes a bottleneck that requires more sophisticated architectures (message queues, pub/sub).

  5. Text protocols are debuggable. Because our protocol is plain text, we can test the server using telnet or netcat without writing a client at all. This is invaluable during development.