Skip to content

Architecture

BrandonRobare edited this page Jun 2, 2026 · 1 revision

Architecture

netPong runs as one binary in two roles. The server takes a port; the client takes a host and a port. The argument count in main() decides the role. After the connection is up, both processes run the same loop and the same physics. The difference between them is which side of the court each defends, which court.h captures in a struct court.

Modules

flowchart TD
    main["pong.c (main loop and game state machine)"]
    paddle["paddle.c (paddle module)"]
    net["net.c (BSD socket setup)"]
    proto["protocol.c (SPPBTP wire format)"]
    court["court.h (per-side court geometry)"]

    main -->|moves and tests| paddle
    main -->|opens connection| net
    main -->|sends and parses lines| proto
    main -->|configures geometry| court
    proto -->|writes to / reads from| net
Loading
  • pong.c holds the main loop, the game state machine, the ball physics in bounce_or_lose(), and the curses drawing. It is the only module that knows about game rules.
  • paddle.c is a small object-style module. It owns one static paddle, draws it, moves it within bounds, and answers paddle_contact(y, x). paddle_init() takes a column argument so the same code works on the left or the right.
  • net.c wraps the BSD socket calls. make_server_socket() does socket/bind/listen, wait_for_client() does accept, and connect_to_server() does socket/connect. Nothing above this layer touches sockaddr_in.
  • protocol.c is the wire format. The send_* helpers write CRLF-terminated SPPBTP lines with dprintf, and recv_msg() reads one line and fills a tagged struct sppbtp_msg.
  • court.h declares the geometry struct and configure_side(). I pulled the four hard-coded edge constants out of the single-player version and made them runtime fields, so one engine renders either court.

I kept the protocol layer above the socket layer. protocol.c writes to a plain integer fd and reads from a FILE*; it never calls a socket function. That made it possible to test the protocol with telnet and dprintf without involving game logic.

Two-process topology

flowchart LR
    subgraph ClientProc["Client process (left court)"]
        C_main["pong.c main loop"]
        C_pad["paddle.c"]
        C_proto["protocol.c"]
        C_net["net.c"]
        C_main --> C_pad
        C_main --> C_proto
        C_proto --> C_net
    end

    subgraph ServerProc["Server process (right court)"]
        S_net["net.c"]
        S_proto["protocol.c"]
        S_pad["paddle.c"]
        S_main["pong.c main loop"]
        S_main --> S_pad
        S_main --> S_proto
        S_proto --> S_net
    end

    C_net <-->|TCP, SPPBTP text lines| S_net
Loading

The server defends the right court (SIDE_RIGHT) and the client defends the left (SIDE_LEFT). Each process draws its own paddle and the net wall on its far edge. The connection is a single TCP stream. After accept() the server closes its listening socket, so one launch serves one game.

The connection is client/server. The gameplay is peer-symmetric: once HELO/NAME/SERV finish, neither side is privileged. Each side simulates the ball on its own court and trusts the BALL line the other side sends.

Game state machine

stateDiagram-v2
    [*] --> INTRO
    INTRO --> PLAY: client serves first ball
    INTRO --> WAIT: server waits for client serve

    PLAY --> WAIT: ball crosses net (send BALL)
    PLAY --> WAIT: paddle missed (send MISS)
    WAIT --> PLAY: receive BALL
    WAIT --> PLAY: receive MISS, balls_left > 0 (re-serve)

    PLAY --> FINISHED: press Q (send QUIT)
    WAIT --> FINISHED: receive DONE (send QUIT)
    WAIT --> FINISHED: receive QUIT
    WAIT --> FINISHED: last ball missed (send DONE)
    FINISHED --> [*]
Loading

pong.c defines enum game_state { STATE_INTRO, STATE_PLAY, STATE_WAIT, STATE_FINISHED }. The whole design rests on one rule: only the process in PLAY owns the ball and runs the physics ticker. I implemented that with enter_play() and enter_wait(), which flip the state and start or stop the interval timer through set_ticker().

  • INTRO runs the handshake. The server sends HELO, reads NAME, sends SERV, then drops into WAIT. The client reads HELO, sends NAME, reads SERV, then serves and enters PLAY.
  • PLAY runs run_play_loop(). A SIGALRM every 50 ms calls bounce_or_lose() to move the ball. The loop reads k/j/Q from the keyboard. It also peeks the socket with a zero-timeout select() so it can catch an opponent QUIT without blocking.
  • WAIT runs run_wait_loop(). The ticker is off. The loop blocks on recv_msg() and dispatches: BALL re-enters PLAY, MISS either re-serves or sends DONE, DONE sends QUIT, and QUIT finishes.
  • FINISHED breaks the outer loop, tears down curses, closes the socket, and prints the final score.

Why the ticker moves with the ball

In the single-player baseline the interval timer ran the whole time. For netPong that wastes signals, because for half the game the ball is on the other court. I made set_ticker(TICK_MSECS) start it on entry to PLAY and set_ticker(0) stop it on entry to WAIT. The on_alarm() handler also guards on state != STATE_PLAY and returns early, so a stray late signal cannot move a ball that the local process no longer owns.

Buffered reads on a duplicated fd

I read protocol lines with fgets, which wants a FILE*, but I write with dprintf on the raw fd. To keep both safe I call fdopen(dup(sock_fd), "r"). The duplicate gives the read stream its own file descriptor, so closing the FILE* at shutdown does not close the fd I still write through, and the two directions never share a buffer. This follows the socklib pattern from Molay's Understanding Unix/Linux Programming, chapter 12.

Clone this wiki locally