A multithreaded, terminal-native LAN chat application written in C.
Clients connect over TCP and communicate through a central server. The interface is rendered entirely using ANSI/VT100 escape sequences — no external UI libraries, no ncurses.
- Overview
- Architecture
- Wire Protocol
- Terminal UI Design
- Building
- Usage
- Commands
- Known Bugs & Limitations
- Future Improvements
lanmsg is a prototype LAN messenger built as a systems programming exercise. It demonstrates:
- TCP socket programming with a custom binary protocol
- Multithreaded server using POSIX threads and mutex-protected shared state
- A concurrent client that reads from the network while accepting keyboard input simultaneously
- A live terminal UI built with raw ANSI escape codes, including scroll region management and terminal resize handling
The codebase is intentionally minimal: three files, no dependencies beyond the C standard library and POSIX.
┌──────────────────────────────────────────────────────────┐
│ SERVER │
│ │
│ main() accept loop │
│ │ │
│ ├──► clients[MAX_CLIENTS] (shared, mutex-guarded)│
│ │ │
│ └──► handle_client() thread (one per connection) │
│ │ │
│ ├── MSG_CHAT → send_to_all() │
│ ├── MSG_PRIV → send_private() │
│ ├── MSG_LIST → enumerate clients[] │
│ └── MSG_EXIT → mark inactive, broadcast│
└──────────────────────────────────────────────────────────┘
┌──────────────────────────────────────────────────────────┐
│ CLIENT │
│ │
│ main thread recieve_messages() thread │
│ (stdin → send) (recv → terminal output) │
│ │ │ │
│ └────── server_fd ───────┘ │
└──────────────────────────────────────────────────────────┘
Server: Single-process, multi-threaded. One thread per connected client, all sharing a clients[] array guarded by a single pthread_mutex_t. The main thread does nothing after spawning — it loops on accept().
Client: Two threads share one socket. The main thread owns stdin and sends packets; the receiver thread blocks on recv() and writes incoming messages to the terminal. Cursor state is preserved across both threads using ANSI save/restore sequences (\e7 / \e8), which acts as a crude form of output synchronization.
All communication uses a single fixed-size packet type defined in common.h:
typedef struct packet {
enum msg_type type;
char username[NAME_SIZE]; // 32 bytes — sender name
char target[NAME_SIZE]; // 32 bytes — recipient (private messages only)
size_t payload_len;
char message[SIZE]; // 256 bytes — message body
} chat_packet;Total packet size is constant regardless of message length. This simplifies framing entirely — recv(..., MSG_WAITALL) on the client side always reads exactly sizeof(chat_packet) bytes.
| Type | Direction | Description |
|---|---|---|
MSG_CONNECT |
Client → Server | Initial handshake; carries the username |
MSG_CHAT |
Bidirectional | Broadcast message to all connected clients |
MSG_PRIV |
Client → Server | Private message; target field names recipient |
MSG_LIST |
Client → Server | Request list of active users |
MSG_EXIT |
Client → Server | Graceful disconnect signal |
MSG_SERVER_ACKNW |
Server → Client | Acknowledgement of successful handshake |
MSG_SERVER_ERR |
Server → Client | Error response (e.g. unknown private target) |
MSG_SERVER_REJ |
Server → Client | Rejection of connection due to server at max capacity. |
Client Server
│ │
│──── MSG_CONNECT (username) ──►│ add_client(), log name
│◄─── MSG_SERVER_ACKNW ─────────│
│ │
│ [chat loop] │
│ │
│──── MSG_EXIT ─────────────────►│ broadcast exit, mark inactive
│ │
When the server is full, a client is rejected by the server after they enter a name. This is because the listener is not active before the name is sent over to the server (intentional architecture), and response of rejection is only received once listener becomes active. It prints the rejection message and immediately exits the program.
The client uses raw ANSI/VT100 escape sequences to build a split terminal layout without ncurses.
A screenshot of the chat from a client perspective.

