Frontend Labs
⋯

38. Terminal project: a TCP chat server with rooms

Goal: apply Chapters 15–17, 27–29, and 34 to a real multi-client, concurrent, stateful server — the natural "advanced" successor to the cache server in Chapter 26.

package main

import (
	"bufio"
	"fmt"
	"log"
	"net"
	"strings"
	"sync"
)

type Room struct {
	mu      sync.Mutex
	clients map[string]net.Conn
}

func NewRoom() *Room { return &Room{clients: map[string]net.Conn{}} }

func (r *Room) Join(name string, conn net.Conn) {
	r.mu.Lock()
	defer r.mu.Unlock()
	r.clients[name] = conn
	r.broadcastLocked(name, "* "+name+" joined")
}

func (r *Room) Leave(name string) {
	r.mu.Lock()
	defer r.mu.Unlock()
	delete(r.clients, name)
	r.broadcastLocked(name, "* "+name+" left")
}

func (r *Room) Broadcast(from, msg string) {
	r.mu.Lock()
	defer r.mu.Unlock()
	r.broadcastLocked(from, msg)
}

// caller must hold r.mu
func (r *Room) broadcastLocked(from, msg string) {
	line := fmt.Sprintf("[%s] %s\n", from, msg)
	for name, conn := range r.clients {
		if name == from {
			continue
		}
		fmt.Fprint(conn, line)
	}
}

func handleClient(conn net.Conn, room *Room) {
	defer conn.Close()
	fmt.Fprint(conn, "enter your name: ")
	scanner := bufio.NewScanner(conn)
	if !scanner.Scan() {
		return
	}
	name := strings.TrimSpace(scanner.Text())
	if name == "" {
		return
	}

	room.Join(name, conn)
	defer room.Leave(name)

	for scanner.Scan() {
		msg := scanner.Text()
		if msg == "" {
			continue
		}
		room.Broadcast(name, msg)
	}
}

func main() {
	room := NewRoom()
	ln, err := net.Listen("tcp", ":9100")
	if err != nil {
		log.Fatal(err)
	}
	log.Println("chat server listening on :9100")
	for {
		conn, err := ln.Accept()
		if err != nil {
			log.Println("accept:", err)
			continue
		}
		go handleClient(conn, room)
	}
}
go run . &
nc localhost 9100   # open in two terminals, pick different names, chat

This uses newline-delimited framing (Chapter 34) rather than length-prefixing because it's a human-typed, line-oriented protocol — nc is a perfectly good client. Every shared piece of state (the clients map) is behind one mutex; every connection's read loop runs in its own goroutine.


Generated from real-life-golang-guide.md · 8 Oct 2026 16:36