Scroll region (\e[4;%dr): Instructs the terminal to only scroll rows 4 through terminal_h - 2. The header and input line are outside this region and are never overwritten by scrolling.
Cursor save/restore (\e7 / \e8): Before printing any incoming message, the receiver thread saves the cursor position, moves to the bottom of the scroll region, prints, then restores. This allows the input prompt to remain visually stable while messages arrive.
SIGWINCH: The client installs a handler for SIGWINCH (terminal resize). On resize, it re-queries terminal dimensions via ioctl(STDOUT_FILENO, TIOCGWINSZ, &w), redraws the full UI, and repositions the cursor. fgets is interrupted by the signal; errno == EINTR is checked to resume cleanly.
A screenshot showing server running

Requirements: GCC or Clang, POSIX-compliant system (Linux, macOS). Run these commands from project directory
# Compile server
gcc src/server.c -o server -lpthread
# Compile client
gcc src/client.c -o client -lpthreadStart the server:
./server # listens on 0.0.0.0:8080 (default)
./server 9090 # listens on a custom portConnect a client:
./client # connects to 127.0.0.1:8080
./client 192.168.1.10 # custom IP, default port
./client 192.168.1.10 9090 # custom IP and portPorts allowed for connection on either client or server are in the range (1024-49151).
On connect, the client prompts for a username (1–31 characters). The client does not listen for incoming messages yet. After entry, the split-pane UI is drawn and the session begins.
| Command | Description |
|---|---|
/msg <username> <message> |
Send a private message to a user |
/list |
List all currently connected users |
/quit |
Disconnect gracefully |
| (any other input) | Broadcast message to all clients |
Buffer overflow in /list:
The server builds the user list by concatenating names into the 256-byte message field using strcat with no bounds check. With MAX_CLIENTS=10 and NAME_SIZE=32, worst-case output is approximately 330 bytes — exceeding the buffer. Fix: track written length and truncate, or use snprintf with remaining capacity.
No name uniqueness check: Server does not enforce a name uniqueness policy, multiple clients with same name can exist simultaneously, a severe limitation in security.
MSG_EXIT broadcasts to the sender:
send_to_all(payload, -1) uses -1 as the exclude-fd, which never matches any real file descriptor. The departing client therefore receives their own exit notification before the connection closes. Does not happen in practice, since listener thread does not receive a reply from server before the program exits.
No mutual exclusion between output threads:
The main thread and receiver thread both write to stdout. The cursor save/restore mechanism provides visual coherence but is not atomic — a context switch between SAVE_CUR and the subsequent MOVE_TO print can corrupt the display under high message load.
Every received message should carry a timestamp. This requires either adding a time_t field to chat_packet (set by the sender) or having the server stamp packets on receipt. Server-side stamping is preferable as it gives a consistent ordering. Display as [HH:MM] prefix in the scroll region.
Currently, the username is set by the client and trusted entirely. There is no server-side enforcement of uniqueness. Two clients can connect with the same name, making private messages ambiguous and the user list misleading. The server should reject MSG_CONNECT with a duplicate name and return a new error type (e.g. MSG_NAME_TAKEN).
The scroll region discards messages once they scroll past the top of the terminal. A proper scrollback buffer (ring buffer of N recent packets, replayed on connect or on a /history command) would significantly improve usability.
All traffic is currently plaintext. For a LAN context this is acceptable, but adding TLS via OpenSSL or a simple symmetric key exchange (e.g. X25519 + ChaCha20-Poly1305) would make the tool viable on untrusted networks.
The server has no shutdown path. SIGINT abruptly closes the listening socket without notifying connected clients. A SIGINT handler should send a MSG_CHAT broadcast from "SERVER" announcing shutdown, then close all client file descriptors before exiting.
The client cap is a compile-time constant. Making it a runtime argument (or dynamically growing the clients array) would remove an arbitrary restriction.
The race condition between the main thread's echo output and the receiver thread's incoming message display in client.c should be resolved with a proper output mutex rather than relying on ANSI save/restore as a substitute for synchronization.
If the server restarts, clients have no mechanism to reconnect — exit(0) is called immediately. A reconnection loop with exponential backoff would make the client more resilient.
ANSI escape sequences are not supported on the Windows console without explicit VT processing enabled. Abstracting terminal output behind a thin compatibility layer (or detecting and falling back gracefully) would widen platform support.
.
├── common.h — shared protocol definitions (packet struct, enums, constants)
├── server.c — multi-threaded TCP server
└── client.c — terminal client with split-pane ANSI UI